From 31a4819cf0f41f3c52b06e5bed8d49a0142425dc Mon Sep 17 00:00:00 2001 From: Peter Jacobson Date: Mon, 13 Jul 2026 11:20:11 +1000 Subject: [PATCH] Harden processing replay and scientific exports --- probeflow/analysis/grains.py | 8 +- probeflow/cli/commands/processing.py | 27 +++- probeflow/cli/parser.py | 2 + probeflow/cli/processing_ops.py | 16 ++- probeflow/core/file_type.py | 10 +- probeflow/core/indexing.py | 6 +- probeflow/core/scan_model.py | 13 +- probeflow/core/source_identity.py | 9 +- probeflow/gui/app.py | 25 +++- probeflow/gui/dialogs/image_arithmetic.py | 11 +- probeflow/gui/roi_manager_dock.py | 15 ++- probeflow/gui/tv.py | 9 +- .../image_viewer_processing_export_mixin.py | 20 ++- probeflow/gui/viewer/png_export.py | 24 +++- probeflow/gui/viewer/roi_analysis.py | 14 +- probeflow/io/common.py | 19 +-- probeflow/io/readers/createc_dat.py | 5 +- probeflow/io/writers/gwy.py | 27 ++-- probeflow/processing/history.py | 13 +- probeflow/processing/state.py | 56 +++++++- tests/test_browse_folder_filters.py | 22 ++++ tests/test_common.py | 18 ++- tests/test_folder_index_shallow.py | 17 +++ tests/test_gui_processing_panel.py | 124 ++++++++++++++++++ tests/test_processing_history_cli.py | 15 +++ tests/test_processing_physics.py | 17 +++ tests/test_processing_state.py | 56 ++++++++ tests/test_scan_model_roundtrip.py | 29 ++++ tests/test_scan_writers.py | 29 ++++ tests/test_viewer_qt_free.py | 51 +++++++ 30 files changed, 629 insertions(+), 78 deletions(-) diff --git a/probeflow/analysis/grains.py b/probeflow/analysis/grains.py index d55d80e6..7dd7b0fa 100644 --- a/probeflow/analysis/grains.py +++ b/probeflow/analysis/grains.py @@ -288,8 +288,12 @@ def measure_periodicity( freq_m = math.sqrt(freq_m_x**2 + freq_m_y**2) period_m = 1.0 / freq_m if freq_m > 0 else 0.0 - angle_deg = math.degrees(math.atan2(fy * pixel_size_y_m, - fx * pixel_size_x_m)) + # Orientation belongs to the physical reciprocal vector. Multiplying + # the pixel frequencies by pixel size (the old implementation) applies + # anisotropy in the wrong direction and effectively squares the aspect + # ratio error. The period above already uses these calibrated + # components, so derive the angle from the same vector. + angle_deg = math.degrees(math.atan2(freq_m_y, freq_m_x)) results.append({ 'period_m': period_m, diff --git a/probeflow/cli/commands/processing.py b/probeflow/cli/commands/processing.py index c4baecb3..79c2233e 100644 --- a/probeflow/cli/commands/processing.py +++ b/probeflow/cli/commands/processing.py @@ -16,7 +16,6 @@ _Op, _parse_processing_steps, _processing_state_from_ops, - _record_op, _write_output, ) @@ -40,9 +39,19 @@ def _cmd_pipeline(args) -> int: if args.plane >= scan.n_planes: log.error("Plane %d not present (file has %d)", args.plane, scan.n_planes) return 1 - for op in ops: - scan.planes[args.plane] = op(scan.planes[args.plane]) - _record_op(scan, op.name, op.params) + state = _processing_state_from_ops(ops) + if state.steps: + from probeflow.processing.state import apply_processing_state_with_calibration + + scan.planes[args.plane], new_range = apply_processing_state_with_calibration( + scan.planes[args.plane], + state, + scan_range_m=scan.scan_range_m, + strict=True, + ) + if new_range is not None: + scan.scan_range_m = new_range + scan.record_processing_state(state) try: _write_output(args, scan, default_suffix=".sxm") except Exception as exc: @@ -104,6 +113,7 @@ def _cmd_prepare_png(args) -> int: mask_set = None new_plane, new_range = apply_processing_state_with_calibration( plane, state, roi_set, mask_set=mask_set, scan_range_m=raw_range, + strict=True, ) scan.planes[args.plane] = new_plane if new_range is not None: @@ -212,7 +222,11 @@ def _cmd_plane_bg(args) -> int: params_hist: dict = {"order": args.order} if args.step_tolerance: + step_threshold_deg = float(getattr(args, "step_threshold_deg", 3.0)) params_hist["step_tolerance"] = True + params_hist["step_threshold_deg"] = step_threshold_deg + else: + step_threshold_deg = 3.0 if fit_roi is not None: params_hist["fit_roi"] = getattr(fit_roi, "name", "inline") if apply_roi is not None: @@ -221,6 +235,8 @@ def _cmd_plane_bg(args) -> int: params_hist["exclude_roi"] = getattr(exclude_roi, "name", "inline") def _bg_op(a: np.ndarray) -> np.ndarray: + from probeflow.cli.processing_ops import _pixel_sizes_m_from_scan + psx, psy = _pixel_sizes_m_from_scan(scan) return _proc.subtract_background( a, order=args.order, @@ -228,6 +244,9 @@ def _bg_op(a: np.ndarray) -> np.ndarray: apply_roi=apply_roi, exclude_roi=exclude_roi, step_tolerance=args.step_tolerance, + step_threshold_deg=step_threshold_deg, + pixel_size_x_m=psx, + pixel_size_y_m=psy, ) op = _Op("plane_bg", params_hist, fn=_bg_op) diff --git a/probeflow/cli/parser.py b/probeflow/cli/parser.py index d55e79b1..366976a3 100644 --- a/probeflow/cli/parser.py +++ b/probeflow/cli/parser.py @@ -124,6 +124,8 @@ def _build_parser() -> argparse.ArgumentParser: help="Polynomial order (1=plane, 2=quadratic, 3=cubic, 4=quartic)") plane_bg.add_argument("--step-tolerance", action="store_true", help="Exclude step-edge pixels from the polynomial fit") + plane_bg.add_argument("--step-threshold-deg", type=float, default=3.0, + help="Maximum physical surface slope retained by --step-tolerance (degrees)") plane_bg.add_argument("--fit-roi", type=str, default=None, metavar="NAME_OR_ID", help="Fit background to pixels within this persisted ROI only") plane_bg.add_argument("--fit-roi-rect", type=float, nargs=4, diff --git a/probeflow/cli/processing_ops.py b/probeflow/cli/processing_ops.py index 1b743182..020cc875 100644 --- a/probeflow/cli/processing_ops.py +++ b/probeflow/cli/processing_ops.py @@ -145,10 +145,22 @@ def _apply_to_plane( f"Plane {plane_idx} not present — file has {scan.n_planes} plane(s)" ) old_shape = scan.planes[plane_idx].shape - scan.planes[plane_idx] = op(scan.planes[plane_idx]) + if isinstance(op, _Op) and op.state is not None: + from probeflow.processing.state import apply_processing_state_with_calibration + + scan.planes[plane_idx], new_range = apply_processing_state_with_calibration( + scan.planes[plane_idx], + op.state, + scan_range_m=scan.scan_range_m, + strict=True, + ) + if new_range is not None: + scan.scan_range_m = new_range + else: + scan.planes[plane_idx] = op(scan.planes[plane_idx]) new_shape = scan.planes[plane_idx].shape if isinstance(op, _Op): - if new_shape != old_shape: + if op.state is None and new_shape != old_shape: new_range = _update_scan_range_for_op( op.name, scan.scan_range_m, old_shape, new_shape, ) diff --git a/probeflow/core/file_type.py b/probeflow/core/file_type.py index b23640ca..31d75291 100644 --- a/probeflow/core/file_type.py +++ b/probeflow/core/file_type.py @@ -29,6 +29,14 @@ }) +def has_supported_suffix(path) -> bool: + """Return whether *path* can contain a currently supported probe file.""" + try: + return Path(path).suffix.lower() in _SNIFF_SUFFIXES + except (TypeError, ValueError): + return False + + class FileType(Enum): CREATEC_IMAGE = "createc_image" CREATEC_SPEC = "createc_spec" @@ -67,7 +75,7 @@ def sniff_file_type(path) -> FileType: """ try: p = Path(path) - if p.suffix.lower() not in _SNIFF_SUFFIXES: + if not has_supported_suffix(p): return FileType.UNKNOWN with p.open("rb") as fh: head = fh.read(_SNIFF_BYTES) diff --git a/probeflow/core/indexing.py b/probeflow/core/indexing.py index 4f126776..ee94ed92 100644 --- a/probeflow/core/indexing.py +++ b/probeflow/core/indexing.py @@ -20,7 +20,7 @@ from probeflow.core.browse_filters import FolderFilterState, scan_matches_folder_filters from probeflow.core.common import _f -from probeflow.core.file_type import FileType, sniff_file_type +from probeflow.core.file_type import FileType, has_supported_suffix, sniff_file_type # Folder names to always skip when walking. _SKIP_DIRS: frozenset[str] = frozenset({ @@ -361,6 +361,8 @@ def _peek_subfolder( except OSError: continue if is_file: + if not has_supported_suffix(e.name): + continue if files_examined >= max_files: capped = True break @@ -421,6 +423,8 @@ def subfolder_matches_filters( continue if not is_file: continue + if not has_supported_suffix(entry.name): + continue if files_examined >= max_files: return False files_examined += 1 diff --git a/probeflow/core/scan_model.py b/probeflow/core/scan_model.py index 8e57f195..24b0e569 100644 --- a/probeflow/core/scan_model.py +++ b/probeflow/core/scan_model.py @@ -176,13 +176,12 @@ def processing_history(self) -> list[dict]: @processing_history.setter def processing_history(self, entries: list[dict] | None) -> None: - from probeflow.processing.history import processing_state_from_history - self._processing_state = processing_state_from_history(entries) - self._processing_history_timestamps = [ - entry.get("timestamp") if isinstance(entry, dict) else None - for entry in (entries or []) - if isinstance(entry, dict) and entry.get("op") - ] + from probeflow.processing.history import ( + processing_state_and_timestamps_from_history, + ) + state, timestamps = processing_state_and_timestamps_from_history(entries) + self._processing_state = state + self._processing_history_timestamps = timestamps @property def n_planes(self) -> int: diff --git a/probeflow/core/source_identity.py b/probeflow/core/source_identity.py index 4fd689ab..d5ce1634 100644 --- a/probeflow/core/source_identity.py +++ b/probeflow/core/source_identity.py @@ -24,14 +24,19 @@ def build_source_identity( "item_type": item_type, "file_size_bytes": int(stat.st_size), "mtime_ns": int(stat.st_mtime_ns), - "sha256": _sha256_file(p), + "sha256": sha256_file(p), "data_offset": int(data_offset) if data_offset is not None else None, } -def _sha256_file(path: Path) -> str: +def sha256_file(path: Path) -> str: + """Return the hexadecimal SHA-256 digest of a file's bytes.""" h = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() + + +# Backward-compatible private spelling retained for existing internal callers. +_sha256_file = sha256_file diff --git a/probeflow/gui/app.py b/probeflow/gui/app.py index 41296d12..b2769765 100644 --- a/probeflow/gui/app.py +++ b/probeflow/gui/app.py @@ -753,6 +753,7 @@ def _create_tv_window(self) -> WorkspaceWindow: self._tv_sidebar = TVSidebar(t) self._tv_pool = QThreadPool.globalInstance() self._tv_signals = _TVWorkerSignals() + self._tv_run_generation = 0 self._tv_signals.finished.connect(self._on_tv_finished) self._tv_sidebar.load_from_browse_requested.connect( self._on_tv_load_from_browse) @@ -1205,6 +1206,10 @@ def _on_tv_load_from_browse(self): except Exception as exc: self._tv_sidebar.set_status(f"Could not read scan: {exc}") return + # Invalidate any worker that is still finishing the previously loaded + # image. Its result must never be attached to this entry. + self._tv_run_generation = getattr(self, "_tv_run_generation", 0) + 1 + self._tv_sidebar.set_running(False) self._tv_panel.load_entry(entry, plane_idx, arr, px_m) self._tv_sidebar.set_status( f"Loaded {entry.stem} (plane {plane_idx}). Adjust parameters and Run.") @@ -1215,12 +1220,28 @@ def _on_tv_run(self): self._tv_sidebar.set_status("Load a scan first.") return params = self._tv_sidebar.params() + self._tv_run_generation = getattr(self, "_tv_run_generation", 0) + 1 + run_identity = ( + self._tv_run_generation, + id(arr), + self._tv_panel.current_plane_idx(), + ) self._tv_sidebar.set_running(True) self._tv_sidebar.set_status(f"Running TV-denoise ({params['method']})…") - worker = _TVWorker(arr, params, self._tv_signals) + worker = _TVWorker(arr, params, self._tv_signals, run_identity) self._tv_pool.start(worker) - def _on_tv_finished(self, result, error: str): + def _on_tv_finished(self, result, error: str, run_identity=None): + current_arr = self._tv_panel.current_array() + expected = ( + getattr(self, "_tv_run_generation", 0), + id(current_arr), + self._tv_panel.current_plane_idx(), + ) + if run_identity != expected: + # A different entry/plane was loaded, or a newer run was started. + # Ignore this completion without changing the current run's UI. + return self._tv_sidebar.set_running(False) if error: self._tv_sidebar.set_status(f"TV-denoise failed: {error}") diff --git a/probeflow/gui/dialogs/image_arithmetic.py b/probeflow/gui/dialogs/image_arithmetic.py index 479de675..afa287b2 100644 --- a/probeflow/gui/dialogs/image_arithmetic.py +++ b/probeflow/gui/dialogs/image_arithmetic.py @@ -482,14 +482,23 @@ def _image_params(self) -> dict[str, Any]: entry = self._selected_entry() if entry is None: raise ValueError("No source image selected.") + source_path = Path(entry.path).resolve() + from probeflow.core.source_identity import sha256_file + + stat = source_path.stat() plane_idx = int(self._plane_combo.currentData() or 0) return { "operation": self._operation(), "operand_type": "image", - "source_path": str(Path(entry.path).resolve()), + "source_path": str(source_path), "source_label": str(getattr(entry, "stem", Path(entry.path).stem)), "plane_idx": plane_idx, "plane_label": self._plane_combo.currentText(), + "source_fingerprint": { + "file_size_bytes": int(stat.st_size), + "mtime_ns": int(stat.st_mtime_ns), + "sha256": sha256_file(source_path), + }, } def _generated_params(self) -> dict[str, Any]: diff --git a/probeflow/gui/roi_manager_dock.py b/probeflow/gui/roi_manager_dock.py index d028e5b9..2e2a2454 100644 --- a/probeflow/gui/roi_manager_dock.py +++ b/probeflow/gui/roi_manager_dock.py @@ -193,7 +193,7 @@ def _on_invert(self) -> None: if roi_id is None or roi_set is None: return roi = roi_set.get(roi_id) - if roi is None: + if roi is None or roi.kind not in AREA_ROI_KINDS: return get_shape = self._cb.get("get_image_shape") image_shape = get_shape() if get_shape else None @@ -212,7 +212,7 @@ def _on_combine(self) -> None: return rois = [roi_set.get(rid) for rid in roi_ids] rois = [r for r in rois if r is not None] - if len(rois) < 2: + if len(rois) < 2 or any(r.kind not in AREA_ROI_KINDS for r in rois): return mode = self._combine_mode.currentText() from probeflow.core import roi as _roi_module @@ -224,11 +224,16 @@ def _on_combine(self) -> None: def _on_item_selection_changed(self) -> None: ids = self._selected_roi_ids() n = len(ids) + roi_set = self._roi_set_getter() + selected = [roi_set.get(rid) for rid in ids] if roi_set is not None else [] + all_area = len(selected) == n and all( + roi is not None and roi.kind in AREA_ROI_KINDS for roi in selected + ) self._rename_btn.setEnabled(n == 1) self._delete_btn.setEnabled(n >= 1) self._active_btn.setEnabled(n == 1) - self._invert_btn.setEnabled(n == 1) - self._combine_btn.setEnabled(n >= 2) + self._invert_btn.setEnabled(n == 1 and all_area) + self._combine_btn.setEnabled(n >= 2 and all_area) self._cb.get("on_roi_selection_changed", lambda: None)() # ── context menu ───────────────────────────────────────────────────────── @@ -260,7 +265,7 @@ def _show_context_menu(self, pos) -> None: set_active_act.triggered.connect(self._on_set_active) invert_act = menu.addAction("Invert") - invert_act.setEnabled(roi is not None) + invert_act.setEnabled(is_area) invert_act.triggered.connect(self._on_invert) menu.addSeparator() diff --git a/probeflow/gui/tv.py b/probeflow/gui/tv.py index 3b17d4b8..cd096bae 100644 --- a/probeflow/gui/tv.py +++ b/probeflow/gui/tv.py @@ -43,27 +43,28 @@ def _sep() -> QFrame: class _TVWorkerSignals(QObject): - finished = Signal(object, str) # denoised-or-None, error-or-"" + finished = Signal(object, str, object) # result, error, run identity class _TVWorker(QRunnable): """Run tv_denoise off the GUI thread.""" def __init__(self, arr: np.ndarray, params: dict, - signals: _TVWorkerSignals): + signals: _TVWorkerSignals, run_identity=None): super().__init__() self._arr = arr self._params = params self._signals = signals + self._run_identity = run_identity @Slot() def run(self): try: from probeflow.processing import tv_denoise out = tv_denoise(self._arr, **self._params) - self._signals.finished.emit(out, "") + self._signals.finished.emit(out, "", self._run_identity) except Exception as exc: - self._signals.finished.emit(None, str(exc)) + self._signals.finished.emit(None, str(exc), self._run_identity) class TVPanel(QWidget): diff --git a/probeflow/gui/viewer/image_viewer_processing_export_mixin.py b/probeflow/gui/viewer/image_viewer_processing_export_mixin.py index debe4a4d..13675581 100644 --- a/probeflow/gui/viewer/image_viewer_processing_export_mixin.py +++ b/probeflow/gui/viewer/image_viewer_processing_export_mixin.py @@ -30,7 +30,7 @@ ) from probeflow.processing.gui_adapter import processing_state_from_gui from probeflow.processing.state import ( - apply_processing_state, + apply_processing_state_with_calibration, assert_roi_references_resolved, ) from probeflow.provenance import build_export_record, display_lines @@ -628,6 +628,7 @@ def _on_save_png(self): add_scalebar=self._export_scalebar_enabled(), include_provenance=self._export_provenance_enabled(), image_mask_set=getattr(self, "_image_mask_set", None), + scan_range_m=self._processed_scan_range_m(), ) if msg.startswith("Saved") and self._processing_history is not None: self._mark_history_export(out_path, export_parameters={"export_kind": "viewer_png"}) @@ -674,19 +675,16 @@ def _assert_exportable_processing(self) -> bool: return False if not self._assert_mask_scopes_current(ps): return False - if ( - self._raw_arr is not None - and self._processing_state_has_image_arithmetic_operand(ps) - ): + if self._raw_arr is not None and ps.steps: try: - # Forward calibration (review image-proc #1) — preflight - # must mirror what _refresh_display_array does so it - # validates the same pipeline that will execute. - psx, psy = self._processing_pixel_sizes_m() - apply_processing_state( + # Export is strict even though live preview is forgiving: every + # recorded step must resolve and execute before an artifact can + # claim that processing history. + apply_processing_state_with_calibration( self._raw_arr, ps, self._image_roi_set, mask_set=mask_set, - pixel_size_x_m=psx, pixel_size_y_m=psy, + scan_range_m=getattr(self, "_scan_range_m", None), + strict=True, ) except Exception as exc: self._status_lbl.setText(f"Export blocked: Processing failed: {exc}") diff --git a/probeflow/gui/viewer/png_export.py b/probeflow/gui/viewer/png_export.py index 228f3848..3ec2fb9b 100644 --- a/probeflow/gui/viewer/png_export.py +++ b/probeflow/gui/viewer/png_export.py @@ -27,6 +27,7 @@ def save_viewer_png( add_scalebar: bool = True, include_provenance: bool = True, image_mask_set=None, + scan_range_m: tuple[float, float] | None = None, ) -> str: """Write *arr* to *out_path* as a PNG with embedded provenance metadata. @@ -41,20 +42,35 @@ def save_viewer_png( from probeflow.provenance.export import build_scan_export_provenance, png_display_state try: + load_error = None try: _scan = load_scan(entry_path) - w_m, h_m = _scan.scan_range_m - except Exception: + except Exception as exc: _scan = None - w_m = h_m = 0.0 + load_error = exc + if include_provenance and _scan is None: + return f"Export error: source scan could not be loaded for provenance: {load_error}" + + effective_range = scan_range_m + if effective_range is None and _scan is not None: + effective_range = _scan.scan_range_m + if effective_range is None: + effective_range = (0.0, 0.0) + w_m, h_m = float(effective_range[0]), float(effective_range[1]) vmin, vmax = drs.resolve(arr) provenance = None if include_provenance and _scan is not None: try: + # Provenance describes the exported processed array. Keep the + # source Scan itself untouched while giving the export record + # the same calibrated range used by the scale bar. + import copy + provenance_scan = copy.copy(_scan) + provenance_scan.scan_range_m = (w_m, h_m) ps = processing_state_from_gui(processing or {}) provenance = build_scan_export_provenance( - _scan, + provenance_scan, channel_index=ch_idx, channel_name=ch_name, processing_state=ps, diff --git a/probeflow/gui/viewer/roi_analysis.py b/probeflow/gui/viewer/roi_analysis.py index 212eafa8..36e74012 100644 --- a/probeflow/gui/viewer/roi_analysis.py +++ b/probeflow/gui/viewer/roi_analysis.py @@ -36,15 +36,21 @@ def show_roi_histogram( if len(vals) == 0: QMessageBox.information(parent, "Histogram", "No pixels in ROI.") return + finite = vals[np.isfinite(vals)] + if finite.size == 0: + QMessageBox.information( + parent, "Histogram", "ROI contains no finite pixel values." + ) + return scale, unit, _ = channel_unit_fn() unit_str = f" {unit}" if unit else "" QMessageBox.information( parent, f"Histogram: {roi.name}", - f"Pixels: {len(vals)}\n" - f"Min: {float(vals.min()) * scale:.4g}{unit_str}\n" - f"Max: {float(vals.max()) * scale:.4g}{unit_str}\n" - f"Mean: {float(vals.mean()) * scale:.4g}{unit_str}", + f"Pixels: {len(vals)} ({finite.size} finite)\n" + f"Min: {float(finite.min()) * scale:.4g}{unit_str}\n" + f"Max: {float(finite.max()) * scale:.4g}{unit_str}\n" + f"Mean: {float(finite.mean()) * scale:.4g}{unit_str}", ) diff --git a/probeflow/io/common.py b/probeflow/io/common.py index afb8eca3..1d9e2f28 100644 --- a/probeflow/io/common.py +++ b/probeflow/io/common.py @@ -155,19 +155,22 @@ def detect_channels(payload: bytes, Ny: int, Nx: int) -> Tuple[np.ndarray, int]: def trim_stack(stack: np.ndarray) -> Tuple[np.ndarray, int]: """ - Remove trailing incomplete scan rows using channel 0 as a reference. + Remove trailing scan lines that did not reach the final column. + + A zero in one channel is a valid DAC value and cannot by itself mark an + incomplete acquisition. The legacy Createc fallback therefore requires a + completed scan line to reach the final column in at least one stored + channel. Callers with vendor completion metadata should use that first. Returns (trimmed_stack, new_Ny). """ - ch0 = stack[0] - Ny, Nx = ch0.shape - valid = np.logical_and(~np.isnan(ch0), ch0 != 0) - rows, cols = np.where(valid) + Ny = stack.shape[1] + final_column = stack[:, :, -1] + completed_row = np.any(np.isfinite(final_column) & (final_column != 0), axis=0) + rows = np.flatnonzero(completed_row) if rows.size == 0: return stack, Ny last_row = int(rows.max()) - last_col = int(cols[rows == last_row].max()) - new_Ny = (last_row + 1) if last_col == (Nx - 1) else last_row - new_Ny = max(1, new_Ny) + new_Ny = last_row + 1 return stack[:, :new_Ny, :], new_Ny diff --git a/probeflow/io/readers/createc_dat.py b/probeflow/io/readers/createc_dat.py index 79b89867..bf52cf82 100644 --- a/probeflow/io/readers/createc_dat.py +++ b/probeflow/io/readers/createc_dat.py @@ -380,7 +380,10 @@ def _trim_createc_stack( Real complete Createc fixtures record ``ImageYPosMax`` as ``Num.Y + 1``. Interpreting the field as one-based "next Y position" gives the completed row count as ``ImageYPosMax - 1``. When the field is absent or outside the - declared image height, the legacy non-zero channel-0 heuristic is retained. + declared image height, the fallback removes only trailing rows that are + zero/nonfinite through the final column across every stored channel; a zero + in channel 0 alone is valid and is not sufficient evidence of an incomplete + row. """ Ny = int(stack.shape[1]) diff --git a/probeflow/io/writers/gwy.py b/probeflow/io/writers/gwy.py index 69da7927..7a953770 100644 --- a/probeflow/io/writers/gwy.py +++ b/probeflow/io/writers/gwy.py @@ -55,18 +55,19 @@ def _plane_meta( meta["ProbeFlow scan width (m)"] = float(scan.scan_range_m[0]) meta["ProbeFlow scan height (m)"] = float(scan.scan_range_m[1]) meta["ProbeFlow num planes"] = int(scan.n_planes) - meta["ProbeFlow export provenance"] = json.dumps(provenance.to_dict(), sort_keys=True) - meta["ProbeFlow processing state"] = json.dumps( - provenance.processing_state, - sort_keys=True, - ) - meta["ProbeFlow processing state hash"] = str(provenance.processing_state_hash) - meta["ProbeFlow processing steps"] = int( - len(provenance.processing_state.get("steps", [])) - ) - meta["ProbeFlow source id"] = str(provenance.source_id or "") - meta["ProbeFlow channel id"] = str(provenance.channel_id or "") - meta["ProbeFlow artifact id"] = str(provenance.artifact_id or "") + if provenance is not None: + meta["ProbeFlow export provenance"] = json.dumps(provenance.to_dict(), sort_keys=True) + meta["ProbeFlow processing state"] = json.dumps( + provenance.processing_state, + sort_keys=True, + ) + meta["ProbeFlow processing state hash"] = str(provenance.processing_state_hash) + meta["ProbeFlow processing steps"] = int( + len(provenance.processing_state.get("steps", [])) + ) + meta["ProbeFlow source id"] = str(provenance.source_id or "") + meta["ProbeFlow channel id"] = str(provenance.channel_id or "") + meta["ProbeFlow artifact id"] = str(provenance.artifact_id or "") return meta @@ -156,7 +157,7 @@ def write_gwy( container["/0/data"] = field container["/0/data/title"] = str(plane_name) container["/0/data/visible"] = True - if include_meta and provenance is not None: + if include_meta: container["/0/meta"] = _plane_meta( GwyContainer, scan, diff --git a/probeflow/processing/history.py b/probeflow/processing/history.py index 526bab60..fb2be890 100644 --- a/probeflow/processing/history.py +++ b/probeflow/processing/history.py @@ -54,7 +54,16 @@ def processing_state_from_history( an *empty* state — a replay would reproduce the raw image while claiming to be processed. """ + state, _timestamps = processing_state_and_timestamps_from_history(history) + return state + + +def processing_state_and_timestamps_from_history( + history: list[dict[str, Any]] | tuple[dict[str, Any], ...] | None, +) -> tuple[ProcessingState, list[str | None]]: + """Normalize replayable steps and their timestamps in the same pass.""" steps: list[ProcessingStep] = [] + timestamps: list[str | None] = [] for entry in history or (): if not isinstance(entry, dict): continue @@ -76,7 +85,9 @@ def processing_state_from_history( } params = dict(params) steps.append(ProcessingStep(op, deepcopy(params))) - return ProcessingState(steps=steps) + timestamp = entry.get("timestamp") + timestamps.append(str(timestamp) if timestamp is not None else None) + return ProcessingState(steps=steps), timestamps def processing_state_dict_from_history( diff --git a/probeflow/processing/state.py b/probeflow/processing/state.py index 9a0ba64f..d84ee088 100644 --- a/probeflow/processing/state.py +++ b/probeflow/processing/state.py @@ -456,8 +456,33 @@ def _load_arithmetic_operand_image(params: "dict[str, Any]") -> np.ndarray: from probeflow.core.scan_loader import load_scan + path = Path(str(source_path)) + fingerprint = params.get("source_fingerprint") + if isinstance(fingerprint, dict): + from probeflow.core.source_identity import sha256_file + + try: + stat = path.stat() + actual_size = int(stat.st_size) + actual_hash = sha256_file(path) + except OSError as exc: + raise ValueError( + f"Could not fingerprint image arithmetic operand {source_path!r}: {exc}" + ) from exc + expected_size = fingerprint.get("file_size_bytes") + expected_hash = fingerprint.get("sha256") + if ( + expected_size is not None and actual_size != int(expected_size) + ) or ( + expected_hash and actual_hash != str(expected_hash) + ): + raise ValueError( + "Image arithmetic operand has changed since this processing step " + f"was created: {source_path!r}. Remove and recreate the step." + ) + try: - scan = load_scan(Path(str(source_path))) + scan = load_scan(path) except Exception as exc: raise ValueError( f"Could not load image arithmetic operand {source_path!r}: {exc}" @@ -482,6 +507,7 @@ def apply_processing_state( pixel_size_x_m: float | None = None, pixel_size_y_m: float | None = None, operand_resolver=None, + strict: bool = False, _depth: int = 0, ) -> np.ndarray: """Apply *state* steps in order to *arr*. @@ -527,6 +553,11 @@ def apply_processing_state( callers that don't want that hidden I/O can inject a resolver that looks up the operand in an in-memory cache or dict (review arch-backend #13). + strict: + When true, any recoverable ``UserWarning`` emitted while resolving or + applying a requested step becomes ``ValueError``. Interactive preview + keeps the default forgiving behavior; CLI and export validation use + strict mode so recorded steps cannot be silently skipped. Returns ------- @@ -538,6 +569,27 @@ def apply_processing_state( If a step contains an unrecognised operation name, or if ROI-in-ROI nesting exceeds depth 2. """ + if strict and _depth == 0: + import warnings + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", UserWarning) + result = apply_processing_state( + arr, + state, + roi_set, + mask_set=mask_set, + pixel_size_x_m=pixel_size_x_m, + pixel_size_y_m=pixel_size_y_m, + operand_resolver=operand_resolver, + strict=False, + _depth=0, + ) + failures = [str(item.message) for item in caught if issubclass(item.category, UserWarning)] + if failures: + raise ValueError("Strict processing rejected warning: " + "; ".join(failures)) + return result + # Always return a fresh float64 copy so raw Scan planes are never mutated. a = arr.astype(np.float64, copy=True) @@ -1112,6 +1164,7 @@ def apply_processing_state_with_calibration( mask_set: "Any | None" = None, scan_range_m: tuple[float, float] | None, operand_resolver=None, + strict: bool = False, ) -> tuple[np.ndarray, tuple[float, float] | None]: """Apply *state* to *arr* and return the post-processing scan_range. @@ -1170,6 +1223,7 @@ def apply_processing_state_with_calibration( pixel_size_x_m=psx, pixel_size_y_m=psy, operand_resolver=operand_resolver, + strict=strict, ) h_out, w_out = a.shape if (h_out, w_out) != (h_in, w_in): diff --git a/tests/test_browse_folder_filters.py b/tests/test_browse_folder_filters.py index d5a9bca2..f7baf3cf 100644 --- a/tests/test_browse_folder_filters.py +++ b/tests/test_browse_folder_filters.py @@ -122,3 +122,25 @@ def test_subfolder_matches_filters_uses_indexed_scan_metadata(tmp_path): assert subfolder_matches_filters(sub, match_state) is True assert subfolder_matches_filters(sub, miss_state) is False + + +def test_subfolder_filter_budget_ignores_unsupported_files(tmp_path): + src_root = Path(__file__).resolve().parents[1] / "test_data" + src = next(src_root.glob("*.sxm"), None) or next(src_root.glob("*.dat")) + sub = tmp_path / "sample_input" + sub.mkdir() + for i in range(10): + (sub / f"log_{i:02d}.txt").write_text("not probe data") + scan_path = sub / f"zz_scan{src.suffix}" + shutil.copy(src, scan_path) + + from probeflow.core.indexing import index_folder_shallow + item = next(it for it in index_folder_shallow(sub).files if it.item_type == "scan") + if item.bias is None: + pytest.skip("sample scan carries no bias metadata") + + assert subfolder_matches_filters( + sub, + FolderFilterState(bias_value_mv=item.bias * 1000.0), + max_files=1, + ) is True diff --git a/tests/test_common.py b/tests/test_common.py index cbadba9b..f89fd944 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -96,16 +96,26 @@ def test_channel_detection_and_trim_contract(): stack = np.ones((2, 8, 4), dtype=np.float32) stack[:, 6:, :] = 0.0 trimmed, new_Ny = trim_stack(stack) - assert new_Ny < 8 + assert new_Ny == 6 assert trimmed.shape[1] == new_Ny untrimmed, untrimmed_Ny = trim_stack(np.ones((2, 4, 4), dtype=np.float32)) assert untrimmed_Ny == 4 assert untrimmed.shape[1] == 4 - zero_trimmed, zero_Ny = trim_stack(np.zeros((2, 4, 4), dtype=np.float32)) - assert zero_Ny >= 1 - assert zero_trimmed.shape[1] == zero_Ny + # A zero-valued topography row is legitimate when another acquired channel + # confirms that the row exists; the old channel-0 heuristic dropped it. + channel_zero = np.ones((2, 4, 4), dtype=np.float32) + channel_zero[0, 3, :] = 0.0 + zero_trimmed, zero_Ny = trim_stack(channel_zero) + assert zero_Ny == 4 + assert zero_trimmed.shape == channel_zero.shape + + # With no acquired sample in any channel there is no defensible trim point. + all_zero = np.zeros((2, 4, 4), dtype=np.float32) + unplaced, unplaced_Ny = trim_stack(all_zero) + assert unplaced_Ny == 4 + assert unplaced.shape == all_zero.shape def test_clip_and_uint8_helpers_contract(): diff --git a/tests/test_folder_index_shallow.py b/tests/test_folder_index_shallow.py index 8194ca5a..5c3a2c61 100644 --- a/tests/test_folder_index_shallow.py +++ b/tests/test_folder_index_shallow.py @@ -127,3 +127,20 @@ def test_peek_file_budget_caps_counts(tmp_path): uncapped = _peek_subfolder(sub, max_files=400) assert uncapped.counts_capped is False assert uncapped.n_scans == 10 + + +def test_peek_budget_ignores_unsupported_files(tmp_path): + import shutil + from probeflow.core.indexing import _peek_subfolder + + src = next(TESTDATA.glob("*.sxm"), None) or next(TESTDATA.glob("*.dat")) + sub = tmp_path / "experiment" + sub.mkdir() + for i in range(10): + (sub / f"log_{i:02d}.txt").write_text("not probe data") + shutil.copy(src, sub / f"zz_scan{src.suffix}") + + result = _peek_subfolder(sub, max_files=1) + + assert result.n_scans == 1 + assert result.counts_capped is False diff --git a/tests/test_gui_processing_panel.py b/tests/test_gui_processing_panel.py index c435902e..6ce592de 100644 --- a/tests/test_gui_processing_panel.py +++ b/tests/test_gui_processing_panel.py @@ -2057,6 +2057,7 @@ def fake_save_viewer_png(*args, **kwargs): assert saved_calls[0][1]["add_scalebar"] is False assert saved_calls[0][1]["include_provenance"] is False + assert saved_calls[0][1]["scan_range_m"] == dlg._processed_scan_range_m() assert "Saved" in dlg._status_lbl.text() dlg.close() @@ -2195,6 +2196,9 @@ class FakeSidebar: def plane_index(self): return 3 + def set_running(self, _running): + pass + def set_status(self, text): statuses.append(text) @@ -2231,6 +2235,126 @@ def fake_load_scan_plane_for_analysis(self, got_entry, plane_idx): assert "Loaded example" in statuses[-1] +def test_tv_completion_is_ignored_after_a_different_scan_is_loaded(): + """A late worker must not attach scan A's pixels to scan B.""" + from probeflow.gui.app import ProbeFlowWindow + + old = np.zeros((3, 3)) + current = np.ones((3, 3)) + + class FakePanel: + result = None + + def current_array(self): return current + def current_plane_idx(self): return 2 + def set_denoised(self, result): self.result = result + + class FakeSidebar: + running_calls = [] + + def set_running(self, running): self.running_calls.append(running) + def set_status(self, _text): pass + + win = ProbeFlowWindow.__new__(ProbeFlowWindow) + win._tv_panel = FakePanel() + win._tv_sidebar = FakeSidebar() + win._tv_run_generation = 8 + + ProbeFlowWindow._on_tv_finished( + win, np.full((3, 3), 9.0), "", (7, id(old), 0) + ) + + assert win._tv_panel.result is None + assert win._tv_sidebar.running_calls == [] + + +def test_tv_completion_is_accepted_for_current_run(): + from probeflow.gui.app import ProbeFlowWindow + + current = np.ones((3, 3)) + result = np.full((3, 3), 2.0) + + class FakePanel: + denoised = None + + def current_array(self): return current + def current_plane_idx(self): return 1 + def set_denoised(self, value): self.denoised = value + + class FakeSidebar: + running = True + + def set_running(self, running): self.running = running + def set_status(self, _text): pass + + win = ProbeFlowWindow.__new__(ProbeFlowWindow) + win._tv_panel = FakePanel() + win._tv_sidebar = FakeSidebar() + win._tv_run_generation = 4 + + ProbeFlowWindow._on_tv_finished(win, result, "", (4, id(current), 1)) + + assert win._tv_panel.denoised is result + assert win._tv_sidebar.running is False + + +def test_roi_histogram_uses_only_finite_pixels(monkeypatch, qapp): + from PySide6.QtWidgets import QMessageBox + from probeflow.gui.viewer.roi_analysis import show_roi_histogram + + messages = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda _parent, title, text: messages.append((title, text)), + ) + roi = SimpleNamespace( + name="mixed", + to_mask=lambda _shape: np.ones((2, 2), dtype=bool), + ) + + show_roi_histogram( + roi, + np.array([[1.0, np.nan], [3.0, np.inf]]), + lambda: (1.0, "m", "Height"), + ) + + assert "2 finite" in messages[0][1] + assert "Min: 1 m" in messages[0][1] + assert "Max: 3 m" in messages[0][1] + assert "Mean: 2 m" in messages[0][1] + + +def test_roi_manager_disables_area_algebra_for_line_rois(qapp): + from probeflow.core.roi import ROI, ROISet + from probeflow.gui.roi_manager_dock import ROIManagerPanel + + roi_set = ROISet(image_id="img") + line = ROI.new("line", {"x1": 0, "y1": 0, "x2": 5, "y2": 5}) + rect = ROI.new("rectangle", {"x": 1, "y": 1, "width": 3, "height": 3}) + roi_set.add(line) + roi_set.add(rect) + panel = ROIManagerPanel( + lambda: roi_set, + {"get_image_shape": lambda: (10, 10)}, + ) + panel.refresh(roi_set) + + panel._list.item(0).setSelected(True) + panel._on_item_selection_changed() + assert panel._invert_btn.isEnabled() is False + panel._on_invert() + assert len(roi_set.rois) == 2 + + panel._list.item(1).setSelected(True) + panel._on_item_selection_changed() + assert panel._combine_btn.isEnabled() is False + panel._on_combine() + assert len(roi_set.rois) == 2 + + panel.deleteLater() + + def test_open_viewer_tracking_reaps_destroyed_dialog(): from probeflow.gui.app import ProbeFlowWindow diff --git a/tests/test_processing_history_cli.py b/tests/test_processing_history_cli.py index 6e085d8d..1e4bb581 100644 --- a/tests/test_processing_history_cli.py +++ b/tests/test_processing_history_cli.py @@ -230,6 +230,21 @@ def test_plain_callable_does_not_record(self, first_sample_dat): scan = _apply_to_plane(first_sample_dat, 0, plain) assert scan.processing_history == [] + def test_state_op_receives_scan_pixel_calibration(self, first_sample_dat, monkeypatch): + captured = {} + + def fake_facet(arr, threshold_deg=3.0, *, pixel_size_x_m=1.0, + pixel_size_y_m=1.0): + captured["pixel_sizes"] = (pixel_size_x_m, pixel_size_y_m) + return np.asarray(arr, dtype=float) + + monkeypatch.setattr("probeflow.processing.facet_level", fake_facet) + scan = _apply_to_plane(first_sample_dat, 0, _op_facet_level(3.0)) + nx, ny = scan.dims + expected = (scan.scan_range_m[0] / nx, scan.scan_range_m[1] / ny) + + np.testing.assert_allclose(captured["pixel_sizes"], expected) + # ─── factory ops ───────────────────────────────────────────────────────────── diff --git a/tests/test_processing_physics.py b/tests/test_processing_physics.py index f13253d6..3e919199 100644 --- a/tests/test_processing_physics.py +++ b/tests/test_processing_physics.py @@ -768,6 +768,23 @@ def test_physical_period_preserved_when_pixel_count_changes_at_fixed_scan_size(s # ── B: lattice orientation ──────────────────────────────────────────────── + def test_anisotropic_pixels_use_physical_reciprocal_angle(self): + """A physically 45-degree grating remains 45 degrees on 2:1 pixels.""" + Ny = Nx = 128 + dx_m, dy_m = 2e-9, 1e-9 + # kx=8 and ky=4 give equal physical frequencies: + # 8/(Nx*dx) == 4/(Ny*dy). + arr = _sine_wave(Ny, Nx, fx=8 / Nx, fy=4 / Ny, amp=1.0) + + peak = measure_periodicity( + arr, + pixel_size_x_m=dx_m, + pixel_size_y_m=dy_m, + n_peaks=1, + )[0] + + assert abs((peak["angle_deg"] % 180.0) - 45.0) < 1.0 + def test_grating_at_non_trivial_angle_recovered_within_five_degrees(self): """A grating whose wave-vector is not 45° must be recovered correctly. diff --git a/tests/test_processing_state.py b/tests/test_processing_state.py index a0360013..f0d7f1c0 100644 --- a/tests/test_processing_state.py +++ b/tests/test_processing_state.py @@ -156,6 +156,34 @@ def test_arithmetic_image_shape_mismatch_raises(monkeypatch): apply_processing_state(np.ones((2, 2)), state) +def test_arithmetic_image_fingerprint_rejects_changed_operand(tmp_path, monkeypatch): + from probeflow.core.source_identity import sha256_file + + source = tmp_path / "operand.sxm" + source.write_bytes(b"original operand") + state = ProcessingState(steps=[ + ProcessingStep("arithmetic", { + "operation": "add", + "operand_type": "image", + "source_path": str(source), + "plane_idx": 0, + "source_fingerprint": { + "file_size_bytes": source.stat().st_size, + "sha256": sha256_file(source), + }, + }), + ]) + monkeypatch.setattr( + "probeflow.core.scan_loader.load_scan", + lambda _path: _scan_with_plane(np.ones((2, 2))), + ) + + source.write_bytes(b"replacement operand") + + with pytest.raises(ValueError, match="operand has changed"): + apply_processing_state(np.ones((2, 2)), state) + + def test_generated_arithmetic_checkerboard_pattern_values(): pattern = generate_arithmetic_pattern( (4, 4), @@ -1006,6 +1034,34 @@ def test_none_scan_range_propagates(self): assert out.shape != (6, 6) assert new_range is None + def test_strict_mode_rejects_a_skipped_crop(self): + from probeflow.processing.state import apply_processing_state_with_calibration + + state = ProcessingState(steps=[ + ProcessingStep("crop", {"x0": 100, "y0": 100, "x1": 110, "y1": 110}), + ]) + + with pytest.raises(ValueError, match="Strict processing rejected warning"): + apply_processing_state_with_calibration( + np.ones((8, 8)), + state, + scan_range_m=(8e-9, 8e-9), + strict=True, + ) + + def test_preview_mode_still_allows_a_skipped_crop(self): + from probeflow.processing.state import apply_processing_state_with_calibration + + state = ProcessingState(steps=[ + ProcessingStep("crop", {"x0": 100, "y0": 100, "x1": 110, "y1": 110}), + ]) + with pytest.warns(UserWarning, match="crop step skipped"): + out, new_range = apply_processing_state_with_calibration( + np.ones((8, 8)), state, scan_range_m=(8e-9, 8e-9), + ) + assert out.shape == (8, 8) + assert new_range == (8e-9, 8e-9) + def test_apply_processing_state_to_scan_updates_scan_range_after_rotate(self): """The GUI-export entry point must update scan.scan_range_m too.""" diff --git a/tests/test_scan_model_roundtrip.py b/tests/test_scan_model_roundtrip.py index 7fdec996..88037285 100644 --- a/tests/test_scan_model_roundtrip.py +++ b/tests/test_scan_model_roundtrip.py @@ -106,3 +106,32 @@ def test_processing_history_roundtrip_is_idempotent(): def test_processing_history_empty_by_default(): assert _scan().processing_history == [] + + +def test_processing_history_keeps_timestamps_aligned_when_bookkeeping_is_filtered(): + scan = _scan() + scan.processing_history = [ + {"op": "file_load", "params": {}, "timestamp": "LOAD"}, + {"op": "align_rows", "params": {"method": "median"}, "timestamp": "ALIGN"}, + {"op": "export_png", "params": {}, "timestamp": "EXPORT"}, + ] + + assert scan.processing_history == [ + {"op": "align_rows", "params": {"method": "median"}, "timestamp": "ALIGN"}, + ] + + +def test_provenance_history_format_preserves_processing_timestamp(): + scan = _scan() + scan.processing_history = [ + {"operation_id": "file_load", "parameters": {}, "timestamp": "LOAD"}, + { + "operation_id": "smooth", + "parameters": {"sigma_px": 1.5}, + "timestamp": "SMOOTH", + }, + ] + + assert scan.processing_history == [ + {"op": "smooth", "params": {"sigma_px": 1.5}, "timestamp": "SMOOTH"}, + ] diff --git a/tests/test_scan_writers.py b/tests/test_scan_writers.py index 89dc1d48..aab2f28b 100644 --- a/tests/test_scan_writers.py +++ b/tests/test_scan_writers.py @@ -101,6 +101,35 @@ def test_gwy_writer_and_scan_save_method_preserve_channel_and_provenance(self, d dat_scan.save_gwy(via_save, plane_idx=1) assert via_save.read_bytes()[:4] == b"GWYP" + def test_basic_metadata_does_not_require_provenance(self, dat_scan, tmp_path, monkeypatch): + import probeflow.io.writers.gwy as gwy_writer + + class FakeContainer(dict): + last = None + + def tofile(self, _path): + FakeContainer.last = self + + class FakeDataField: + def __init__(self, data, **kwargs): + self.data = data + self.kwargs = kwargs + + monkeypatch.setattr( + gwy_writer, "_import_gwyfile", lambda: (FakeContainer, FakeDataField) + ) + + gwy_writer.write_gwy( + dat_scan, + tmp_path / "metadata_only.gwy", + include_meta=True, + include_provenance=False, + ) + + meta = FakeContainer.last["/0/meta"] + assert meta["ProbeFlow plane name"] == dat_scan.plane_names[0] + assert "ProbeFlow export provenance" not in meta + class TestSaveScanDispatch: def test_unknown_suffix_raises(self, dat_scan, tmp_path): diff --git a/tests/test_viewer_qt_free.py b/tests/test_viewer_qt_free.py index 174ea2f7..a92ba671 100644 --- a/tests/test_viewer_qt_free.py +++ b/tests/test_viewer_qt_free.py @@ -266,6 +266,57 @@ def test_save_viewer_png_can_skip_provenance(tmp_path): assert "Saved" in msg +def test_save_viewer_png_uses_explicit_processed_scan_range(tmp_path): + from probeflow.gui.viewer.png_export import save_viewer_png + + arr = np.zeros((4, 6), dtype=float) + fake_scan = MagicMock() + fake_scan.scan_range_m = (60e-9, 40e-9) + drs = MagicMock() + drs.resolve.return_value = (0.0, 1.0) + processed_range = (12e-9, 8e-9) + + with patch( + "probeflow.core.scan_loader.load_scan", return_value=fake_scan, + ), patch( + "probeflow.provenance.export.build_scan_export_provenance", + return_value="prov", + ) as build_provenance, patch( + "probeflow.processing.export_png", + ) as export_png: + msg = save_viewer_png( + arr, str(tmp_path / "viewer.png"), tmp_path / "source.sxm", + "gray", 1.0, 99.0, drs, {}, None, 0, "Z", + scan_range_m=processed_range, + ) + + assert "Saved" in msg + assert export_png.call_args.kwargs["scan_range_m"] == processed_range + provenance_scan = build_provenance.call_args.args[0] + assert provenance_scan.scan_range_m == processed_range + assert fake_scan.scan_range_m == (60e-9, 40e-9) + + +def test_save_viewer_png_fails_closed_when_requested_provenance_source_is_missing(tmp_path): + from probeflow.gui.viewer.png_export import save_viewer_png + + drs = MagicMock() + drs.resolve.return_value = (0.0, 1.0) + with patch( + "probeflow.core.scan_loader.load_scan", + side_effect=FileNotFoundError("gone"), + ), patch("probeflow.processing.export_png") as export_png: + msg = save_viewer_png( + np.zeros((4, 4)), str(tmp_path / "viewer.png"), + tmp_path / "missing.sxm", "gray", 1.0, 99.0, + drs, {}, None, 0, "Z", include_provenance=True, + scan_range_m=(4e-9, 4e-9), + ) + + assert msg.startswith("Export error: source scan could not be loaded") + export_png.assert_not_called() + + # ── save_provenance_json ────────────────────────────────────────────────────── def test_save_provenance_json_writes_file(tmp_path):