feat: add load error signaling and comparison validation
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user