69753e35f9
- Implement synchronous offscreen wafer map PNG rendering and CSV summary. - Add "Batch Export" button to file browser sidebar. - Adjust trend pane bottom spacer to align with About button when visible. - Fix type check errors and add pytest coverage for batch export.
821 lines
31 KiB
Python
821 lines
31 KiB
Python
"""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
|
||
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
|
||
so read_official_csv still resolves the layout on re-import.
|
||
Emits segmentExported with success + path (or error).
|
||
"""
|
||
from pathlib import Path
|
||
|
||
from pygui.backend.data.csv_recorder import CsvRecorder
|
||
|
||
source = getattr(self, "_last_split_file", "")
|
||
segments = getattr(self, "_last_split_segments", [])
|
||
if not source or not (0 <= index < len(segments)):
|
||
self.segmentExported.emit({
|
||
"success": False,
|
||
"error": "No split result to export"
|
||
})
|
||
return
|
||
|
||
seg = segments[index]
|
||
src = Path(source)
|
||
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
|
||
try:
|
||
ok = CsvRecorder.write_segment(
|
||
str(dest), str(src), seg.start_frame, seg.end_frame)
|
||
except Exception as exc:
|
||
log.warning("Segment export failed: %s", exc)
|
||
self.segmentExported.emit({"success": False, "error": str(exc)})
|
||
return
|
||
|
||
if ok:
|
||
self.segmentExported.emit({"success": True, "path": str(dest)})
|
||
else:
|
||
self.segmentExported.emit({
|
||
"success": False,
|
||
"error": "No frames in range"
|
||
})
|
||
|
||
@Slot()
|
||
@slot_error_boundary
|
||
def play(self) -> None:
|
||
self._play_timer.start(self._next_interval_ms())
|
||
self.stateChanged.emit()
|
||
|
||
@Slot()
|
||
@slot_error_boundary
|
||
def pause(self) -> None:
|
||
self._play_timer.stop()
|
||
self.stateChanged.emit()
|
||
|
||
@Slot()
|
||
@slot_error_boundary
|
||
def stop(self) -> None:
|
||
self._play_timer.stop()
|
||
self._player.seek(0)
|
||
self._emit_current()
|
||
self.stateChanged.emit()
|
||
|
||
@Slot(int)
|
||
@slot_error_boundary
|
||
def step(self, delta: int) -> None:
|
||
self._play_timer.stop()
|
||
self._player.step(delta)
|
||
self._emit_current()
|
||
self.stateChanged.emit()
|
||
|
||
@Slot(int)
|
||
@slot_error_boundary
|
||
def seek(self, i: int) -> None:
|
||
self._player.seek(i)
|
||
self._emit_current()
|
||
|
||
@Slot(float)
|
||
@slot_error_boundary
|
||
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()
|
||
self.stateChanged.emit()
|
||
return
|
||
self._player.step(1)
|
||
self._emit_current()
|
||
self._play_timer.start(self._next_interval_ms())
|
||
|
||
def _apply_pipeline(self, raw_values: list[float]) -> list[float]:
|
||
"""Apply override first, then optional cluster averaging."""
|
||
edited = self._sensor_editor.apply(raw_values)
|
||
if getattr(self, "_cluster_averaging_enabled", False):
|
||
return average_clusters(edited, getattr(self, "_active_clusters", []))
|
||
return edited
|
||
|
||
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._apply_pipeline(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._apply_pipeline(frame.values))
|
||
self._last = self._model.process(edited)
|
||
self.frameUpdated.emit()
|
||
self.stateChanged.emit()
|
||
|
||
# ---- live: stream start/stop ----
|
||
@Slot(str, str)
|
||
@slot_error_boundary
|
||
def startStream(self, port: str, family_code: str = "") -> None:
|
||
if self._reader is not None:
|
||
log.warning("startStream: StreamReader is already running.")
|
||
return
|
||
|
||
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||
from pygui.serialcomm.serial_port import SerialPort # transport open
|
||
|
||
if family_code:
|
||
try:
|
||
self._sensors = load_layout_for_wafer_id(family_code)
|
||
self.sensorsChanged.emit()
|
||
except Exception as e:
|
||
log.warning("Could not load layout for %s: %s", family_code, e)
|
||
|
||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||
if not self._active_clusters:
|
||
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||
|
||
# The new binary protocol sends payload of (sensorCount * 2) bytes
|
||
# Each sensor is a big-endian 16-bit value (Sign-Magnitude).
|
||
expected_sensors = 80 if (family_code or "") == "X" else 244
|
||
|
||
def parse_binary_frame(payload: bytes, seq: int) -> Frame:
|
||
values = []
|
||
num_sensors = min(expected_sensors, len(payload) // 2)
|
||
for i in range(num_sensors):
|
||
high_byte = payload[i * 2]
|
||
low_byte = payload[i * 2 + 1]
|
||
val16 = (high_byte << 8) | low_byte
|
||
|
||
is_negative = (val16 & 0x8000) != 0
|
||
integer_part = (val16 >> 7) & 0xFF
|
||
fractional_bits = val16 & 0x7F
|
||
fractional_part = fractional_bits / 128.0
|
||
result = integer_part + fractional_part
|
||
if is_negative:
|
||
result = -result
|
||
values.append(result)
|
||
|
||
return Frame(seq=seq, time=time.monotonic(), values=values)
|
||
|
||
# Clear out any old data from the prev sessions
|
||
self._model.reset()
|
||
self._reset_live_trend()
|
||
self._last = None
|
||
self._last_raw_frame = None
|
||
self._received_count = 0
|
||
self._error_count = 0
|
||
self.liveStatsChanged.emit()
|
||
|
||
transport = SerialPort.open_port(port, timeout=1)
|
||
# Send 'D2' command padded to 512 bytes to start the stream
|
||
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
||
transport.write(cmd)
|
||
|
||
import weakref
|
||
weak_self = weakref.ref(self)
|
||
|
||
def on_error(exc: Exception):
|
||
log.error("Live stream error: %s", exc)
|
||
ref = weak_self()
|
||
if ref is not None:
|
||
ref._liveError.emit()
|
||
ref.stopStream()
|
||
|
||
def on_frame(frame: Frame):
|
||
ref = weak_self()
|
||
if ref is not None:
|
||
ref._liveFrame.emit(frame)
|
||
|
||
self._reader = StreamReader(
|
||
transport, parse_binary_frame,
|
||
on_frame=on_frame,
|
||
on_error=on_error,
|
||
family_code=family_code or "A")
|
||
self._reader.start()
|
||
self._repaint_timer.start()
|
||
|
||
self.setMode("live")
|
||
self.stateChanged.emit()
|
||
|
||
@Slot()
|
||
@slot_error_boundary
|
||
def stopStream(self) -> None:
|
||
self._repaint_timer.stop()
|
||
self._received_count = 0
|
||
self._error_count = 0
|
||
self.liveStatsChanged.emit()
|
||
if self._reader:
|
||
transport = self._reader._transport
|
||
|
||
# Send 'D2S' command padded to 512 bytes to stop the stream
|
||
# Send BEFORE stopping the reader which will close the port on thread exit
|
||
if transport and transport.is_open:
|
||
try:
|
||
cmd = b"D2S" + (b"F" * 509)
|
||
transport.write(cmd)
|
||
transport.flush()
|
||
except Exception as exc:
|
||
log.error("Error sending stop command: %s", exc)
|
||
|
||
self._reader.stop()
|
||
self._reader = None
|
||
|
||
self.stateChanged.emit()
|
||
self.stopRecording()
|
||
|
||
@Slot(object)
|
||
@slot_error_boundary
|
||
def _on_live_frame(self, frame: Frame) -> None:
|
||
self._received_count += 1
|
||
self.liveStatsChanged.emit()
|
||
# 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._apply_pipeline(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
|
||
|
||
if self._last and self._last.stats:
|
||
avg = self._last.stats.avg
|
||
elapsed = time.monotonic() - self._stream_start_time
|
||
self.trendDelta.emit(json.dumps([[elapsed, avg]]))
|
||
|
||
def _flush_repaint(self) -> None:
|
||
if not self._dirty:
|
||
return
|
||
self._dirty = False
|
||
self.frameUpdated.emit()
|
||
self.stateChanged.emit()
|
||
|
||
def _on_live_error(self) -> None:
|
||
"""Main-thread callback for worker-thread stream errors."""
|
||
self._error_count += 1
|
||
self.liveStatsChanged.emit()
|
||
|
||
# ---- recording ----
|
||
@Slot(str, str)
|
||
@slot_error_boundary
|
||
def startRecording(self, path: str, serial: str = "") -> None:
|
||
self._recorder.start(path, self._sensors, serial)
|
||
self.recordingChanged.emit()
|
||
|
||
@Slot()
|
||
@slot_error_boundary
|
||
def stopRecording(self) -> None:
|
||
if self._recorder.is_recording:
|
||
self._recorder.stop()
|
||
self.recordingChanged.emit()
|
||
|
||
@Slot(int, float)
|
||
@slot_error_boundary
|
||
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)
|
||
@slot_error_boundary
|
||
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)
|
||
@slot_error_boundary
|
||
def clearSensorEdit(self, index: int) -> None:
|
||
"""Remove all overrides for sensor `index`."""
|
||
self._sensor_editor.clear(index)
|
||
self._reprocess_current()
|
||
|
||
@Slot()
|
||
@slot_error_boundary
|
||
def clearSensorEdits(self) -> None:
|
||
"""Remove all sensor overrides."""
|
||
self._sensor_editor.clear()
|
||
self._reprocess_current()
|
||
|
||
def _reset_live_trend(self) -> None:
|
||
"""Mark a fresh stream start for elapsed-time trend deltas."""
|
||
self._stream_start_time = time.monotonic()
|
||
self.trendReset.emit()
|
||
|