ca1a514f23
## Backend — Session & playback - Add SessionController (QML-facing live/review controller) with loadedFile property for active-file highlight binding - Add SessionModel, FramePlayer, and CsvRecorder for state machine, frame seek/step/speed, and CSV recording - Add SensorEditor for per-sensor value overrides (replace + offset)
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""QML-facing controller for the live/review wafer dashboard."""
|
||
from __future__ import annotations
|
||
import logging
|
||
from typing import Any, Optional
|
||
|
||
from PySide6.QtCore import QObject, Property, QTimer, Qt, Signal, Slot
|
||
|
||
from pygui.backend.csv_recorder import CsvRecorder
|
||
from pygui.backend.frame import Frame
|
||
from pygui.backend.frame_player import FramePlayer, frames_from_wafer_data
|
||
from pygui.backend.session_model import SessionModel
|
||
from pygui.backend.threshold_classifier import ThresholdConfig
|
||
from pygui.backend.zwafer_models import Sensor
|
||
from pygui.backend.zwafer_parser import ZWaferParser
|
||
from pygui.serialcomm.stream_reader import StreamReader
|
||
from pygui.backend.sensor_editor import SensorEditor
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
MODE_LIVE = "live"
|
||
MODE_REVIEW = "review"
|
||
|
||
|
||
class SessionController(QObject):
|
||
# public signals
|
||
frameUpdated = Signal() # any property below may have changed
|
||
modeChanged = Signal()
|
||
stateChanged = Signal()
|
||
recordingChanged = Signal()
|
||
sensorsChanged = Signal()
|
||
loadedFileChanged = Signal()
|
||
# private: marshal a worker-thread frame onto the main thread
|
||
_liveFrame = Signal(object) # Frame
|
||
|
||
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 = None # last SessionUpdate
|
||
self._elapsed = 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._sensor_editor = SensorEditor()
|
||
self._last_raw_frame: Frame | None = None
|
||
self._loaded_file: str = ""
|
||
|
||
# ---- 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(str, notify=stateChanged)
|
||
def state(self) -> str: return self._last.state if self._last else "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
|
||
|
||
# ---- WaferMapItem bindings (split view of sensorDots) ----
|
||
@Property("QVariantList", notify=sensorsChanged)
|
||
def sensorLayout(self) -> list:
|
||
"""[{label, x, y}] for WaferMapItem.sensors."""
|
||
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
||
|
||
@Property("QVariantList", notify=frameUpdated)
|
||
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)
|
||
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)
|
||
def overriddenSensors(self) -> list[int]:
|
||
"""Indices of sensors that currently have a replacement or offset."""
|
||
return self._sensor_editor.active_indices()
|
||
|
||
# ---- mode + thresholds ----
|
||
@Slot(str)
|
||
def setMode(self, mode: str) -> None:
|
||
if mode == self._mode:
|
||
return
|
||
self.stopStream()
|
||
self._play_timer.stop()
|
||
self._mode = mode
|
||
self.modeChanged.emit()
|
||
|
||
@Slot(float, float, bool)
|
||
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)
|
||
def loadFile(self, file_path: str) -> None:
|
||
from pygui.backend.data_records import is_official_csv, read_data_records, read_official_csv
|
||
|
||
sensors: list[Sensor] = []
|
||
frames = []
|
||
|
||
if is_official_csv(file_path):
|
||
sensors, records = read_official_csv(file_path)
|
||
frames = frames_from_wafer_data(None, records)
|
||
else:
|
||
try:
|
||
data, _ = ZWaferParser().parse(file_path)
|
||
except (ValueError, KeyError) as exc:
|
||
log.warning("Could not parse %s: %s", file_path, exc)
|
||
return
|
||
if data is None or not data.sensors:
|
||
log.warning("Could not parse %s", file_path)
|
||
return
|
||
records = read_data_records(file_path)
|
||
sensors = data.sensors
|
||
frames = frames_from_wafer_data(data, records)
|
||
|
||
if not sensors or not frames:
|
||
log.warning("No sensors or data in %s", file_path)
|
||
return
|
||
|
||
self._sensors = sensors
|
||
self._player.load(frames)
|
||
self._model.reset()
|
||
self._loaded_file = file_path
|
||
self.loadedFileChanged.emit()
|
||
self.sensorsChanged.emit()
|
||
self._emit_current()
|
||
|
||
@Slot()
|
||
def play(self) -> None:
|
||
self._play_timer.start(self._next_interval_ms())
|
||
|
||
@Slot()
|
||
def pause(self) -> None: self._play_timer.stop()
|
||
|
||
@Slot()
|
||
def stop(self) -> None:
|
||
self._play_timer.stop()
|
||
self._player.seek(0)
|
||
self._emit_current()
|
||
|
||
@Slot(int)
|
||
def step(self, delta: int) -> None:
|
||
self._play_timer.stop()
|
||
self._player.step(delta)
|
||
self._emit_current()
|
||
|
||
@Slot(int)
|
||
def seek(self, i: int) -> None:
|
||
self._player.seek(i)
|
||
self._emit_current()
|
||
|
||
@Slot(float)
|
||
def setSpeed(self, x: float) -> None:
|
||
self._speed = max(0.1, x)
|
||
if self._play_timer.isActive():
|
||
self._play_timer.start(self._next_interval_ms())
|
||
|
||
def _next_interval_ms(self) -> int:
|
||
"""Timer interval in ms for the next frame, scaled by playback speed. Falls back to 500ms."""
|
||
ms = self._player.next_frame_ms()
|
||
if ms <= 0:
|
||
ms = 500.0
|
||
return max(1, int(ms / self._speed))
|
||
|
||
def _advance(self) -> None:
|
||
if self._player.at_end:
|
||
self._play_timer.stop()
|
||
return
|
||
self._player.step(1)
|
||
self._emit_current()
|
||
self._play_timer.start(self._next_interval_ms())
|
||
|
||
def _emit_current(self) -> None:
|
||
frame = self._player.current()
|
||
if frame is None:
|
||
return
|
||
self._last_raw_frame = frame
|
||
edited = Frame(seq=frame.seq, time=frame.time,
|
||
values=self._sensor_editor.apply(frame.values))
|
||
self._last = self._model.process(edited)
|
||
self.frameUpdated.emit()
|
||
self.stateChanged.emit()
|
||
|
||
def _reprocess_current(self) -> None:
|
||
"""Re-run the model on the last raw frame"""
|
||
frame = self._last_raw_frame
|
||
if frame is None:
|
||
return
|
||
edited = Frame(seq=frame.seq, time=frame.time,
|
||
values=self._sensor_editor.apply(frame.values))
|
||
self._last = self._model.process(edited)
|
||
self.frameUpdated.emit()
|
||
self.stateChanged.emit()
|
||
|
||
# ---- live: stream start/stop ----
|
||
@Slot(str)
|
||
def startStream(self, port: str) -> None:
|
||
from pygui.serialcomm.serial_port import SerialPort # transport open
|
||
import serial as pyserial
|
||
|
||
def parse_line(raw: str, seq: int) -> Frame:
|
||
parts = [float(x) for x in raw.split(",")]
|
||
return Frame(seq=seq, time=parts[0], values=parts[1:])
|
||
|
||
transport = pyserial.Serial(port, SerialPort.BAUDRATE, timeout=1)
|
||
self._reader = StreamReader(
|
||
transport, parse_line,
|
||
on_frame=lambda f: self._liveFrame.emit(f))
|
||
self._reader.start()
|
||
self._repaint_timer.start() # Q1: begin ~20 Hz repaints
|
||
|
||
@Slot()
|
||
def stopStream(self) -> None:
|
||
self._repaint_timer.stop()
|
||
if self._reader is not None:
|
||
self._reader.stop()
|
||
self._reader = None
|
||
self.stopRecording()
|
||
|
||
@Slot(object)
|
||
def _on_live_frame(self, frame: Frame) -> None:
|
||
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||
self._last_raw_frame = frame
|
||
edited = Frame(seq=frame.seq, time=frame.time,
|
||
values=self._sensor_editor.apply(frame.values))
|
||
self._last = self._model.process(edited)
|
||
if self._recorder.is_recording:
|
||
self._recorder.write(frame) # always record raw values, never edited
|
||
self._dirty = True
|
||
|
||
def _flush_repaint(self) -> None:
|
||
if not self._dirty:
|
||
return
|
||
self._dirty = False
|
||
self.frameUpdated.emit()
|
||
self.stateChanged.emit()
|
||
|
||
# ---- recording ----
|
||
@Slot(str, str)
|
||
def startRecording(self, path: str, serial: str = "") -> None:
|
||
self._recorder.start(path, self._sensors, serial)
|
||
self.recordingChanged.emit()
|
||
|
||
@Slot()
|
||
def stopRecording(self) -> None:
|
||
if self._recorder.is_recording:
|
||
self._recorder.stop()
|
||
self.recordingChanged.emit()
|
||
|
||
@Slot(int, float)
|
||
def replaceSensor(self, index: int, value: float) -> None:
|
||
"""Override sensor `index` to display `value` every frame."""
|
||
self._sensor_editor.set_replacement(index, value)
|
||
self._reprocess_current()
|
||
|
||
@Slot(int, float)
|
||
def offsetSensor(self, index: int, delta: float) -> None:
|
||
"""Shift sensor `index` by `delta` every frame."""
|
||
self._sensor_editor.set_offset(index, delta)
|
||
self._reprocess_current()
|
||
|
||
@Slot(int)
|
||
def clearSensorEdit(self, index: int) -> None:
|
||
"""Remove all overrides for sensor `index`."""
|
||
self._sensor_editor.clear(index)
|
||
self._reprocess_current()
|
||
|
||
@Slot()
|
||
def clearSensorEdits(self) -> None:
|
||
"""Remove all sensor overrides."""
|
||
self._sensor_editor.clear()
|
||
self._reprocess_current()
|
||
|
||
|
||
|