feat(backend): introduce session controller and status models

Implement DeviceController and SessionController to bridge QML requests with backend state, supporting playback replay tracking, cluster averaging, and slot error boundaries.
This commit is contained in:
jack
2026-06-25 13:47:21 -07:00
parent 470f8acba3
commit 2cbc143c20
4 changed files with 40 additions and 30 deletions
+8 -2
View File
@@ -17,8 +17,14 @@ Returns:
x = np.array(series_a, dtype=float) x = np.array(series_a, dtype=float)
y = np.array(series_b, dtype=float) y = np.array(series_b, dtype=float)
if x.size == 0 or y.size == 0: # Filter out NaN/Inf values so dtw doesn't choke on them.
return {"distance": float('inf'), "index_a": [],"index_b": [], "warping_path": []} mask_x = np.isfinite(x)
mask_y = np.isfinite(y)
if not mask_x.any() or not mask_y.any():
return {"distance": float('inf'), "index_a": [], "index_b": [], "warping_path": []}
x = x[mask_x]
y = y[mask_y]
alignment = dtw(x, y, keep_internals=True) alignment = dtw(x, y, keep_internals=True)
return { return {
@@ -58,8 +58,8 @@ class DeviceController(QObject):
self._data_dir = data_dir self._data_dir = data_dir
self._service = DeviceService(settings) self._service = DeviceService(settings)
# Initialize status from persisted settings (with defaults for new installations) # Always start fresh — never restore connection status from a prior session
self._connection_status = getattr(settings, 'connection_status', "Disconnected") self._connection_status = "Disconnected"
self._operation_in_progress = False self._operation_in_progress = False
self._activity_log: list[str] = getattr(settings, 'activity_log', []) self._activity_log: list[str] = getattr(settings, 'activity_log', [])
self._raw_bytes: Optional[bytes] = None self._raw_bytes: Optional[bytes] = None
@@ -87,6 +87,9 @@ class DeviceController(QObject):
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})") self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
self.statusRestored.emit() self.statusRestored.emit()
# Reset connection status on fresh start
self._connection_status = "Disconnected"
# Marshal worker-thread results onto the Qt main thread. # Marshal worker-thread results onto the Qt main thread.
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection) self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection) self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
@@ -101,6 +104,12 @@ class DeviceController(QObject):
"""Currently available serial port names.""" """Currently available serial port names."""
return self._service.enumerate_ports() return self._service.enumerate_ports()
def _set_connection_status(self, status: str) -> None:
"""Update connection status and notify QML."""
if self._connection_status != status:
self._connection_status = status
self.portsUpdated.emit(self.availablePorts)
@Property(str, notify=portsUpdated) @Property(str, notify=portsUpdated)
def connectionStatus(self) -> str: def connectionStatus(self) -> str:
"""Current connection status string for display.""" """Current connection status string for display."""
@@ -223,7 +232,7 @@ class DeviceController(QObject):
@slot_error_boundary @slot_error_boundary
def clearSession(self) -> None: def clearSession(self) -> None:
"""Clear the current session state, allowing a clean detect.""" """Clear the current session state, allowing a clean detect."""
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._selected_port = "" self._selected_port = ""
self._last_wafer_info = {} self._last_wafer_info = {}
self._data_row_count = 0 self._data_row_count = 0
@@ -235,6 +244,7 @@ class DeviceController(QObject):
self._append_log("Session refreshed/cleared") self._append_log("Session refreshed/cleared")
self._save_status() self._save_status()
def _set_operation_progress(self, in_progress: bool) -> None: def _set_operation_progress(self, in_progress: bool) -> None:
"""Set operation progress state and notify QML. """Set operation progress state and notify QML.
@@ -252,7 +262,7 @@ class DeviceController(QObject):
def refreshPorts(self) -> None: def refreshPorts(self) -> None:
"""Scan for available serial ports and emit updated list.""" """Scan for available serial ports and emit updated list."""
ports = self._service.enumerate_ports() ports = self._service.enumerate_ports()
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self.portsUpdated.emit(ports) self.portsUpdated.emit(ports)
self._append_log(f"Scanned {len(ports)} serial port(s)") self._append_log(f"Scanned {len(ports)} serial port(s)")
self._save_status() self._save_status()
@@ -271,7 +281,7 @@ class DeviceController(QObject):
return return
self._set_operation_progress(True) self._set_operation_progress(True)
self._connection_status = "Detecting..." self._set_connection_status("Detecting...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log("Scanning all ports for wafer ...") self._append_log("Scanning all ports for wafer ...")
@@ -295,7 +305,7 @@ class DeviceController(QObject):
"""Main-thread completion handler for detectWafer.""" """Main-thread completion handler for detectWafer."""
if info is not None: if info is not None:
self._selected_port = port or "" self._selected_port = port or ""
self._connection_status = "Connected" self._set_connection_status("Connected")
self._append_log( self._append_log(
f"Detected: {info.serial_number} (family={info.family_code}, " f"Detected: {info.serial_number} (family={info.family_code}, "
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})" f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
@@ -306,7 +316,7 @@ class DeviceController(QObject):
self._save_status() self._save_status()
else: else:
self._selected_port = "" self._selected_port = ""
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._append_log("No wafer detected on any port") self._append_log("No wafer detected on any port")
self._last_wafer_info = {} self._last_wafer_info = {}
self.detectResult.emit(None) self.detectResult.emit(None)
@@ -334,7 +344,7 @@ class DeviceController(QObject):
family_code = self._last_wafer_info.get("familyCode", "") family_code = self._last_wafer_info.get("familyCode", "")
self._set_operation_progress(True) self._set_operation_progress(True)
self._connection_status = "Reading..." self._set_connection_status("Reading...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log( self._append_log(
f"Reading memory on {port} (family={family_code or 'auto'}) ..." f"Reading memory on {port} (family={family_code or 'auto'}) ..."
@@ -362,7 +372,7 @@ class DeviceController(QObject):
) -> None: ) -> None:
"""Main-thread completion handler for readMemoryAsync.""" """Main-thread completion handler for readMemoryAsync."""
if data is not None: if data is not None:
self._connection_status = "Connected" self._set_connection_status("Connected")
self._append_log(f"Read {len(data)} bytes") self._append_log(f"Read {len(data)} bytes")
self._raw_bytes = data self._raw_bytes = data
self.readResult.emit({"success": True, "bytes": len(data)}) self.readResult.emit({"success": True, "bytes": len(data)})
@@ -370,7 +380,7 @@ class DeviceController(QObject):
self._append_log("Memory read complete - choose save directory to save CSV") self._append_log("Memory read complete - choose save directory to save CSV")
self._save_status() self._save_status()
return return
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._append_log("Read returned no data") self._append_log("Read returned no data")
self._raw_bytes = None self._raw_bytes = None
self.readResult.emit({"error": "Read returned no data"}) self.readResult.emit({"error": "Read returned no data"})
@@ -382,16 +392,16 @@ class DeviceController(QObject):
def eraseMemory(self, port: str) -> None: def eraseMemory(self, port: str) -> None:
"""Send p1 erase command.""" """Send p1 erase command."""
self._set_operation_progress(True) self._set_operation_progress(True)
self._connection_status = "Erasing..." self._set_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...") self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
ok = self._service.erase_wafer(port) ok = self._service.erase_wafer(port)
if ok: if ok:
self._connection_status = "Connected" self._set_connection_status("Connected")
self._append_log("Erase command accepted — wait ~15 seconds") self._append_log("Erase command accepted — wait ~15 seconds")
else: else:
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._append_log("Erase command failed") self._append_log("Erase command failed")
self.eraseResult.emit({"success": ok}) self.eraseResult.emit({"success": ok})
self._set_operation_progress(False) self._set_operation_progress(False)
@@ -404,14 +414,14 @@ class DeviceController(QObject):
Emits debugResult with counts on success. Emits debugResult with counts on success.
""" """
self._set_operation_progress(True) self._set_operation_progress(True)
self._connection_status = "Reading debug..." self._set_connection_status("Reading debug...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Reading debug data on {port} ...") self._append_log(f"Reading debug data on {port} ...")
# Step 1: D1 sensor data # Step 1: D1 sensor data
sensor_data = self._service.read_wafer_data(port, family_code="") sensor_data = self._service.read_wafer_data(port, family_code="")
if sensor_data is None: if sensor_data is None:
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._append_log("D1 read failed — aborting debug read") self._append_log("D1 read failed — aborting debug read")
self.debugResult.emit({"error": "D1 read failed"}) self.debugResult.emit({"error": "D1 read failed"})
self._set_operation_progress(False) self._set_operation_progress(False)
@@ -422,13 +432,13 @@ class DeviceController(QObject):
# Step 2: F1 debug data # Step 2: F1 debug data
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1") debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
if debug_data is None: if debug_data is None:
self._connection_status = "Disconnected" self._set_connection_status("Disconnected")
self._append_log("F1 read failed") self._append_log("F1 read failed")
self.debugResult.emit({"error": "F1 read failed"}) self.debugResult.emit({"error": "F1 read failed"})
self._set_operation_progress(False) self._set_operation_progress(False)
return return
self._connection_status = "Connected" self._set_connection_status("Connected")
self._append_log( self._append_log(
f"Debug read complete: {len(sensor_data)} sensor bytes, " f"Debug read complete: {len(sensor_data)} sensor bytes, "
f"{len(debug_data)} debug bytes" f"{len(debug_data)} debug bytes"
@@ -87,12 +87,7 @@ class SessionController(QObject):
def _load_settings(self, settings: dict) -> None: def _load_settings(self, settings: dict) -> None:
"""Restore session state from persisted settings dict.""" """Restore session state from persisted settings dict."""
last_file = settings.get("session_last_file", "") # Do NOT auto-restore the last session file — always start fresh.
if last_file:
try:
self.loadFile(last_file)
except Exception:
log.warning("Could not restore last session file: %s", last_file)
def collect_settings(self) -> dict: def collect_settings(self) -> dict:
"""Return a dict of session state to persist.""" """Return a dict of session state to persist."""
+3 -4
View File
@@ -2,14 +2,14 @@ import numpy as np
class ReplayStatsTracker: class ReplayStatsTracker:
"""Accumulates per-frames statistic acrossa replay session.""" """Accumulates per-frame statistics across a replay session."""
def __init__(self) -> None: def __init__(self) -> None:
self.history: list[dict] = [] self.history: list[dict] = []
self.running_avg: float = 0.0 self.running_avg: float = 0.0
def track(self, frame_index: int, temperatures: list[float]) -> dict: def track(self, frame_index: int, temperatures: list[float]) -> dict:
arr = np.array(temperatures, dtype = np.float32) arr = np.array(temperatures, dtype=np.float32)
clean_arr = arr[np.isfinite(arr)] clean_arr = arr[np.isfinite(arr)]
if clean_arr.size == 0: if clean_arr.size == 0:
@@ -24,12 +24,11 @@ class ReplayStatsTracker:
} }
self.history.append(stats) self.history.append(stats)
if self.history:
self.running_avg = float(np.mean([s["avg"] for s in self.history])) self.running_avg = float(np.mean([s["avg"] for s in self.history]))
return stats return stats
def get_trend(self) -> list[float]: def get_trend(self) -> list[float]:
"""Return list of per-frame average for trend chat""" """Return list of per-frame averages for the trend chart."""
return [s["avg"] for s in self.history] return [s["avg"] for s in self.history]
def reset(self) -> None: def reset(self) -> None: