"""QML-facing controller for the live/review wafer dashboard.""" from __future__ import annotations import json import logging import time from typing import Optional from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius from pygui.backend.data.csv_recorder import CsvRecorder from pygui.backend.models.frame import Frame from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data from pygui.backend.models.sensor_editor import SensorEditor from pygui.backend.models.session_model import SessionModel, SessionUpdate from pygui.backend.models.threshold_classifier import ThresholdConfig from pygui.backend.utils import slot_error_boundary from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.serialcomm.stream_reader import StreamReader log = logging.getLogger(__name__) MODE_LIVE = "live" MODE_REVIEW = "review" class SessionController(QObject): # public signals frameUpdated = Signal() modeChanged = Signal() stateChanged = Signal() recordingChanged = Signal() sensorsChanged = Signal() loadedFileChanged = Signal() loadFileError = Signal(str) clusterAveragingEnabledChanged = Signal() liveStatsChanged = Signal() # emitted when receivedCount or errorCount change # trend: per-frame avg for live graph trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame trendReset = Signal() # live trend buffer cleared (new stream started) # NOTE: dict (QVariantMap), not object — Signal(object) delivers Python # dicts to QML as an opaque empty wrapper with no accessible fields. comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b splitResult = Signal(dict) # dict with success, segments segmentExported = Signal(dict) # dict with success, path | error # private: marshal a worker-thread frame onto the main thread _liveFrame = Signal(object) # Frame _liveError = Signal() # worker-thread error ping def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._mode = MODE_REVIEW self._model = SessionModel() self._player = FramePlayer() self._reader: Optional[StreamReader] = None self._recorder = CsvRecorder() self._sensors: list[Sensor] = [] self._last: Optional[SessionUpdate] = None self._elapsed = 0.0 self._stream_start_time: float = 0.0 self._play_timer = QTimer(self) self._play_timer.timeout.connect(self._advance) self._speed = 1.0 # Q1: coalesce live repaints to ~20 Hz; data is still processed every frame. self._repaint_timer = QTimer(self) self._repaint_timer.setInterval(50) # ~20 Hz self._repaint_timer.timeout.connect(self._flush_repaint) self._dirty = False self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection) self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection) self._sensor_editor = SensorEditor() self._last_raw_frame: Frame | None = None self._loaded_file: str = "" self._cluster_averaging_enabled = False self._active_clusters: list[list[int]] = [] self._received_count: int = 0 self._error_count: int = 0 # Comparison cache for dynamic overlap mapping self._compare_recs_a: list = [] self._compare_recs_b: list = [] self._compare_alignment_map: dict[int, int] = {} # ---- properties QML binds to ---- @Property(str, notify=modeChanged) def mode(self) -> str: return self._mode @Property(int, notify=frameUpdated) def frameIndex(self) -> int: return self._player.index @Property(int, notify=frameUpdated) def frameTotal(self) -> int: return self._player.total @Property(bool, notify=stateChanged) def playing(self) -> bool: return self._play_timer.isActive() @Property(str, notify=stateChanged) def state(self) -> str: if self._last: return self._last.state # IF we dont have data yet, but the reader is running -> Streaming if self._mode == MODE_LIVE and self._reader is not None: return "streaming" return "idle" @Property(bool, notify=recordingChanged) def recording(self) -> bool: return self._recorder.is_recording @Property(list, notify=frameUpdated) def sensorDots(self) -> list[dict]: """Per-sensor render data for the radial map.""" if not self._last: return [] out = [] for i, s in enumerate(self._sensors): v = self._last.values[i] if i < len(self._last.values) else 0.0 band = self._last.bands[i] if i < len(self._last.bands) else "in_range" out.append({"label": s.label, "x": s.x, "y": s.y, "value": round(v, 2), "band": band, "index": i}) return out @Property("QVariantList", notify=sensorsChanged) # type: ignore[arg-type] def sensorLayout(self) -> list: """[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors.""" return [ { "label": s.label, "x": s.x, "y": s.y, "side": getattr(s, "side", "right"), "offset_x": getattr(s, "offset_x", 0.0), "offset_y": getattr(s, "offset_y", 0.0), } for s in self._sensors ] @Property(str, notify=sensorsChanged) def waferShape(self) -> str: """Wafer shape: 'round' or 'square'.""" return getattr(self._sensors, "shape", "round") @Property(float, notify=sensorsChanged) def waferSize(self) -> float: """Wafer size in mm.""" return getattr(self._sensors, "size", 300.0) @Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type] def sensorValues(self) -> list: """[float] in sensor order.""" if not self._last: return [] return [round(v, 3) for v in self._last.values] @Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type] def sensorBands(self) -> list: """['in_range'|'high'|'low'] in sensor order.""" if not self._last: return [] return list(self._last.bands) @Property(float, notify=frameUpdated) def target(self) -> float: """Resolved band center (frame mean in auto mode, else set_point).""" return self._last.target if self._last else 149.0 @Property(float, notify=frameUpdated) def margin(self) -> float: """Resolved band half-width (frame 1σ in auto mode, else margin).""" return self._last.margin if self._last else 1.0 @Property(dict, notify=frameUpdated) def stats(self) -> dict: if not self._last: return {} s = self._last.stats return {"min": round(s.min, 2), "minIndex": s.min_index + 1, "max": round(s.max, 2), "maxIndex": s.max_index + 1, "diff": round(s.diff, 2), "avg": round(s.avg, 2), "sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)} @Property(str, notify=loadedFileChanged) def loadedFile(self) -> str: return self._loaded_file @Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type] def overriddenSensors(self) -> list[int]: """Indices of sensors that currently have a replacement or offset.""" return self._sensor_editor.active_indices() @Property(bool, notify=clusterAveragingEnabledChanged) def clusterAveragingEnabled(self) -> bool: return self._cluster_averaging_enabled @clusterAveragingEnabled.setter # type: ignore[no-redef] def clusterAveragingEnabled(self, value: bool) -> None: if self._cluster_averaging_enabled == value: return self._cluster_averaging_enabled = value self.clusterAveragingEnabledChanged.emit() self._reprocess_current() @Property(int, notify=liveStatsChanged) def receivedCount(self) -> int: return self._received_count @Property(int, notify=liveStatsChanged) def errorCount(self) -> int: return self._error_count @Property(int, notify=liveStatsChanged) def resyncCount(self) -> int: return self._reader.resync_count if self._reader else 0 # ---- mode + thresholds ---- @Slot(str) @slot_error_boundary def setMode(self, mode: str) -> None: if mode == self._mode: return if mode != "live": self.stopStream() self._play_timer.stop() self._mode = mode self.modeChanged.emit() @Slot(float, float, bool) @slot_error_boundary def setThresholds(self, set_point: float, margin: float, auto: bool) -> None: self._model.set_thresholds(ThresholdConfig(set_point, margin, auto)) if self._last: # re-band current frame in place self._reprocess_current() # ---- review: file load + playback ---- @Slot(str) @slot_error_boundary def loadFile(self, file_path: str) -> None: from pathlib import Path from pygui.backend.data.data_records import ( is_official_csv, read_data_records, read_official_csv, ) from pygui.backend.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size frames = [] try: if is_official_csv(file_path): sensors, records = read_official_csv(file_path) frames = frames_from_wafer_data(None, records) else: data, _ = ZWaferParser().parse(file_path) if data is None or not data.sensors: self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.") return records = read_data_records(file_path) sensors = data.sensors frames = frames_from_wafer_data(data, records) if not sensors or not frames: self.loadFileError.emit("No sensors or frames found in data file.") return except Exception as exc: log.warning("Could not parse file %s: %s", file_path, exc) self.loadFileError.emit(f"Load error: {exc}") return wafer_id = "" if not is_official_csv(file_path): wafer_id = data.serial if (data and data.serial) else "" else: stem = Path(file_path).stem wafer_id = stem.split("-")[0] if "-" in stem else stem shape, size = resolve_shape_and_size(sensors, wafer_id) self._sensors = WaferLayout(sensors, shape=shape, size=size) self._active_clusters = getattr(self._sensors, 'clusters', []) if not self._active_clusters: self._active_clusters = group_sensors_by_radius(self._sensors) self._player.load(frames) self._model.reset() self._loaded_file = file_path self.loadedFileChanged.emit() self.sensorsChanged.emit() self._emit_current() @Slot() @slot_error_boundary def unloadFile(self) -> None: """Clear the loaded file, resetting the player and frame states.""" self._player.load([]) self._model.reset() self._loaded_file = "" self.loadedFileChanged.emit() self.sensorsChanged.emit() # ---- comparison: DTW between two CSV files ---- @Slot(str, str) @slot_error_boundary def compareFiles(self, file_a: str, file_b: str) -> None: """Run DTW comparison between two CSV files and emit result.""" from pathlib import Path from pygui.backend.comparison import compare_runs from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv if Path(file_a).resolve() == Path(file_b).resolve(): self.comparisonResult.emit({ "success": False, "error": "Cannot compare a file to itself. Choose two different runs." }) return try: if is_official_csv(file_a): _, recs_a = read_official_csv(file_a) else: recs_a = read_data_records(file_a) if is_official_csv(file_b): _, recs_b = read_official_csv(file_b) else: recs_b = read_data_records(file_b) if not recs_a or not recs_b: self.comparisonResult.emit({ "success": False, "error": "No data in one or both files" }) return self._compare_recs_a = recs_a self._compare_recs_b = recs_b len_a, len_b = len(recs_a), len(recs_b) # DTW can only align frames both runs actually have; the longer # run's extra tail is still returned for display, just unaligned. overlap_frames = min(len_a, len_b) series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)] series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)] time_a = [round(recs_a[i].time, 3) for i in range(len_a)] time_b = [round(recs_b[i].time, 3) for i in range(len_b)] result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames]) self._compare_alignment_map = {i: j for i, j in result["warping_path"]} # Mean temporal shift along the DTW path: positive means run B # reaches the same profile features later than run A. path = result["warping_path"] frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0 frame_offset_seconds = round( sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2 ) if path else 0.0 # Max sensor deviation: walk the DTW-aligned frame pairs (bounded to # the overlap both runs share) and take the largest per-sensor abs # difference across all sensors, not just the sensor[0] series used # for alignment. num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0 max_sensor_deviation = 0.0 for i, j in zip(result["index_a"], result["index_b"]): values_a = recs_a[i].values values_b = recs_b[j].values for s in range(min(num_sensors, len(values_a), len(values_b))): diff = abs(values_a[s] - values_b[s]) if diff > max_sensor_deviation: max_sensor_deviation = diff # Sensor layout + per-sensor diff for the wafer overlap view. Reuses the # same sensor-parsing branch as loadFile() (file_a is assumed representative # of both files' wafer type). from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size from pygui.backend.wafer.zwafer_parser import ZWaferParser sensor_layout: list[dict] = [] sensor_diff: list[float] = [] wafer_shape = "round" wafer_size = 300.0 try: if is_official_csv(file_a): sensors, _ = read_official_csv(file_a) stem = Path(file_a).stem wafer_id = stem.split("-")[0] if "-" in stem else stem else: parsed, _ = ZWaferParser().parse(file_a) sensors = parsed.sensors if parsed else [] wafer_id = parsed.serial if (parsed and parsed.serial) else "" if sensors: wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id) last_a = recs_a[overlap_frames - 1].values last_b = recs_b[overlap_frames - 1].values n = min(len(sensors), len(last_a), len(last_b)) sensor_layout = [ { "label": s.label, "x": s.x, "y": s.y, "side": getattr(s, "side", "right"), "offset_x": getattr(s, "offset_x", 0.0), "offset_y": getattr(s, "offset_y", 0.0), } for s in sensors[:n] ] sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)] except Exception as layout_exc: log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc) self.comparisonResult.emit({ "success": True, "distance": result["distance"], # Lists, not tuples — tuples don't survive QVariant conversion "warping_path": [list(p) for p in result["warping_path"][:50]], "frame_offset": frame_offset, "frame_offset_seconds": frame_offset_seconds, "max_sensor_deviation": max_sensor_deviation, "series_a": series_a, "series_b": series_b, "time_a": time_a, "time_b": time_b, "frame_count_a": len_a, "frame_count_b": len_b, "sensor_layout": sensor_layout, "sensor_diff": sensor_diff, "wafer_shape": wafer_shape, "wafer_size": wafer_size, }) except Exception as exc: log.warning("Comparison failed: %s", exc) self.comparisonResult.emit({ "success": False, "error": str(exc) }) @Slot(int, result="QVariantList") def getSensorDiffAt(self, scrub_idx: int) -> list[float]: """Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment.""" if not self._compare_recs_a or not self._compare_recs_b: return [] idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1) if idx_a not in self._compare_alignment_map: # DTW path only covers indices up to the shorter run's length — # past that, Run B has no data at this frame, not a clampable one. return [] idx_b = self._compare_alignment_map[idx_a] values_a = self._compare_recs_a[idx_a].values values_b = self._compare_recs_b[idx_b].values n = min(len(values_a), len(values_b)) return [round(values_b[i] - values_a[i], 3) for i in range(n)] # ---- splitting: threshold-based segmentation ---- @Slot(str, float) @slot_error_boundary def splitData(self, file_path: str, threshold: float) -> None: """Segment temperature profile into Ramp/Soak/Cool phases.""" from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv from pygui.backend.splitter import segment_profile try: if is_official_csv(file_path): _, records = read_official_csv(file_path) else: records = read_data_records(file_path) if not records: self.splitResult.emit({ "success": False, "error": "No data in file" }) return # Extract average temperatures (use first sensor as proxy) avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records] segments = segment_profile(avg_temps, threshold) # cache for exportSegment (per-segment Export in SplitDialog) self._last_split_file = file_path self._last_split_segments = segments self.splitResult.emit({ "success": True, "segments": [{ "label": seg.label, "start_frame": seg.start_frame, "end_frame": seg.end_frame, "avg_temp": seg.avg_temp } for seg in segments] }) except Exception as exc: log.warning("Split failed: %s", exc) self.splitResult.emit({ "success": False, "error": str(exc) }) @Slot(int) @slot_error_boundary def exportSegment(self, index: int) -> None: """Write one segment from the last split as a standalone CSV. Output lands next to the source file as ``_