refactor: reorganize backend modules into sub-packages for models, data, visualization, wafer, and controllers
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# ===== Models Sub-package =====
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||
from pygui.backend.models.data_model import TemperatureTableModel
|
||||
from pygui.backend.models.sensor_editor import SensorEditor
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
||||
from pygui.backend.models.stability_detector import StabilityDetector
|
||||
|
||||
__all__ = [
|
||||
"Frame", "FramePlayer", "frames_from_wafer_data",
|
||||
"Stats", "compute_stats",
|
||||
"SessionModel", "SessionUpdate",
|
||||
"TemperatureTableModel",
|
||||
"SensorEditor",
|
||||
"ThresholdConfig", "classify", "resolve_bounds",
|
||||
"StabilityDetector",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""QAbstractTableModel for displaying parsed wafer temperature data in QML."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QAbstractTableModel, Qt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Column roles for the temperature data table
|
||||
ROW_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
COL_ROLE = Qt.ItemDataRole.UserRole + 2
|
||||
|
||||
|
||||
class TemperatureTableModel(QAbstractTableModel):
|
||||
"""Table model for parsed wafer temperature data.
|
||||
|
||||
Exposes a 2D list of temperature strings to QML TableView.
|
||||
Column 0 = row index, remaining columns = Sensor1, Sensor2, ...
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Any = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._data: list[list[str]] = [] # 2D array of temperature strings
|
||||
self._col_count: int = 0 # Number of sensor columns (excludes row index)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear all data."""
|
||||
self.beginResetModel()
|
||||
self._data = []
|
||||
self._col_count = 0
|
||||
self.endResetModel()
|
||||
|
||||
def load_data(self, data: list[list[str]], col_count: int) -> None:
|
||||
"""Load parsed temperature data into the model.
|
||||
|
||||
Args:
|
||||
data: 2D list of temperature strings (e.g. [["25.30", "24.80", ...], ...]).
|
||||
col_count: Number of sensor columns (may differ from data[0] length).
|
||||
"""
|
||||
self.beginResetModel()
|
||||
self._data = data
|
||||
self._col_count = col_count
|
||||
self.endResetModel()
|
||||
log.info("Loaded %d rows × %d cols into model", len(data), col_count)
|
||||
|
||||
# ---- QAbstractTableModel interface ----
|
||||
|
||||
def rowCount(self, parent: Any = None) -> int:
|
||||
return len(self._data)
|
||||
|
||||
def columnCount(self, parent: Any = None) -> int:
|
||||
# Column 0 = row index, columns 1..N = sensor values
|
||||
return self._col_count + 1
|
||||
|
||||
def data(self, index: Any, role: int = ...) -> Any:
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
row = index.row()
|
||||
col = index.column()
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if col == 0:
|
||||
return str(row + 1) # 1-based row index
|
||||
sensor_col = col - 1
|
||||
if row < len(self._data) and sensor_col < len(self._data[row]):
|
||||
return self._data[row][sensor_col]
|
||||
return "0"
|
||||
|
||||
return None
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: int, role: int = ...
|
||||
) -> Any:
|
||||
if role != Qt.ItemDataRole.DisplayRole:
|
||||
return None
|
||||
if orientation == Qt.Orientation.Horizontal:
|
||||
if section == 0:
|
||||
return "Row"
|
||||
return f"Sensor{section}"
|
||||
return str(section + 1)
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Frame:
|
||||
"""One sample across all sensors ata a point in time"""
|
||||
|
||||
seq: int # monotonically increasing
|
||||
time: float # seconds (relative or epoch)
|
||||
values: list[float] # one per sensor, in sensor-layout order
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.wafer.zwafer_models import ZWaferData
|
||||
|
||||
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
|
||||
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
||||
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
||||
|
||||
class FramePlayer:
|
||||
def __init__(self) -> None:
|
||||
self._frames: list[Frame] =[]
|
||||
self._i =0
|
||||
|
||||
def load(self, frames: list[Frame]) -> "FramePlayer":
|
||||
self._frames = frames
|
||||
self._i = 0
|
||||
return self
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
@property
|
||||
def index(self) -> int:
|
||||
return self._i
|
||||
|
||||
@property
|
||||
def at_end(self) -> bool:
|
||||
return self._i >= len(self._frames) -1
|
||||
|
||||
def current(self) -> Frame | None:
|
||||
if not self._frames:
|
||||
return None
|
||||
return self._frames[self._i]
|
||||
|
||||
def _clamp(self, i:int):
|
||||
return max(0, min(i, len(self._frames) - 1)) if self._frames else 0
|
||||
|
||||
def step(self, delta: int) -> "FramePlayer":
|
||||
self._i = self._clamp(self._i + delta)
|
||||
return self
|
||||
|
||||
def seek(self, i: int) -> "FramePlayer":
|
||||
self._i = self._clamp(i)
|
||||
return self
|
||||
|
||||
def next_frame_ms(self) -> float:
|
||||
"""Wall-clock ms until the next frame based on recorded timestamps; 0 if at end."""
|
||||
if self._i + 1 >= len(self._frames):
|
||||
return 0.0
|
||||
return (self._frames[self._i + 1].time - self._frames[self._i].time) * 1000.0
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Per-frame descriptive statistics"""
|
||||
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Stats:
|
||||
min: float; min_index: int
|
||||
max: float; max_index: int
|
||||
diff: float; avg: float
|
||||
sigma: float; three_sigma: float
|
||||
|
||||
|
||||
def compute_stats(values: list[float]) -> Stats:
|
||||
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
||||
if not clean:
|
||||
return Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
min_index, min_v = min(clean, key=lambda iv: iv[1])
|
||||
max_index, max_v = max(clean, key=lambda iv: iv[1])
|
||||
nums = [v for _, v in clean]
|
||||
avg = sum(nums) / len(nums)
|
||||
variance = sum((v - avg) ** 2 for v in nums) / len(nums)
|
||||
sigma = math.sqrt(variance)
|
||||
|
||||
return Stats(
|
||||
min=min_v,
|
||||
min_index=min_index,
|
||||
max=max_v,
|
||||
max_index=max_index,
|
||||
diff=max_v - min_v,
|
||||
avg=avg,
|
||||
sigma=sigma,
|
||||
three_sigma=3 * sigma,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Per-session sensor value overrides (replacement and/or offset)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class SensorEditor:
|
||||
def __init__(self) -> None:
|
||||
self._replacements: dict[int, float] = {}
|
||||
self._offsets: dict[int, float] = {}
|
||||
|
||||
|
||||
def set_replacement(self, index: int, value: float) -> None:
|
||||
"""Force sensor `index` to read `value` for every frame this session."""
|
||||
self._replacements[index] = value
|
||||
|
||||
def set_offset(self, index: int, value: float) -> None:
|
||||
"""Shift sensor `index` by `delta` (applied after any replacement)."""
|
||||
self._offsets[index] = value
|
||||
|
||||
def clear(self, index: int | None = None) -> None:
|
||||
"""Remove overrides. Pass an index to clear one sensor; omit to clear all."""
|
||||
if index is None:
|
||||
self._replacements.clear()
|
||||
self._offsets.clear()
|
||||
else:
|
||||
self._replacements.pop(index, None)
|
||||
self._offsets.pop(index, None)
|
||||
def has_overrides(self) -> bool:
|
||||
return bool(self._replacements or self._offsets)
|
||||
|
||||
def active_indices(self) -> list[int]:
|
||||
"""Return sorted sensor indices that have any override."""
|
||||
return sorted(set(self._replacements) | set(self._offsets))
|
||||
|
||||
|
||||
def apply(self, values: list[float]) -> list[float]:
|
||||
"""Return a new list wit all overrides applied (original unchanged)"""
|
||||
if not self.has_overrides():
|
||||
return list(values)
|
||||
result = list(values)
|
||||
for i in range(len(result)):
|
||||
v = result[i]
|
||||
if i in self._replacements:
|
||||
v = self._replacements[i]
|
||||
if i in self._offsets:
|
||||
v += self._offsets[i]
|
||||
result[i] = v
|
||||
return result
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
||||
from pygui.backend.models.stability_detector import StabilityDetector
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionUpdate:
|
||||
seq: int
|
||||
values: list[float]
|
||||
bands: list[str]
|
||||
stats: Stats
|
||||
state: str
|
||||
target: float
|
||||
margin: float
|
||||
|
||||
class SessionModel:
|
||||
def __init__(self, thresholds: ThresholdConfig | None = None) -> None:
|
||||
self._config = thresholds or ThresholdConfig()
|
||||
self._stability = StabilityDetector()
|
||||
|
||||
def set_thresholds(self, config: ThresholdConfig) -> None:
|
||||
self._config = config
|
||||
|
||||
def reset(self) -> None:
|
||||
self._stability.reset()
|
||||
|
||||
def process(self, frame: Frame) -> SessionUpdate:
|
||||
stats = compute_stats(frame.values)
|
||||
target, margin = resolve_bounds(frame.values, self._config)
|
||||
bands = [classify(v, target, margin)for v in frame.values]
|
||||
# State always track the user's process set_point, even in auto-color mode.
|
||||
state = self._stability.update(stats.avg, frame.time, self._config.set_point)
|
||||
return SessionUpdate(
|
||||
seq=frame.seq, values=frame.values, bands=bands,
|
||||
stats=stats, state=state, target=target, margin=margin,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Detect process state (Idle, Ramp, Set) from the running average temp"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
|
||||
STATE_IDLE = "idle"
|
||||
STATE_RAMP = "ramp"
|
||||
STATE_SET = "set"
|
||||
|
||||
|
||||
class StabilityDetector:
|
||||
def __init__(self, idle_below: float = 50.0, tolerance: float = 1.0,
|
||||
settle_seconds: float = 10.0) -> None:
|
||||
self._idle_below = idle_below
|
||||
self._tolerance = tolerance
|
||||
self._settle_seconds = settle_seconds
|
||||
self._near_since: Optional[float] = None # When avg entered the +- tolerance band
|
||||
|
||||
|
||||
def reset(self) -> None:
|
||||
self._near_since = None
|
||||
|
||||
def update(self, avg: float, time: float, set_point: float) ->str:
|
||||
if avg < self._idle_below:
|
||||
self._near_since = None
|
||||
return STATE_IDLE
|
||||
if abs(avg - set_point) <= self._tolerance:
|
||||
if self._near_since is None:
|
||||
self._near_since = time
|
||||
if time - self._near_since >= self._settle_seconds:
|
||||
return STATE_SET
|
||||
return STATE_RAMP
|
||||
self._near_since = None
|
||||
return STATE_RAMP
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Classify sensor values into three bands around (target, margin)
|
||||
|
||||
Auto mode derives target=mean, margin=1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
BAND_IN = "in_range"
|
||||
BAND_HIGH = "high"
|
||||
BAND_LOW = "low"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThresholdConfig:
|
||||
set_point: float = 149.0 # process target: used as band TARGET when auto=False
|
||||
margin: float = 1.0 # used as band MARGIN when auto=False
|
||||
auto: bool = True # auto=True: target=frame mean, margin=frame 1σ
|
||||
|
||||
def resolve_bounds(values: list[float], cfg: ThresholdConfig) -> tuple[float, float]:
|
||||
if not cfg.auto:
|
||||
return cfg.set_point, cfg.margin
|
||||
clean = [v for v in values if not math.isnan(v)]
|
||||
if not clean:
|
||||
return cfg.set_point, cfg.margin
|
||||
mean = sum(clean) / len(clean)
|
||||
variance = sum((v - mean) ** 2 for v in clean) / len(clean)
|
||||
return mean, math.sqrt(variance)
|
||||
|
||||
def classify(value: float, target: float, margin: float) -> str:
|
||||
if math.isnan(value):
|
||||
return BAND_IN
|
||||
if value > target + margin:
|
||||
return BAND_HIGH
|
||||
if value < target - margin:
|
||||
return BAND_LOW
|
||||
return BAND_IN
|
||||
|
||||
def classify_all(values: list[float], cfg: ThresholdConfig) -> list[str]:
|
||||
target, margin = resolve_bounds(values, cfg)
|
||||
return [classify(v, target, margin)for v in values ]
|
||||
Reference in New Issue
Block a user