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