refactor: reorganize backend modules into sub-packages for models, data, visualization, wafer, and controllers
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# ===== Controllers Sub-package =====
|
||||
from pygui.backend.controllers.device_controller import DeviceController
|
||||
from pygui.backend.controllers.session_controller import SessionController
|
||||
|
||||
__all__ = ["DeviceController", "SessionController"]
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
"""QML-exposed controller for wafer device communication.
|
||||
|
||||
Bridges DeviceService (serialcomm) to QML via @Slot methods and signals.
|
||||
All hardware operations run synchronously on the main thread (matching
|
||||
C# Form1.cs per-operation open/close pattern). For long operations
|
||||
(erase ~15s, read up to 120s) the caller should show a loading state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Property, Qt, Signal, Slot
|
||||
|
||||
from pygui.backend.models.data_model import TemperatureTableModel
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.serialcomm.data_parser import (
|
||||
convert_to_temperatures,
|
||||
parse_binary_data,
|
||||
remove_trailing_zeros,
|
||||
save_to_csv,
|
||||
)
|
||||
from pygui.serialcomm.device_service import DeviceService
|
||||
from pygui.serialcomm.serial_port import WaferInfo
|
||||
from pygui.backend.controllers.session_controller import SessionController
|
||||
# import pygui.backend.wafer_map_item
|
||||
|
||||
stream_controller = SessionController()
|
||||
# engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceController(QObject):
|
||||
"""Controls serial communication with the temperature-sensing wafer.
|
||||
|
||||
Exposed to QML as ``deviceController`` context property.
|
||||
"""
|
||||
|
||||
# ---- public signals ----
|
||||
portsUpdated = Signal(list)
|
||||
detectResult = Signal(object) # WaferInfo dict or None
|
||||
readResult = Signal(object) # {"success": bool, "bytes": int} or {"error": str}
|
||||
eraseResult = Signal(object) # {"success": bool}
|
||||
debugResult = Signal(object) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
|
||||
parsedDataReady = Signal(object) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
|
||||
statusRestored = Signal() # Emitted when status is restored from previous session
|
||||
logMessage = Signal(str)
|
||||
activityLogUpdated = Signal(str)
|
||||
|
||||
# ---- private signals (marshal worker-thread results back to main thread) ----
|
||||
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
|
||||
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
|
||||
|
||||
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._settings = settings
|
||||
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")
|
||||
self._operation_in_progress = False
|
||||
self._activity_log: list[str] = getattr(settings, 'activity_log', [])
|
||||
self._raw_bytes: Optional[bytes] = None
|
||||
# Default save dir lives next to the settings file (~/Documents/isc_data/csv)
|
||||
from pathlib import Path
|
||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', str(Path.home() / "Documents" / "isc_data" / "csv"))
|
||||
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {})
|
||||
self._last_csv_path: str = getattr(settings, 'last_csv_path', "")
|
||||
self._selected_port: str = getattr(settings, 'selected_port', "")
|
||||
self._data_row_count: int = getattr(settings, 'data_row_count', 0)
|
||||
self._data_col_count: int = getattr(settings, 'data_col_count', 0)
|
||||
self._data_model = TemperatureTableModel(self)
|
||||
self._graph_view = GraphView(self)
|
||||
|
||||
# If we have persisted activity log, emit it to QML
|
||||
if self._activity_log:
|
||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||
|
||||
# Log status restoration and emit signal for UI updates
|
||||
if self._connection_status != "Disconnected" or self._selected_port or self._last_wafer_info:
|
||||
self._append_log("Restored previous session status")
|
||||
if self._selected_port:
|
||||
self._append_log(f"Last selected port: {self._selected_port}")
|
||||
if self._last_wafer_info:
|
||||
info = self._last_wafer_info
|
||||
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
||||
self.statusRestored.emit()
|
||||
|
||||
# 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)
|
||||
|
||||
# ---- Properties ----
|
||||
@Property(list, notify=portsUpdated)
|
||||
def availablePorts(self) -> list[str]:
|
||||
"""Currently available serial port names."""
|
||||
return self._service.enumerate_ports()
|
||||
|
||||
@Property(str, notify=portsUpdated)
|
||||
def connectionStatus(self) -> str:
|
||||
"""Current connection status string for display."""
|
||||
return self._connection_status
|
||||
|
||||
@Property(bool, notify=portsUpdated)
|
||||
def operationInProgress(self) -> bool:
|
||||
"""Whether a hardware operation is currently running."""
|
||||
return self._operation_in_progress
|
||||
|
||||
@Property(str, notify=activityLogUpdated)
|
||||
def saveDataDir(self) -> str:
|
||||
"""Directory where parsed CSV data files are saved."""
|
||||
return self._save_data_dir
|
||||
|
||||
@Slot(str)
|
||||
def setSaveDataDir(self, path: str) -> None:
|
||||
"""Set the directory for saving parsed CSV data files."""
|
||||
self._save_data_dir = path
|
||||
self._append_log(f"Save data dir set to: {path}")
|
||||
self._save_status()
|
||||
|
||||
@Property(str, notify=portsUpdated)
|
||||
def selectedPort(self) -> str:
|
||||
"""Currently selected serial port shared across the UI."""
|
||||
return self._selected_port
|
||||
|
||||
@Slot(str)
|
||||
def setSelectedPort(self, port: str) -> None:
|
||||
"""Set the active serial port from the port selector."""
|
||||
self._selected_port = port
|
||||
self._save_status()
|
||||
|
||||
@Property(int, notify=parsedDataReady)
|
||||
def dataRowCount(self) -> int:
|
||||
"""Number of rows in the parsed temperature dataset."""
|
||||
return self._data_row_count
|
||||
|
||||
@Property(int, notify=parsedDataReady)
|
||||
def dataColCount(self) -> int:
|
||||
"""Number of sensor columns in the parsed temperature dataset."""
|
||||
return self._data_col_count
|
||||
|
||||
@Property(list, notify=detectResult)
|
||||
def lastWaferInfo(self) -> list:
|
||||
"""Last detected wafer info as a flat list for QML bindings.
|
||||
|
||||
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
|
||||
"""
|
||||
info = self._last_wafer_info
|
||||
return [
|
||||
info.get("familyCode", ""),
|
||||
info.get("serialNumber", ""),
|
||||
info.get("sensorCount", 0),
|
||||
info.get("mfgDateHex", ""),
|
||||
info.get("runtime", 0),
|
||||
info.get("cycleCount", 0),
|
||||
]
|
||||
|
||||
@Property(object, notify=detectResult)
|
||||
def dataModel(self) -> TemperatureTableModel:
|
||||
"""QAbstractTableModel for the parsed temperature data table."""
|
||||
return self._data_model
|
||||
|
||||
@Property(object, notify=debugResult)
|
||||
def graphView(self) -> GraphView:
|
||||
"""GraphView for rendering sensor temperature line charts."""
|
||||
return self._graph_view
|
||||
|
||||
@Slot(result=object)
|
||||
def getChartData(self) -> dict[str, Any]:
|
||||
"""Extract chart-ready data from the current model.
|
||||
|
||||
Returns a dict with sensor names and per-sensor value lists
|
||||
suitable for GraphView.updateChart().
|
||||
"""
|
||||
if not self._data_model or self._data_model.rowCount() == 0:
|
||||
return {"success": False}
|
||||
|
||||
num_sensors = self._data_model.columnCount() - 1 # Exclude row index
|
||||
sensor_names = [f"Sensor{i+1}" for i in range(num_sensors)]
|
||||
|
||||
# Extract per-sensor values from the model
|
||||
series_data = []
|
||||
for col in range(1, self._data_model.columnCount()):
|
||||
sensor_values = []
|
||||
for row in range(self._data_model.rowCount()):
|
||||
idx = self._data_model.index(row, col)
|
||||
val = self._data_model.data(idx)
|
||||
sensor_values.append(val if val else "0")
|
||||
series_data.append(sensor_values)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sensor_names": sensor_names,
|
||||
"series": series_data,
|
||||
}
|
||||
|
||||
def _append_log(self, message: str) -> None:
|
||||
"""Append a message to the activity log and emit updated log."""
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
self._activity_log.append(f"[{ts}] {message}")
|
||||
# Keep only last 200 lines
|
||||
if len(self._activity_log) > 200:
|
||||
self._activity_log = self._activity_log[-200:]
|
||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||
|
||||
def _set_operation_progress(self, in_progress: bool) -> None:
|
||||
"""Set operation progress state and notify QML.
|
||||
|
||||
portsUpdated is the shared notify signal for availablePorts,
|
||||
connectionStatus, and operationInProgress — emitting it forces
|
||||
QML to re-evaluate all three bindings.
|
||||
"""
|
||||
self._operation_in_progress = in_progress
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
|
||||
# ---- Public Slots ----
|
||||
|
||||
@Slot()
|
||||
def refreshPorts(self) -> None:
|
||||
"""Scan for available serial ports and emit updated list."""
|
||||
ports = self._service.enumerate_ports()
|
||||
self._connection_status = "Disconnected"
|
||||
self.portsUpdated.emit(ports)
|
||||
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
||||
self._save_status()
|
||||
|
||||
@Slot()
|
||||
def detectWafer(self) -> None:
|
||||
"""Scan all serial ports for a wafer (runs in background thread).
|
||||
|
||||
Mirrors C# chkConnection(): tries every available port with s0.
|
||||
Stores the found port in _selected_port for subsequent read/erase.
|
||||
Emits detectResult with WaferInfo dict on success, or None.
|
||||
"""
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring detect request")
|
||||
return
|
||||
|
||||
self._set_operation_progress(True)
|
||||
self._connection_status = "Detecting..."
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log("Scanning all ports for wafer ...")
|
||||
|
||||
# Spawn a daemon worker — UI stays responsive during the scan.
|
||||
threading.Thread(target=self._detect_worker, daemon=True).start()
|
||||
|
||||
def _detect_worker(self) -> None:
|
||||
"""Background-thread body for detectWafer."""
|
||||
try:
|
||||
port, info = self._service.detect_all_ports()
|
||||
except Exception as exc:
|
||||
log.exception("Detect worker crashed: %s", exc)
|
||||
port, info = None, None
|
||||
self._detectFinished.emit(port, info)
|
||||
|
||||
@Slot(object, object)
|
||||
def _handle_detect_finished(
|
||||
self, port: Optional[str], info: Optional[WaferInfo]
|
||||
) -> None:
|
||||
"""Main-thread completion handler for detectWafer."""
|
||||
if info is not None:
|
||||
self._selected_port = port or ""
|
||||
self._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})"
|
||||
)
|
||||
wafer_dict = self._wafer_info_to_dict(info)
|
||||
self._last_wafer_info = wafer_dict
|
||||
self.detectResult.emit(wafer_dict)
|
||||
self._save_status()
|
||||
else:
|
||||
self._selected_port = ""
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("No wafer detected on any port")
|
||||
self._last_wafer_info = {}
|
||||
self.detectResult.emit(None)
|
||||
self._set_operation_progress(False)
|
||||
|
||||
@Slot()
|
||||
def readMemoryAsync(self) -> None:
|
||||
"""Read wafer memory in a background thread.
|
||||
|
||||
Uses the family code and port from the last detect.
|
||||
Emits readResult on completion, then auto-chains to parseAndSaveData.
|
||||
"""
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring read request")
|
||||
return
|
||||
|
||||
if not self._selected_port:
|
||||
self._append_log("No wafer detected — run Detect Wafer first")
|
||||
self.readResult.emit({"error": "No port — detect wafer first"})
|
||||
return
|
||||
|
||||
port = self._selected_port
|
||||
family_code = self._last_wafer_info.get("familyCode", "")
|
||||
|
||||
self._set_operation_progress(True)
|
||||
self._connection_status = "Reading..."
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log(
|
||||
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
||||
)
|
||||
|
||||
threading.Thread(
|
||||
target=self._read_worker,
|
||||
args=(port, family_code),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _read_worker(self, port: str, family_code: str) -> None:
|
||||
"""Background-thread body for readMemoryAsync."""
|
||||
try:
|
||||
data = self._service.read_wafer_data(port, family_code)
|
||||
except Exception as exc:
|
||||
log.exception("Read worker crashed: %s", exc)
|
||||
data = None
|
||||
self._readFinished.emit(port, family_code, data)
|
||||
|
||||
@Slot(str, str, object)
|
||||
def _handle_read_finished(
|
||||
self, port: str, family_code: str, data: Optional[bytes]
|
||||
) -> None:
|
||||
"""Main-thread completion handler for readMemoryAsync."""
|
||||
if data is not None:
|
||||
self._connection_status = "Connected"
|
||||
self._append_log(f"Read {len(data)} bytes")
|
||||
self._raw_bytes = data
|
||||
self.readResult.emit({"success": True, "bytes": len(data)})
|
||||
self._set_operation_progress(False)
|
||||
# Auto-chain: read → parse → save (matches C# behavior).
|
||||
# Parse runs on main thread — it's CPU-bound but bounded (~1s).
|
||||
self.parseAndSaveData(family_code, port)
|
||||
self._save_status()
|
||||
return
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("Read returned no data")
|
||||
self._raw_bytes = None
|
||||
self.readResult.emit({"error": "Read returned no data"})
|
||||
self._set_operation_progress(False)
|
||||
self._save_status()
|
||||
|
||||
@Slot(str)
|
||||
def eraseMemory(self, port: str) -> None:
|
||||
"""Send p1 erase command."""
|
||||
self._set_operation_progress(True)
|
||||
self._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._append_log("Erase command accepted — wait ~15 seconds")
|
||||
else:
|
||||
self._connection_status = "Disconnected"
|
||||
self._append_log("Erase command failed")
|
||||
self.eraseResult.emit({"success": ok})
|
||||
self._set_operation_progress(False)
|
||||
|
||||
@Slot(str)
|
||||
def readDebug(self, port: str) -> None:
|
||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
||||
|
||||
Emits debugResult with counts on success.
|
||||
"""
|
||||
self._set_operation_progress(True)
|
||||
self._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._append_log("D1 read failed — aborting debug read")
|
||||
self.debugResult.emit({"error": "D1 read failed"})
|
||||
self._set_operation_progress(False)
|
||||
return
|
||||
|
||||
self._append_log(f"D1 read: {len(sensor_data)} bytes")
|
||||
|
||||
# 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._append_log("F1 read failed")
|
||||
self.debugResult.emit({"error": "F1 read failed"})
|
||||
self._set_operation_progress(False)
|
||||
return
|
||||
|
||||
self._connection_status = "Connected"
|
||||
self._append_log(
|
||||
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
||||
f"{len(debug_data)} debug bytes"
|
||||
)
|
||||
self.debugResult.emit({
|
||||
"success": True,
|
||||
"sensor_bytes": len(sensor_data),
|
||||
"debug_bytes": len(debug_data),
|
||||
})
|
||||
self._set_operation_progress(False)
|
||||
|
||||
@Slot(str, str)
|
||||
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
||||
"""Parse raw bytes into temperatures and save to CSV.
|
||||
|
||||
Uses the bytes from the most recent readMemoryAsync call.
|
||||
Emits parsedDataReady with the parsed result.
|
||||
"""
|
||||
if not self._raw_bytes:
|
||||
self._append_log("No raw bytes to parse (read first)")
|
||||
self.parsedDataReady.emit({
|
||||
"success": False,
|
||||
"error": "No raw bytes available",
|
||||
})
|
||||
return
|
||||
|
||||
if not self._save_data_dir:
|
||||
self._append_log("No save data directory set")
|
||||
self.parsedDataReady.emit({
|
||||
"success": False,
|
||||
"error": "No save data directory set",
|
||||
})
|
||||
return
|
||||
|
||||
fc = family_code or (
|
||||
self._last_wafer_info.get("familyCode", "")
|
||||
if self._last_wafer_info
|
||||
else ""
|
||||
)
|
||||
serial = (
|
||||
self._last_wafer_info.get("serialNumber", "UNKNOWN")
|
||||
if self._last_wafer_info
|
||||
else "UNKNOWN"
|
||||
)
|
||||
|
||||
self._append_log(f"Parsing {len(self._raw_bytes)} bytes (family={fc or 'auto'}) ...")
|
||||
|
||||
# Step 1: Parse binary → hex strings
|
||||
hex_data = parse_binary_data(self._raw_bytes, fc)
|
||||
if hex_data is None:
|
||||
self._append_log("Binary parse failed")
|
||||
self.parsedDataReady.emit({
|
||||
"success": False,
|
||||
"error": "Binary parse failed",
|
||||
})
|
||||
return
|
||||
|
||||
rows = len(hex_data)
|
||||
cols = len(hex_data[0]) if hex_data else 0
|
||||
self._append_log(f"Parsed: {rows} rows × {cols} sensors")
|
||||
|
||||
# Step 2: Convert hex → temperatures
|
||||
temp_data = convert_to_temperatures(hex_data, fc)
|
||||
|
||||
# Step 3: Remove trailing zero rows
|
||||
remove_trailing_zeros(temp_data)
|
||||
self._append_log(f"After trim: {len(temp_data)} rows")
|
||||
|
||||
# Step 4: Save to CSV
|
||||
csv_path = save_to_csv(temp_data, fc, serial, self._save_data_dir)
|
||||
if csv_path is None:
|
||||
self._append_log("CSV save failed")
|
||||
self.parsedDataReady.emit({
|
||||
"success": False,
|
||||
"error": "CSV save failed",
|
||||
})
|
||||
return
|
||||
|
||||
self._append_log(f"Saved CSV: {csv_path}")
|
||||
|
||||
# Load data into the QAbstractTableModel
|
||||
self._data_model.load_data(temp_data, cols)
|
||||
self._data_row_count = len(temp_data)
|
||||
self._data_col_count = cols
|
||||
|
||||
# Emit parsed data to QML
|
||||
self.parsedDataReady.emit({
|
||||
"success": True,
|
||||
"rows": len(temp_data),
|
||||
"cols": cols,
|
||||
"data": temp_data,
|
||||
"csv_path": csv_path,
|
||||
"familyCode": fc,
|
||||
"serialNumber": serial,
|
||||
})
|
||||
self._save_status()
|
||||
|
||||
# ---- Status Persistence ----
|
||||
|
||||
def _save_status(self) -> None:
|
||||
"""Persist current operational status to settings."""
|
||||
# Update settings with current status values
|
||||
self._settings.connection_status = self._connection_status
|
||||
self._settings.selected_port = self._selected_port
|
||||
self._settings.last_wafer_info = self._last_wafer_info.copy()
|
||||
self._settings.save_data_dir = self._save_data_dir
|
||||
self._settings.activity_log = self._activity_log.copy()
|
||||
self._settings.data_row_count = self._data_row_count
|
||||
self._settings.data_col_count = self._data_col_count
|
||||
self._settings.last_csv_path = self._last_csv_path
|
||||
|
||||
# Save to disk
|
||||
LocalSettings.save_settings(str(LocalSettings._settings_path(self._data_dir)), self._settings)
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
@staticmethod
|
||||
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
|
||||
"""Convert WaferInfo dataclass to JSON-serialisable dict for QML."""
|
||||
return {
|
||||
"familyCode": info.family_code,
|
||||
"serialNumber": info.serial_number,
|
||||
"sensorCount": info.sensor_count,
|
||||
"mfgDateHex": info.mfg_date_hex,
|
||||
"runtime": info.runtime,
|
||||
"cycleCount": info.cycle_count,
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""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.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.session_model import SessionModel
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
from pygui.serialcomm.stream_reader import StreamReader
|
||||
from pygui.backend.models.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.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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user