added a bscan extension
This commit is contained in:
@@ -274,7 +274,7 @@ class AppWindow(
|
|||||||
|
|
||||||
def _init_history_state(self) -> None:
|
def _init_history_state(self) -> None:
|
||||||
"""Initialize runtime history buffers and render-cache state."""
|
"""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()
|
save_history_limit = self._save_history_limit_from_config()
|
||||||
self._raw_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
|
self._raw_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
|
||||||
self._pre_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.
|
# 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._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_history_by_combo = {}
|
||||||
self._bscan_depth_axis_by_combo = {}
|
self._bscan_depth_axis_by_combo = {}
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._bscan_history_floor_collection_id = 0
|
||||||
@@ -305,9 +305,9 @@ class AppWindow(
|
|||||||
self._active_processing_mode = "pass_through"
|
self._active_processing_mode = "pass_through"
|
||||||
self._radar_limits: dict[str, float | int] | None = None
|
self._radar_limits: dict[str, float | int] | None = None
|
||||||
|
|
||||||
def _history_limit_from_config(self) -> int:
|
def _cpp_bscan_replay_window_from_config(self) -> int:
|
||||||
"""Return B-scan render history limit derived from configured ring capacities."""
|
"""Return the C++ B-scan replay window for the active config."""
|
||||||
return self._history_limit_for_config(self._defaults_config)
|
return self._cpp_bscan_replay_window_for_config(self._defaults_config)
|
||||||
|
|
||||||
def _save_history_limit_from_config(self) -> int:
|
def _save_history_limit_from_config(self) -> int:
|
||||||
"""Return maxlen for GUI snapshot-save deques (independent of ring capacities)."""
|
"""Return maxlen for GUI snapshot-save deques (independent of ring capacities)."""
|
||||||
|
|||||||
@@ -121,15 +121,15 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
|
|
||||||
Save-side deques use a config-independent limit so that processing-side
|
Save-side deques use a config-independent limit so that processing-side
|
||||||
ring capacities can stay small without truncating the save buffer. The
|
ring capacities can stay small without truncating the save buffer. The
|
||||||
B-scan render limit still follows ring capacities to keep plot updates
|
C++ replay window still follows ring capacities, since it bounds how much
|
||||||
responsive.
|
of the history the processor can re-publish coherently.
|
||||||
"""
|
"""
|
||||||
save_history_limit = self._save_history_limit_for_config(config)
|
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._raw_history = deque(self._raw_history, maxlen=save_history_limit)
|
||||||
self._pre_history = deque(self._pre_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._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()
|
self._clear_bscan_plot_history()
|
||||||
|
|
||||||
def _save_current_config(self) -> None:
|
def _save_current_config(self) -> None:
|
||||||
@@ -283,6 +283,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
self._bscan_start_freq_mhz,
|
self._bscan_start_freq_mhz,
|
||||||
self._bscan_stop_freq_mhz,
|
self._bscan_stop_freq_mhz,
|
||||||
self._bscan_subtract_mean_ascan,
|
self._bscan_subtract_mean_ascan,
|
||||||
|
self._bscan_history_window,
|
||||||
self._gpr_relative_permittivity,
|
self._gpr_relative_permittivity,
|
||||||
self._gpr_tx_geometry_input,
|
self._gpr_tx_geometry_input,
|
||||||
self._gpr_rx_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_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_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_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_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
|
||||||
self._gpr_tx_geometry_input.setPlainText(
|
self._gpr_tx_geometry_input.setPlainText(
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ from python_app.storage.npz_store import radar_key_from_config
|
|||||||
# history without touching the processing-side ring sizes.
|
# history without touching the processing-side ring sizes.
|
||||||
GUI_SAVE_HISTORY_LIMIT: int = 1000
|
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:
|
class AppWindowConfigStateBuildersMixin:
|
||||||
"""Build stable and GUI-only config models from current widget state."""
|
"""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)
|
return (min(x_values) - margin_m, max(x_values) + margin_m)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _history_limit_for_config(config: RunConfigModel) -> int:
|
def _cpp_bscan_replay_window_for_config(config: RunConfigModel) -> int:
|
||||||
"""Return B-scan render history limit derived from config ring capacities."""
|
"""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(
|
return max(
|
||||||
1,
|
1,
|
||||||
min(
|
min(
|
||||||
int(config.rings.raw_tap.capacity),
|
int(config.rings.preprocessed.capacity),
|
||||||
int(config.rings.preprocessed_tap.capacity),
|
|
||||||
int(config.rings.results.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`.
|
Independent of ring capacities — see :data:`GUI_SAVE_HISTORY_LIMIT`.
|
||||||
The `config` argument is kept for symmetry with
|
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.
|
overrides.
|
||||||
"""
|
"""
|
||||||
del config
|
del config
|
||||||
@@ -344,6 +361,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||||
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||||
subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
||||||
|
history_window_scans=int(self._bscan_history_window.value()),
|
||||||
),
|
),
|
||||||
gpr=GuiGprStateModel(
|
gpr=GuiGprStateModel(
|
||||||
input_positions=self._gpr_input_positions_input.text().strip(),
|
input_positions=self._gpr_input_positions_input.text().strip(),
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def build_bscan_signature(
|
|||||||
floor_collection_id=floor_collection_id,
|
floor_collection_id=floor_collection_id,
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
|
int(history_limit),
|
||||||
str(live_config.bscan_axis),
|
str(live_config.bscan_axis),
|
||||||
str(live_config.bscan_channel),
|
str(live_config.bscan_channel),
|
||||||
float(live_config.bscan_cut_m),
|
float(live_config.bscan_cut_m),
|
||||||
@@ -213,6 +214,7 @@ class AppWindowBscanPlotMixin:
|
|||||||
depth_max = float(np.max(depth_axis))
|
depth_max = float(np.max(depth_axis))
|
||||||
depth_span = max(depth_max - depth_min, 1e-6)
|
depth_span = max(depth_max - depth_min, 1e-6)
|
||||||
sweep_count = sweeps.shape[0]
|
sweep_count = sweeps.shape[0]
|
||||||
|
self._warn_if_bscan_exceeds_replay_window(sweep_count)
|
||||||
sweep_width = float(max(sweep_count, 1))
|
sweep_width = float(max(sweep_count, 1))
|
||||||
x_min = 0.5
|
x_min = 0.5
|
||||||
x_max = x_min + sweep_width
|
x_max = x_min + sweep_width
|
||||||
@@ -236,9 +238,52 @@ class AppWindowBscanPlotMixin:
|
|||||||
)
|
)
|
||||||
return True
|
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:
|
def _sync_bscan_history_from_results(self) -> None:
|
||||||
"""Rebuild B-scan history cache when live params or inputs changed."""
|
"""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()
|
signature = self._bscan_signature()
|
||||||
if signature == self._bscan_render_signature:
|
if signature == self._bscan_render_signature:
|
||||||
return
|
return
|
||||||
@@ -253,7 +298,7 @@ class AppWindowBscanPlotMixin:
|
|||||||
live_config=live_config,
|
live_config=live_config,
|
||||||
subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
||||||
result_history=result_history,
|
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,
|
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,7 +307,7 @@ class AppWindowBscanPlotMixin:
|
|||||||
result_history = list(self._result_history)
|
result_history = list(self._result_history)
|
||||||
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
|
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
|
||||||
result_history=result_history,
|
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,
|
floor_collection_id=self._bscan_history_floor_collection_id,
|
||||||
)
|
)
|
||||||
self._bscan_history_by_combo = history_by_combo
|
self._bscan_history_by_combo = history_by_combo
|
||||||
@@ -351,29 +396,35 @@ class AppWindowBscanPlotMixin:
|
|||||||
self._bscan_depth_axis_by_combo.clear()
|
self._bscan_depth_axis_by_combo.clear()
|
||||||
self._bscan_render_signature = None
|
self._bscan_render_signature = None
|
||||||
|
|
||||||
def _advance_bscan_floor_to_cpp_window(self) -> None:
|
def _advance_bscan_floor_to_display_window(self) -> None:
|
||||||
"""Clamp B-scan source history to C++ available replay window."""
|
"""Clamp B-scan source history to the active display window.
|
||||||
if not self._result_history:
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
cpp_window_limit = min(
|
window = self._bscan_display_window_scans()
|
||||||
int(self._defaults_config.rings.preprocessed.capacity),
|
if len(history) <= window:
|
||||||
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():
|
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._bscan_history_floor_collection_id = 0
|
||||||
current_floor = 0
|
return
|
||||||
|
|
||||||
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
|
# `_result_tail` keeps entries with `collection_id > floor`, so sit the
|
||||||
if floor_candidate > current_floor:
|
# floor one below the oldest entry that still fits in the window.
|
||||||
self._bscan_history_floor_collection_id = floor_candidate
|
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:
|
def _ensure_phase_view_box(self) -> pg.ViewBox:
|
||||||
"""Create or return secondary right-axis ViewBox for phase curves."""
|
"""Create or return secondary right-axis ViewBox for phase curves."""
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QWidget,
|
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
|
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 = QCheckBox("Subtract mean A-scan")
|
||||||
owner._bscan_subtract_mean_ascan.setChecked(bool(bscan_defaults.subtract_mean_ascan))
|
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(
|
bscan_page = _build_processing_mode_page(
|
||||||
owner._processing_mode_pages,
|
owner._processing_mode_pages,
|
||||||
[
|
[
|
||||||
@@ -169,6 +179,7 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
("Gain", owner._bscan_gain),
|
("Gain", owner._bscan_gain),
|
||||||
("Start MHz", owner._bscan_start_freq_mhz),
|
("Start MHz", owner._bscan_start_freq_mhz),
|
||||||
("Stop MHz", owner._bscan_stop_freq_mhz),
|
("Stop MHz", owner._bscan_stop_freq_mhz),
|
||||||
|
("Scans to show (stopped)", owner._bscan_history_window),
|
||||||
owner._bscan_subtract_mean_ascan,
|
owner._bscan_subtract_mean_ascan,
|
||||||
],
|
],
|
||||||
split_index=4,
|
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_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_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_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_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_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
|
|||||||
@@ -244,6 +244,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.bscan.subtract_mean_ascan,
|
gui.processing.bscan.subtract_mean_ascan,
|
||||||
"gui.processing.bscan",
|
"gui.processing.bscan",
|
||||||
),
|
),
|
||||||
|
history_window_scans=_optional_int(
|
||||||
|
bscan_object,
|
||||||
|
"history_window_scans",
|
||||||
|
gui.processing.bscan.history_window_scans,
|
||||||
|
"gui.processing.bscan",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
gpr=GuiGprStateModel(
|
gpr=GuiGprStateModel(
|
||||||
input_positions=_optional_string(
|
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,
|
"start_freq_mhz": gui.processing.bscan.start_freq_mhz,
|
||||||
"stop_freq_mhz": gui.processing.bscan.stop_freq_mhz,
|
"stop_freq_mhz": gui.processing.bscan.stop_freq_mhz,
|
||||||
"subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan,
|
"subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan,
|
||||||
|
"history_window_scans": gui.processing.bscan.history_window_scans,
|
||||||
},
|
},
|
||||||
"gpr": {
|
"gpr": {
|
||||||
"input_positions": gui.processing.gpr.input_positions,
|
"input_positions": gui.processing.gpr.input_positions,
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class GuiBscanStateModel:
|
|||||||
start_freq_mhz: float = 100.0
|
start_freq_mhz: float = 100.0
|
||||||
stop_freq_mhz: float = 8800.0
|
stop_freq_mhz: float = 8800.0
|
||||||
subtract_mean_ascan: bool = False
|
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)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user