refactor: reorganize backend modules into sub-packages for models, data, visualization, wafer, and controllers

This commit is contained in:
jack
2026-06-11 12:15:00 -07:00
parent b9f8032203
commit 72334795da
47 changed files with 155 additions and 60 deletions
+19
View File
@@ -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",
]
+84
View File
@@ -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)
+11
View File
@@ -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
+51
View File
@@ -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
+37
View File
@@ -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,
)
+48
View File
@@ -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
+39
View File
@@ -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 ]