diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index ba7ab04..4436094 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -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__.csv" by + # startRecording (see FileBrowser.isRecording), distinct from the + # "-.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 diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index a74d086..c4e7990 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -612,7 +612,6 @@ class WaferMapItem(QQuickPaintedItem): ox = getattr(s, "offset_x", 0.0) * r oy = getattr(s, "offset_y", 0.0) * r - # Pre-compute metrics using current scaled fonts painter.setFont(id_font) id_fm = painter.fontMetrics() id_line_h = id_fm.height()