feat: add load error signaling and comparison validation

This commit is contained in:
jack
2026-07-06 15:43:13 -07:00
parent 88b0214582
commit d63332d619
2 changed files with 66 additions and 11 deletions
@@ -35,6 +35,7 @@ class SessionController(QObject):
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
@@ -275,17 +276,30 @@ class SessionController(QObject):
try: try:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc: except (ValueError, KeyError) as exc:
log.warning("Could not parse %s: %s", file_path, exc) msg = f"Could not parse {Path(file_path).name}: {exc}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
if data is None or not data.sensors: if data is None or not data.sensors:
log.warning("Could not parse %s", file_path) msg = f"Could not parse {Path(file_path).name}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
records = read_data_records(file_path) records = read_data_records(file_path)
sensors = data.sensors sensors = data.sensors
frames = frames_from_wafer_data(data, records) frames = frames_from_wafer_data(data, records)
if not sensors or not frames: if not sensors:
log.warning("No sensors or data in %s", file_path) msg = f"No sensor layout found in {Path(file_path).name}"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return
if not frames:
# A well-formed header with zero data rows below it — most commonly
# a live recording that was stopped before any frames arrived.
msg = f"{Path(file_path).name} has no recorded frames"
log.warning("%s", msg)
self.loadFileError.emit(msg)
return return
wafer_id = "" wafer_id = ""
@@ -304,6 +318,10 @@ class SessionController(QObject):
self._model.reset() self._model.reset()
self._stats_tracker.reset() self._stats_tracker.reset()
self._loaded_file = file_path self._loaded_file = file_path
# A file was explicitly picked for review — show it regardless of
# whichever mode (e.g. Live) was previously active, otherwise a
# successful load can be invisible to the user.
self.setMode(MODE_REVIEW)
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self._emit_current() self._emit_current()
@@ -330,10 +348,48 @@ class SessionController(QObject):
@slot_error_boundary @slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None: def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result.""" """Run DTW comparison between two CSV files and emit result."""
from pathlib import Path
from pygui.backend.comparison import compare_runs from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
try: try:
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
# startRecording (see FileBrowser.isRecording), distinct from the
# "<serial>-<timestamp>.csv" convention used for read-memory dumps.
# Comparing across those categories doesn't make sense (a live
# capture vs. a full memory readout aren't the same kind of run),
# so gate on that before even looking at wafer type.
stem_a, stem_b = Path(file_a).stem, Path(file_b).stem
is_recording_a = stem_a.lower().startswith("live_")
is_recording_b = stem_b.lower().startswith("live_")
if is_recording_a != is_recording_b:
self.comparisonResult.emit({
"success": False,
"error": "Cannot compare a recording file with a read-memory file"
})
return
# Gate on wafer type (family): comparing a P-wafer against an X-wafer
# (different sensor counts/layouts) would silently truncate the
# smaller sensor set and pair up unrelated physical sensors in the
# overlap view. Same convention as FileBrowser's waferType badge —
# first character of the filename stem, minus the "live_" prefix
# so the wafer letter is read from the serial, not the "L".
def _wafer_type(stem: str, is_recording: bool) -> str:
if is_recording:
stem = stem[len("live_"):]
return stem[0].upper() if stem else ""
type_a = _wafer_type(stem_a, is_recording_a)
type_b = _wafer_type(stem_b, is_recording_b)
if type_a and type_b and type_a != type_b:
self.comparisonResult.emit({
"success": False,
"error": f"Cannot compare different wafer types: {type_a} vs {type_b}"
})
return
if is_official_csv(file_a): if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a) _, recs_a = read_official_csv(file_a)
else: else:
@@ -350,8 +406,10 @@ class SessionController(QObject):
}) })
return return
# Use first 100 frames of each for comparison (avoid O(n²) blowup) # Compare the full overlapping length of both runs. DTW's O(n*m) cost
max_frames = min(100, len(recs_a), len(recs_b)) # matrix is cheap even at a few thousand frames (~26MB at 1800x1800),
# so only cap against pathologically long files, not typical runs.
max_frames = min(5000, len(recs_a), len(recs_b))
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
for i in range(max_frames)] for i in range(max_frames)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
@@ -380,10 +438,8 @@ class SessionController(QObject):
max_sensor_deviation = diff max_sensor_deviation = diff
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the # Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
# same sensor-parsing branch as loadFile() (file_a is assumed representative # same sensor-parsing branch as loadFile(); file_a and file_b are now
# of both files' wafer type). # guaranteed to share a wafer type by the gate above.
from pathlib import Path
from pygui.backend.data.data_records import is_official_csv, read_official_csv from pygui.backend.data.data_records import is_official_csv, read_official_csv
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -612,7 +612,6 @@ class WaferMapItem(QQuickPaintedItem):
ox = getattr(s, "offset_x", 0.0) * r ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 0.0) * r oy = getattr(s, "offset_y", 0.0) * r
# Pre-compute metrics using current scaled fonts
painter.setFont(id_font) painter.setFont(id_font)
id_fm = painter.fontMetrics() id_fm = painter.fontMetrics()
id_line_h = id_fm.height() id_line_h = id_fm.height()