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)
y = np.array(series_b, dtype=float)
if x.size == 0 or y.size == 0:
return {"distance": float('inf'), "index_a": [],"index_b": [], "warping_path": []}
# Filter out NaN/Inf values so dtw doesn't choke on them.
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)
return {
@@ -58,8 +58,8 @@ class DeviceController(QObject):
self._data_dir = data_dir
self._service = DeviceService(settings)
# Initialize status from persisted settings (with defaults for new installations)
self._connection_status = getattr(settings, 'connection_status', "Disconnected")
# Always start fresh — never restore connection status from a prior session
self._connection_status = "Disconnected"
self._operation_in_progress = False
self._activity_log: list[str] = getattr(settings, 'activity_log', [])
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.statusRestored.emit()
# Reset connection status on fresh start
self._connection_status = "Disconnected"
# Marshal worker-thread results onto the Qt main thread.
self._detectFinished.connect(self._handle_detect_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."""
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)
def connectionStatus(self) -> str:
"""Current connection status string for display."""
@@ -223,7 +232,7 @@ class DeviceController(QObject):
@slot_error_boundary
def clearSession(self) -> None:
"""Clear the current session state, allowing a clean detect."""
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._selected_port = ""
self._last_wafer_info = {}
self._data_row_count = 0
@@ -235,6 +244,7 @@ class DeviceController(QObject):
self._append_log("Session refreshed/cleared")
self._save_status()
def _set_operation_progress(self, in_progress: bool) -> None:
"""Set operation progress state and notify QML.
@@ -252,7 +262,7 @@ class DeviceController(QObject):
def refreshPorts(self) -> None:
"""Scan for available serial ports and emit updated list."""
ports = self._service.enumerate_ports()
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self.portsUpdated.emit(ports)
self._append_log(f"Scanned {len(ports)} serial port(s)")
self._save_status()
@@ -271,7 +281,7 @@ class DeviceController(QObject):
return
self._set_operation_progress(True)
self._connection_status = "Detecting..."
self._set_connection_status("Detecting...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log("Scanning all ports for wafer ...")
@@ -295,7 +305,7 @@ class DeviceController(QObject):
"""Main-thread completion handler for detectWafer."""
if info is not None:
self._selected_port = port or ""
self._connection_status = "Connected"
self._set_connection_status("Connected")
self._append_log(
f"Detected: {info.serial_number} (family={info.family_code}, "
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
@@ -306,7 +316,7 @@ class DeviceController(QObject):
self._save_status()
else:
self._selected_port = ""
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("No wafer detected on any port")
self._last_wafer_info = {}
self.detectResult.emit(None)
@@ -334,7 +344,7 @@ class DeviceController(QObject):
family_code = self._last_wafer_info.get("familyCode", "")
self._set_operation_progress(True)
self._connection_status = "Reading..."
self._set_connection_status("Reading...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
@@ -362,7 +372,7 @@ class DeviceController(QObject):
) -> None:
"""Main-thread completion handler for readMemoryAsync."""
if data is not None:
self._connection_status = "Connected"
self._set_connection_status("Connected")
self._append_log(f"Read {len(data)} bytes")
self._raw_bytes = 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._save_status()
return
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("Read returned no data")
self._raw_bytes = None
self.readResult.emit({"error": "Read returned no data"})
@@ -382,16 +392,16 @@ class DeviceController(QObject):
def eraseMemory(self, port: str) -> None:
"""Send p1 erase command."""
self._set_operation_progress(True)
self._connection_status = "Erasing..."
self._set_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
ok = self._service.erase_wafer(port)
if ok:
self._connection_status = "Connected"
self._set_connection_status("Connected")
self._append_log("Erase command accepted — wait ~15 seconds")
else:
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("Erase command failed")
self.eraseResult.emit({"success": ok})
self._set_operation_progress(False)
@@ -404,14 +414,14 @@ class DeviceController(QObject):
Emits debugResult with counts on success.
"""
self._set_operation_progress(True)
self._connection_status = "Reading debug..."
self._set_connection_status("Reading debug...")
self.portsUpdated.emit(self._service.enumerate_ports())
self._append_log(f"Reading debug data on {port} ...")
# Step 1: D1 sensor data
sensor_data = self._service.read_wafer_data(port, family_code="")
if sensor_data is None:
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("D1 read failed — aborting debug read")
self.debugResult.emit({"error": "D1 read failed"})
self._set_operation_progress(False)
@@ -422,13 +432,13 @@ class DeviceController(QObject):
# Step 2: F1 debug data
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
if debug_data is None:
self._connection_status = "Disconnected"
self._set_connection_status("Disconnected")
self._append_log("F1 read failed")
self.debugResult.emit({"error": "F1 read failed"})
self._set_operation_progress(False)
return
self._connection_status = "Connected"
self._set_connection_status("Connected")
self._append_log(
f"Debug read complete: {len(sensor_data)} sensor bytes, "
f"{len(debug_data)} debug bytes"
@@ -87,12 +87,7 @@ class SessionController(QObject):
def _load_settings(self, settings: dict) -> None:
"""Restore session state from persisted settings dict."""
last_file = settings.get("session_last_file", "")
if last_file:
try:
self.loadFile(last_file)
except Exception:
log.warning("Could not restore last session file: %s", last_file)
# Do NOT auto-restore the last session file — always start fresh.
def collect_settings(self) -> dict:
"""Return a dict of session state to persist."""
+4 -5
View File
@@ -2,14 +2,14 @@ import numpy as np
class ReplayStatsTracker:
"""Accumulates per-frames statistic acrossa replay session."""
"""Accumulates per-frame statistics across a replay session."""
def __init__(self) -> None:
self.history: list[dict] = []
self.running_avg: float = 0.0
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)]
if clean_arr.size == 0:
@@ -24,12 +24,11 @@ class ReplayStatsTracker:
}
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
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]
def reset(self) -> None: