From d61b59b9a4fb17133574a123766654c0bb3ef492 Mon Sep 17 00:00:00 2001 From: Guriy Date: Fri, 31 Jul 2026 15:46:08 +0300 Subject: [PATCH] added a bscan extension --- python_app/gui/app_window.py | 10 +- .../app_window_config/profile_io_mixin.py | 10 +- .../app_window_config/state_builders.py | 28 +++- .../app_window_plot/bscan_plot_mixin.py | 95 +++++++++--- .../sections/processing_section.py | 12 ++ python_app/models/gui_profile_codec.py | 7 + python_app/models/gui_profile_schema.py | 4 + python_app/tests/test_bscan_history_window.py | 140 ++++++++++++++++++ 8 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 python_app/tests/test_bscan_history_window.py diff --git a/python_app/gui/app_window.py b/python_app/gui/app_window.py index 476a822..05934d2 100644 --- a/python_app/gui/app_window.py +++ b/python_app/gui/app_window.py @@ -274,7 +274,7 @@ class AppWindow( def _init_history_state(self) -> None: """Initialize runtime history buffers and render-cache state.""" - bscan_history_limit = self._history_limit_from_config() + bscan_cpp_replay_window = self._cpp_bscan_replay_window_from_config() save_history_limit = self._save_history_limit_from_config() self._raw_history: deque[SweepCollection] = deque(maxlen=save_history_limit) self._pre_history: deque[SweepCollection] = deque(maxlen=save_history_limit) @@ -282,7 +282,7 @@ class AppWindow( # Sequence id must survive GUI restarts so history commands stay monotonic. self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path) - self._bscan_history_limit = bscan_history_limit + self._bscan_cpp_replay_window = bscan_cpp_replay_window self._bscan_history_by_combo = {} self._bscan_depth_axis_by_combo = {} self._bscan_history_floor_collection_id = 0 @@ -305,9 +305,9 @@ class AppWindow( self._active_processing_mode = "pass_through" self._radar_limits: dict[str, float | int] | None = None - def _history_limit_from_config(self) -> int: - """Return B-scan render history limit derived from configured ring capacities.""" - return self._history_limit_for_config(self._defaults_config) + def _cpp_bscan_replay_window_from_config(self) -> int: + """Return the C++ B-scan replay window for the active config.""" + return self._cpp_bscan_replay_window_for_config(self._defaults_config) def _save_history_limit_from_config(self) -> int: """Return maxlen for GUI snapshot-save deques (independent of ring capacities).""" diff --git a/python_app/gui/controllers/app_window_config/profile_io_mixin.py b/python_app/gui/controllers/app_window_config/profile_io_mixin.py index e9bf158..7431059 100644 --- a/python_app/gui/controllers/app_window_config/profile_io_mixin.py +++ b/python_app/gui/controllers/app_window_config/profile_io_mixin.py @@ -121,15 +121,15 @@ class AppWindowConfigProfileIOMixin: Save-side deques use a config-independent limit so that processing-side ring capacities can stay small without truncating the save buffer. The - B-scan render limit still follows ring capacities to keep plot updates - responsive. + C++ replay window still follows ring capacities, since it bounds how much + of the history the processor can re-publish coherently. """ save_history_limit = self._save_history_limit_for_config(config) - bscan_history_limit = self._history_limit_for_config(config) + bscan_cpp_replay_window = self._cpp_bscan_replay_window_for_config(config) self._raw_history = deque(self._raw_history, maxlen=save_history_limit) self._pre_history = deque(self._pre_history, maxlen=save_history_limit) self._result_history = deque(self._result_history, maxlen=save_history_limit) - self._bscan_history_limit = bscan_history_limit + self._bscan_cpp_replay_window = bscan_cpp_replay_window self._clear_bscan_plot_history() def _save_current_config(self) -> None: @@ -283,6 +283,7 @@ class AppWindowConfigProfileIOMixin: self._bscan_start_freq_mhz, self._bscan_stop_freq_mhz, self._bscan_subtract_mean_ascan, + self._bscan_history_window, self._gpr_relative_permittivity, self._gpr_tx_geometry_input, self._gpr_rx_geometry_input, @@ -438,6 +439,7 @@ class AppWindowConfigProfileIOMixin: self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz)) self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz)) self._bscan_subtract_mean_ascan.setChecked(bool(gui_state.processing.bscan.subtract_mean_ascan)) + self._bscan_history_window.setValue(int(gui_state.processing.bscan.history_window_scans)) self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity)) self._gpr_tx_geometry_input.setPlainText( diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 294f043..eef728d 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -34,6 +34,13 @@ from python_app.storage.npz_store import radar_key_from_config # history without touching the processing-side ring sizes. GUI_SAVE_HISTORY_LIMIT: int = 1000 +# Mirror of `kBscanReplayWindow` in +# data_acq_and_processing/processing/data_processor/src/data_processor.cpp. When a +# live B-scan setting changes, the C++ processor re-processes and re-publishes only +# this many of the newest collections; anything older keeps the payload it was first +# computed with. Keep the two constants in sync. +CPP_BSCAN_REPLAY_WINDOW: int = 50 + class AppWindowConfigStateBuildersMixin: """Build stable and GUI-only config models from current widget state.""" @@ -163,14 +170,24 @@ class AppWindowConfigStateBuildersMixin: return (min(x_values) - margin_m, max(x_values) + margin_m) @staticmethod - def _history_limit_for_config(config: RunConfigModel) -> int: - """Return B-scan render history limit derived from config ring capacities.""" + def _cpp_bscan_replay_window_for_config(config: RunConfigModel) -> int: + """Return how many newest collections the C++ processor re-processes on a + live B-scan settings change. + + Deliberately reproduces `replay_history_limit()` in `data_processor.cpp` + formula-for-formula. The ring capacities matter because `ShmRing` overwrites + the oldest unread slot on overflow, so a replay burst must fit in the results + ring for the GUI to receive all of it. + + This is the single place to change if the replay window ever becomes + configurable on the C++ side. + """ return max( 1, min( - int(config.rings.raw_tap.capacity), - int(config.rings.preprocessed_tap.capacity), + int(config.rings.preprocessed.capacity), int(config.rings.results.capacity), + CPP_BSCAN_REPLAY_WINDOW, ), ) @@ -180,7 +197,7 @@ class AppWindowConfigStateBuildersMixin: Independent of ring capacities — see :data:`GUI_SAVE_HISTORY_LIMIT`. The `config` argument is kept for symmetry with - :meth:`_history_limit_for_config` and possible future per-profile + :meth:`_cpp_bscan_replay_window_for_config` and possible future per-profile overrides. """ del config @@ -344,6 +361,7 @@ class AppWindowConfigStateBuildersMixin: start_freq_mhz=float(self._bscan_start_freq_mhz.value()), stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()), subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()), + history_window_scans=int(self._bscan_history_window.value()), ), gpr=GuiGprStateModel( input_positions=self._gpr_input_positions_input.text().strip(), diff --git a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py index 64c0818..6524a9c 100644 --- a/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py +++ b/python_app/gui/controllers/app_window_plot/bscan_plot_mixin.py @@ -52,6 +52,7 @@ def build_bscan_signature( floor_collection_id=floor_collection_id, ) return ( + int(history_limit), str(live_config.bscan_axis), str(live_config.bscan_channel), float(live_config.bscan_cut_m), @@ -213,6 +214,7 @@ class AppWindowBscanPlotMixin: depth_max = float(np.max(depth_axis)) depth_span = max(depth_max - depth_min, 1e-6) sweep_count = sweeps.shape[0] + self._warn_if_bscan_exceeds_replay_window(sweep_count) sweep_width = float(max(sweep_count, 1)) x_min = 0.5 x_max = x_min + sweep_width @@ -236,9 +238,52 @@ class AppWindowBscanPlotMixin: ) return True + def _warn_if_bscan_exceeds_replay_window(self, sweep_count: int) -> None: + """Warn once when the image reaches past what the C++ processor can replay. + + Beyond that window a frame keeps the payload it was first computed with, so + editing Gain / Cut / Max depth / Start-Stop MHz silently leaves the older + columns on their previous settings — the image mixes two parameter sets. + """ + replay_window = int(self._bscan_cpp_replay_window) + if sweep_count <= replay_window: + return + + stale_count = sweep_count - replay_window + self._log_warning( + f"B-scan shows {sweep_count} sweeps but the processor replays only the newest " + f"{replay_window}; the older {stale_count} keep the settings they were captured with.", + details=( + "Changing Gain / Cut m / Max depth m / Start MHz / Stop MHz re-processes " + f"only the newest {replay_window} sweeps.\n" + f"Reduce 'Scans to show (stopped)' to {replay_window} for an image that is " + "coherent across every column." + ), + # Keyed on the operator-controlled window, not the live column count, so + # that repeated "Remove Last" in stopped mode does not re-warn every click. + once_key=( + f"bscan_window_exceeds_replay_{replay_window}_" + f"{self._bscan_display_window_scans()}" + ), + ) + + def _bscan_display_window_scans(self) -> int: + """Return how many past sweeps the B-scan should render right now. + + While acquisition runs the window stays at the C++ replay window: results + arrive continuously, the whole history is rebuilt on every new one, and a + 1000-wide rebuild on the live path would cost ~20x per frame. + + Once stopped, the operator reviews a frozen history, so the user-configured + window applies and may reach back over the whole GUI result deque. + """ + if self._supervisor.is_running(): + return int(self._bscan_cpp_replay_window) + return max(1, int(self._bscan_history_window.value())) + def _sync_bscan_history_from_results(self) -> None: """Rebuild B-scan history cache when live params or inputs changed.""" - self._advance_bscan_floor_to_cpp_window() + self._advance_bscan_floor_to_display_window() signature = self._bscan_signature() if signature == self._bscan_render_signature: return @@ -253,7 +298,7 @@ class AppWindowBscanPlotMixin: live_config=live_config, subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()), result_history=result_history, - history_limit=self._bscan_history_limit, + history_limit=self._bscan_display_window_scans(), floor_collection_id=self._bscan_history_floor_collection_id, ) @@ -262,7 +307,7 @@ class AppWindowBscanPlotMixin: result_history = list(self._result_history) history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results( result_history=result_history, - history_limit=self._bscan_history_limit, + history_limit=self._bscan_display_window_scans(), floor_collection_id=self._bscan_history_floor_collection_id, ) self._bscan_history_by_combo = history_by_combo @@ -351,29 +396,35 @@ class AppWindowBscanPlotMixin: self._bscan_depth_axis_by_combo.clear() self._bscan_render_signature = None - def _advance_bscan_floor_to_cpp_window(self) -> None: - """Clamp B-scan source history to C++ available replay window.""" - if not self._result_history: + def _advance_bscan_floor_to_display_window(self) -> None: + """Clamp B-scan source history to the active display window. + + The window counts RETAINED ENTRIES, so the floor is read off the n-th + newest entry rather than computed as `latest_id - window`. Collection ids + are not dense: the results ring overwrites unread slots when the producer + outruns the GUI poll loop, so the GUI keeps ids like 1..50, 81..130, ... + Subtracting the window from the newest id would then span far fewer than + `window` entries — asking for 150 sweeps yielded 87. + + Recomputed unconditionally rather than ratcheted upwards: widening the + window in stopped mode must be able to LOWER the floor and bring older + frames back into view. A stale floor cannot survive this way either, so + the previous special case for collection ids restarting on a new C++ run + is no longer needed. + """ + history = self._result_history + if not history: return - cpp_window_limit = min( - int(self._defaults_config.rings.preprocessed.capacity), - int(self._defaults_config.rings.results.capacity), - ) - cpp_window_limit = max(1, cpp_window_limit) - latest_collection_id = int(self._result_history[-1].collection_id) - current_floor = int(self._bscan_history_floor_collection_id) - - # Collection ids restart from 1 on new C++ run; release floor only while - # acquisition is running, so manual "remove last" behavior in stopped mode - # remains deterministic. - if latest_collection_id < current_floor and self._supervisor.is_running(): + window = self._bscan_display_window_scans() + if len(history) <= window: self._bscan_history_floor_collection_id = 0 - current_floor = 0 + return - floor_candidate = max(0, latest_collection_id - cpp_window_limit) - if floor_candidate > current_floor: - self._bscan_history_floor_collection_id = floor_candidate + # `_result_tail` keeps entries with `collection_id > floor`, so sit the + # floor one below the oldest entry that still fits in the window. + oldest_visible = history[len(history) - window] + self._bscan_history_floor_collection_id = max(0, int(oldest_visible.collection_id) - 1) def _ensure_phase_view_box(self) -> pg.ViewBox: """Create or return secondary right-axis ViewBox for phase curves.""" diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 8e96f34..6769b8f 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -18,6 +18,7 @@ from PyQt6.QtWidgets import ( QWidget, ) +from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget @@ -160,6 +161,15 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_subtract_mean_ascan = QCheckBox("Subtract mean A-scan") owner._bscan_subtract_mean_ascan.setChecked(bool(bscan_defaults.subtract_mean_ascan)) + owner._bscan_history_window = QSpinBox() + owner._bscan_history_window.setMinimum(1) + owner._bscan_history_window.setMaximum(GUI_SAVE_HISTORY_LIMIT) + owner._bscan_history_window.setValue(int(bscan_defaults.history_window_scans)) + owner._bscan_history_window.setToolTip( + "How many past sweeps the B-scan shows once acquisition is stopped. While " + "running, the window stays clamped to the C++ ring capacity." + ) + bscan_page = _build_processing_mode_page( owner._processing_mode_pages, [ @@ -169,6 +179,7 @@ def build_processing_group(owner) -> QGroupBox: ("Gain", owner._bscan_gain), ("Start MHz", owner._bscan_start_freq_mhz), ("Stop MHz", owner._bscan_stop_freq_mhz), + ("Scans to show (stopped)", owner._bscan_history_window), owner._bscan_subtract_mean_ascan, ], split_index=4, @@ -569,6 +580,7 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._bscan_subtract_mean_ascan.toggled.connect(owner._on_processing_live_settings_changed) + owner._bscan_history_window.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed) owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed) owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index a25368f..b2ade41 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -244,6 +244,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.bscan.subtract_mean_ascan, "gui.processing.bscan", ), + history_window_scans=_optional_int( + bscan_object, + "history_window_scans", + gui.processing.bscan.history_window_scans, + "gui.processing.bscan", + ), ), gpr=GuiGprStateModel( input_positions=_optional_string( @@ -579,6 +585,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "start_freq_mhz": gui.processing.bscan.start_freq_mhz, "stop_freq_mhz": gui.processing.bscan.stop_freq_mhz, "subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan, + "history_window_scans": gui.processing.bscan.history_window_scans, }, "gpr": { "input_positions": gui.processing.gpr.input_positions, diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 185ec09..496b58f 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -48,6 +48,10 @@ class GuiBscanStateModel: start_freq_mhz: float = 100.0 stop_freq_mhz: float = 8800.0 subtract_mean_ascan: bool = False + # How many past sweeps the B-scan heatmap renders once acquisition is stopped. + # While running the window stays at the C++ replay window (see + # `_cpp_bscan_replay_window_for_config`); this only widens the stopped-mode view. + history_window_scans: int = 50 @dataclass(slots=True) diff --git a/python_app/tests/test_bscan_history_window.py b/python_app/tests/test_bscan_history_window.py new file mode 100644 index 0000000..c23461d --- /dev/null +++ b/python_app/tests/test_bscan_history_window.py @@ -0,0 +1,140 @@ +"""Configurable B-scan display window. + +The B-scan used to be pinned to the C++ replay window (~50 sweeps) by two separate +mechanisms: the render-side history limit and a monotonically rising +`_bscan_history_floor_collection_id`. Widening only the first would have changed +nothing, because the floor kept filtering older collections out for good. + +These tests pin the two properties that make the stopped-mode review work: the +window follows acquisition state, and the floor is recomputed (not ratcheted) so a +widened window can bring already-discarded frames back into view. +""" + +from __future__ import annotations + +import os +import unittest +from pathlib import Path + +import numpy as np + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from PyQt6.QtWidgets import QApplication # noqa: E402 + +from python_app.gui.app_window import AppWindow # noqa: E402 +from python_app.models.dataset_model import ( # noqa: E402 + ComboKey, + ResultBlock, + ResultCollection, + ResultPayload, +) + +_app: QApplication | None = None +_window: AppWindow | None = None + + +def setUpModule() -> None: + global _app, _window + _app = QApplication.instance() or QApplication([]) + _window = AppWindow(Path(".")) + + +def tearDownModule() -> None: + if _window is not None: + _window.close() + + +def _bscan_result(collection_id: int) -> ResultCollection: + payload = ResultPayload( + processing_name="bscan", + kind=1, + frequency_hz=np.array([0.5, 1.0], dtype=np.float32), + trace=np.array([collection_id + 0j, collection_id + 0j], dtype=np.complex64), + ) + block = ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload]) + return ResultCollection(collection_id=collection_id, monotonic_ns=collection_id, blocks=[block]) + + +class BscanDisplayWindowTest(unittest.TestCase): + def setUp(self) -> None: + self.w = _window + self._original_is_running = self.w._supervisor.is_running + self.w._result_history.clear() + self.w._bscan_history_floor_collection_id = 0 + + def tearDown(self) -> None: + self.w._supervisor.is_running = self._original_is_running + self.w._result_history.clear() + self.w._bscan_history_floor_collection_id = 0 + + def _set_running(self, running: bool) -> None: + self.w._supervisor.is_running = lambda: running + + def _fill_history(self, count: int, *, id_step: int = 1) -> None: + for index in range(count): + self.w._result_history.append(_bscan_result(1 + index * id_step)) + + def test_running_acquisition_ignores_the_user_window(self) -> None: + # A live rebuild runs on every incoming result, so the live path stays pinned + # to the replay window no matter what the operator typed for stopped review. + self._set_running(True) + self.w._bscan_history_window.setValue(300) + self.assertEqual(self.w._bscan_display_window_scans(), self.w._bscan_cpp_replay_window) + + def test_stopped_acquisition_uses_the_user_window(self) -> None: + self._set_running(False) + self.w._bscan_history_window.setValue(300) + self.assertEqual(self.w._bscan_display_window_scans(), 300) + + def test_widening_the_window_lowers_the_floor(self) -> None: + # The regression this whole change exists for: a run leaves the floor high, + # and widening the window afterwards must pull it back down. + self._fill_history(300) + self._set_running(True) + self.w._advance_bscan_floor_to_display_window() + raised_floor = self.w._bscan_history_floor_collection_id + self.assertEqual(raised_floor, 300 - self.w._bscan_cpp_replay_window) + + self._set_running(False) + self.w._bscan_history_window.setValue(300) + self.w._advance_bscan_floor_to_display_window() + self.assertEqual(self.w._bscan_history_floor_collection_id, 0) + + def test_floor_survives_collection_ids_restarting(self) -> None: + # A new C++ run restarts ids from 1. The unconditional recompute must not + # leave a stale high floor that hides the whole fresh run. + self._fill_history(300) + self._set_running(True) + self.w._advance_bscan_floor_to_display_window() + self.assertGreater(self.w._bscan_history_floor_collection_id, 0) + + self.w._result_history.clear() + self._fill_history(5) + self.w._advance_bscan_floor_to_display_window() + self.assertEqual(self.w._bscan_history_floor_collection_id, 0) + + def test_window_counts_entries_not_collection_ids(self) -> None: + # The results ring overwrites unread slots when the producer outruns the GUI + # poll loop, so retained collection ids are sparse. A floor of + # `latest_id - window` then spans far fewer than `window` entries: this is + # exactly the case where asking for 150 sweeps rendered only 87. + self._fill_history(300, id_step=3) + self._set_running(False) + self.w._bscan_history_window.setValue(150) + self.w._sync_bscan_history_from_results() + self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 150) + + def test_rebuild_renders_the_full_widened_window(self) -> None: + self._fill_history(300) + self._set_running(False) + self.w._bscan_history_window.setValue(300) + self.w._sync_bscan_history_from_results() + self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300) + + self.w._bscan_history_window.setValue(50) + self.w._sync_bscan_history_from_results() + self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 50) + + +if __name__ == "__main__": + unittest.main()