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()
sensorsChanged = Signal()
loadedFileChanged = Signal()
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
@@ -275,17 +276,30 @@ class SessionController(QObject):
try:
data, _ = ZWaferParser().parse(file_path)
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
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
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)
if not sensors:
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
wafer_id = ""
@@ -304,6 +318,10 @@ class SessionController(QObject):
self._model.reset()
self._stats_tracker.reset()
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.sensorsChanged.emit()
self._emit_current()
@@ -330,10 +348,48 @@ class SessionController(QObject):
@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
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):
_, recs_a = read_official_csv(file_a)
else:
@@ -350,8 +406,10 @@ class SessionController(QObject):
})
return
# Use first 100 frames of each for comparison (avoid O(n²) blowup)
max_frames = min(100, len(recs_a), len(recs_b))
# Compare the full overlapping length of both runs. DTW's O(n*m) cost
# 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
for i in range(max_frames)]
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
# 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 pathlib import Path
# same sensor-parsing branch as loadFile(); file_a and file_b are now
# guaranteed to share a wafer type by the gate above.
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.zwafer_parser import ZWaferParser