Compare commits
6
Commits
e219f6ec02
...
74723bb635
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74723bb635 | ||
|
|
7c0ae1ecf8 | ||
|
|
b183401e6f | ||
|
|
d61b59b9a4 | ||
|
|
7c6cab07fc | ||
|
|
68bec25f17 |
+2
-1
@@ -227,4 +227,5 @@ python_app/runtime
|
|||||||
SHARE_INTERNET_TO_PI.md
|
SHARE_INTERNET_TO_PI.md
|
||||||
|
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
./docs
|
docs/
|
||||||
|
test_end_2/
|
||||||
@@ -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,11 +282,21 @@ 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_render_signature = None
|
self._bscan_render_signature = None
|
||||||
|
# Writer into the processor's input ring, used to re-feed retained sweeps so the
|
||||||
|
# whole visible B-scan is recomputed. Opened lazily, only while acquisition is
|
||||||
|
# stopped (see AppWindowBscanReplayMixin).
|
||||||
|
self._replay_ring_writer = None
|
||||||
|
self._bscan_replay_active = False
|
||||||
|
# Results from the last completed replay. While non-empty they, not
|
||||||
|
# `_result_history`, are what the B-scan renders (see AppWindowBscanReplayMixin).
|
||||||
|
self._bscan_replay_results = []
|
||||||
|
self._bscan_reprocess_timer = QTimer(self)
|
||||||
|
self._bscan_reprocess_timer.setSingleShot(True)
|
||||||
|
self._bscan_reprocess_timer.timeout.connect(self._reprocess_history_through_processor)
|
||||||
self._gpr_lookup_table = None
|
self._gpr_lookup_table = None
|
||||||
self._gpr_image_item = None
|
self._gpr_image_item = None
|
||||||
self._gpr_tx_item = None
|
self._gpr_tx_item = None
|
||||||
@@ -305,9 +315,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)."""
|
||||||
@@ -745,6 +755,10 @@ class AppWindow(
|
|||||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||||
self._stop_control_button_watcher()
|
self._stop_control_button_watcher()
|
||||||
self._resume_pipeline_after_capture = False
|
self._resume_pipeline_after_capture = False
|
||||||
|
# 0) Drop the B-scan replay writer so a pending debounce cannot push into a
|
||||||
|
# ring we are about to tear down.
|
||||||
|
self._bscan_reprocess_timer.stop()
|
||||||
|
self._close_replay_ring_writer()
|
||||||
# 1) Abort active capture first (releases exclusive hardware resources).
|
# 1) Abort active capture first (releases exclusive hardware resources).
|
||||||
self._abort_capture_sequence(resume_pipeline=False)
|
self._abort_capture_sequence(resume_pipeline=False)
|
||||||
# 2) Stop all managed processes/readers.
|
# 2) Stop all managed processes/readers.
|
||||||
|
|||||||
@@ -360,6 +360,10 @@ class AppWindowLiveProcessingMixin:
|
|||||||
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||||
self._sync_bscan_history_from_results()
|
self._sync_bscan_history_from_results()
|
||||||
self._draw_bscan_heatmap_from_history()
|
self._draw_bscan_heatmap_from_history()
|
||||||
|
# The processor only refreshed its own newest sweeps; queue a full
|
||||||
|
# recompute of everything on screen. Debounced, so dragging a spin box
|
||||||
|
# sends one burst instead of one per step.
|
||||||
|
self._schedule_bscan_history_reprocess()
|
||||||
elif self._is_gpr_processing_mode(current_mode):
|
elif self._is_gpr_processing_mode(current_mode):
|
||||||
latest = self._drain_results_until_quiet(timeout_s=0.8, poll_s=0.02)
|
latest = self._drain_results_until_quiet(timeout_s=0.8, poll_s=0.02)
|
||||||
collection = latest
|
collection = latest
|
||||||
@@ -508,7 +512,6 @@ class AppWindowLiveProcessingMixin:
|
|||||||
|
|
||||||
def _clear_history_mode_caches(self) -> None:
|
def _clear_history_mode_caches(self) -> None:
|
||||||
"""Drop cached render state for pass-through, B-scan, and GPR views."""
|
"""Drop cached render state for pass-through, B-scan, and GPR views."""
|
||||||
self._bscan_history_floor_collection_id = 0
|
|
||||||
self._clear_bscan_plot_history()
|
self._clear_bscan_plot_history()
|
||||||
if hasattr(self, "_bscan_plot"):
|
if hasattr(self, "_bscan_plot"):
|
||||||
self._bscan_plot.clear()
|
self._bscan_plot.clear()
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
"""Return the canonical virtual combo matrix shown for matrix-mode radars."""
|
"""Return the canonical virtual combo matrix shown for matrix-mode radars."""
|
||||||
return ",".join(
|
return ",".join(
|
||||||
f"{int(combo.input)}:{int(combo.output)}"
|
f"{int(combo.input)}:{int(combo.output)}"
|
||||||
for combo in RunConfigModel.build_matrix_radar_virtual_combos()
|
for combo in self._defaults_config.build_runtime_combos()
|
||||||
)
|
)
|
||||||
|
|
||||||
def _sync_pass_through_y_controls(self) -> None:
|
def _sync_pass_through_y_controls(self) -> None:
|
||||||
@@ -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."""
|
||||||
@@ -125,7 +132,7 @@ class AppWindowConfigStateBuildersMixin:
|
|||||||
if config.is_matrix_radar:
|
if config.is_matrix_radar:
|
||||||
return ",".join(
|
return ",".join(
|
||||||
f"{int(combo.input)}:{int(combo.output)}"
|
f"{int(combo.input)}:{int(combo.output)}"
|
||||||
for combo in RunConfigModel.build_matrix_radar_virtual_combos()
|
for combo in config.build_runtime_combos()
|
||||||
)
|
)
|
||||||
combos = list(config.combos)
|
combos = list(config.combos)
|
||||||
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -150,6 +150,13 @@ class AppWindowPipelineMixin:
|
|||||||
# completed and the GUI hung.
|
# completed and the GUI hung.
|
||||||
single_capture_start_ns = time.monotonic_ns() if single_capture else None
|
single_capture_start_ns = time.monotonic_ns() if single_capture else None
|
||||||
|
|
||||||
|
# `data_preprocessor` is about to become the owner of the preprocessed ring
|
||||||
|
# again, so the GUI must stop holding a writer into it. A queued replay would
|
||||||
|
# otherwise interleave with the real producer.
|
||||||
|
self._bscan_reprocess_timer.stop()
|
||||||
|
self._close_replay_ring_writer()
|
||||||
|
self._discard_bscan_replay_results()
|
||||||
|
|
||||||
self._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture)
|
self._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture)
|
||||||
self._close_readers()
|
self._close_readers()
|
||||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||||
@@ -682,7 +689,7 @@ class AppWindowPipelineMixin:
|
|||||||
def _reset_runtime_history(self) -> None:
|
def _reset_runtime_history(self) -> None:
|
||||||
"""Reset runtime history and B-scan caches."""
|
"""Reset runtime history and B-scan caches."""
|
||||||
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._discard_bscan_replay_results()
|
||||||
self._clear_history_mode_caches()
|
self._clear_history_mode_caches()
|
||||||
self._update_history_indicator()
|
self._update_history_indicator()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Plot-rendering mixins split by rendering mode."""
|
"""Plot-rendering mixins split by rendering mode."""
|
||||||
|
|
||||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import AppWindowBscanPlotMixin
|
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import AppWindowBscanPlotMixin
|
||||||
|
from python_app.gui.controllers.app_window_plot.bscan_replay_mixin import AppWindowBscanReplayMixin
|
||||||
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
|
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
|
||||||
from python_app.gui.controllers.app_window_plot.trace_plot_mixin import AppWindowTracePlotMixin
|
from python_app.gui.controllers.app_window_plot.trace_plot_mixin import AppWindowTracePlotMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AppWindowBscanPlotMixin",
|
"AppWindowBscanPlotMixin",
|
||||||
|
"AppWindowBscanReplayMixin",
|
||||||
"AppWindowGprPlotMixin",
|
"AppWindowGprPlotMixin",
|
||||||
"AppWindowTracePlotMixin",
|
"AppWindowTracePlotMixin",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,17 +16,19 @@ def _result_tail(
|
|||||||
*,
|
*,
|
||||||
result_history: list[ResultCollection],
|
result_history: list[ResultCollection],
|
||||||
history_limit: int,
|
history_limit: int,
|
||||||
floor_collection_id: int,
|
|
||||||
) -> list[ResultCollection]:
|
) -> list[ResultCollection]:
|
||||||
"""Return filtered and de-duplicated result-history tail for B-scan usage."""
|
"""Return the de-duplicated newest `history_limit` entries for B-scan usage.
|
||||||
filtered = [
|
|
||||||
collection
|
Selection is purely positional. An earlier version also dropped entries below a
|
||||||
for collection in result_history[-history_limit:]
|
`collection_id` floor, which cannot work here: ids are neither dense (the results
|
||||||
if int(collection.collection_id) > int(floor_collection_id)
|
ring overwrites unread slots) nor monotonic across a run boundary (the C++ side
|
||||||
]
|
numbers from 1 again). Given the floor was derived from the first entry of this
|
||||||
|
very slice, the comparison provably removed nothing when ids ascend, and removed
|
||||||
|
exactly the newest frames when they do not.
|
||||||
|
"""
|
||||||
unique_reversed_tail: list[ResultCollection] = []
|
unique_reversed_tail: list[ResultCollection] = []
|
||||||
seen_keys: set[tuple[int, int]] = set()
|
seen_keys: set[tuple[int, int]] = set()
|
||||||
for collection in reversed(filtered):
|
for collection in reversed(result_history[-history_limit:]):
|
||||||
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
||||||
if key in seen_keys:
|
if key in seen_keys:
|
||||||
continue
|
continue
|
||||||
@@ -43,15 +45,14 @@ def build_bscan_signature(
|
|||||||
subtract_mean_ascan_enabled: bool,
|
subtract_mean_ascan_enabled: bool,
|
||||||
result_history: list[ResultCollection],
|
result_history: list[ResultCollection],
|
||||||
history_limit: int,
|
history_limit: int,
|
||||||
floor_collection_id: int,
|
|
||||||
) -> tuple[object, ...]:
|
) -> tuple[object, ...]:
|
||||||
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
||||||
result_tail = _result_tail(
|
result_tail = _result_tail(
|
||||||
result_history=result_history,
|
result_history=result_history,
|
||||||
history_limit=history_limit,
|
history_limit=history_limit,
|
||||||
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),
|
||||||
@@ -60,7 +61,6 @@ def build_bscan_signature(
|
|||||||
float(live_config.bscan_start_freq_mhz),
|
float(live_config.bscan_start_freq_mhz),
|
||||||
float(live_config.bscan_stop_freq_mhz),
|
float(live_config.bscan_stop_freq_mhz),
|
||||||
bool(subtract_mean_ascan_enabled),
|
bool(subtract_mean_ascan_enabled),
|
||||||
int(floor_collection_id),
|
|
||||||
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,19 +81,27 @@ def apply_mean_ascan_subtraction(
|
|||||||
def rebuild_bscan_history_from_results(
|
def rebuild_bscan_history_from_results(
|
||||||
result_history: list[ResultCollection],
|
result_history: list[ResultCollection],
|
||||||
history_limit: int,
|
history_limit: int,
|
||||||
floor_collection_id: int,
|
stats: dict[str, object] | None = None,
|
||||||
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
||||||
"""Rebuild B-scan history and depth axes from processed result payloads."""
|
"""Rebuild B-scan history and depth axes from processed result payloads.
|
||||||
|
|
||||||
|
Pass `stats` to receive a breakdown of why the rebuilt image may hold fewer
|
||||||
|
columns than `history_limit`. The three causes are independent and only
|
||||||
|
distinguishable here: too little history, frames carrying no `bscan` payload, and
|
||||||
|
depth-axis changes that reset the accumulated deque (see below).
|
||||||
|
"""
|
||||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||||
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||||
|
|
||||||
result_tail = _result_tail(
|
result_tail = _result_tail(
|
||||||
result_history=result_history,
|
result_history=result_history,
|
||||||
history_limit=history_limit,
|
history_limit=history_limit,
|
||||||
floor_collection_id=floor_collection_id,
|
|
||||||
)
|
)
|
||||||
|
axis_resets = 0
|
||||||
|
without_bscan = 0
|
||||||
|
|
||||||
for collection in result_tail:
|
for collection in result_tail:
|
||||||
|
carried_bscan = False
|
||||||
for block in collection.blocks:
|
for block in collection.blocks:
|
||||||
key = (block.combo.input, block.combo.output)
|
key = (block.combo.input, block.combo.output)
|
||||||
for payload in block.payloads:
|
for payload in block.payloads:
|
||||||
@@ -109,6 +117,7 @@ def rebuild_bscan_history_from_results(
|
|||||||
if depth_axis.size == 0 or amplitudes.size == 0:
|
if depth_axis.size == 0 or amplitudes.size == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
carried_bscan = True
|
||||||
history = history_by_combo.get(key)
|
history = history_by_combo.get(key)
|
||||||
stored_axis = depth_axis_by_combo.get(key)
|
stored_axis = depth_axis_by_combo.get(key)
|
||||||
if (
|
if (
|
||||||
@@ -117,11 +126,25 @@ def rebuild_bscan_history_from_results(
|
|||||||
or stored_axis.shape != depth_axis.shape
|
or stored_axis.shape != depth_axis.shape
|
||||||
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
||||||
):
|
):
|
||||||
|
# A changed depth axis makes previously accumulated columns
|
||||||
|
# un-stackable, so the deque restarts and everything gathered so far
|
||||||
|
# for this combo is dropped. Frames computed with different
|
||||||
|
# bscan_max_depth_m / frequency bounds land here — which is exactly
|
||||||
|
# what a partially replayed history looks like.
|
||||||
|
if history is not None:
|
||||||
|
axis_resets += 1
|
||||||
history = deque(maxlen=history_limit)
|
history = deque(maxlen=history_limit)
|
||||||
history_by_combo[key] = history
|
history_by_combo[key] = history
|
||||||
depth_axis_by_combo[key] = depth_axis.copy()
|
depth_axis_by_combo[key] = depth_axis.copy()
|
||||||
|
|
||||||
history.append(amplitudes.copy())
|
history.append(amplitudes.copy())
|
||||||
|
if not carried_bscan:
|
||||||
|
without_bscan += 1
|
||||||
|
|
||||||
|
if stats is not None:
|
||||||
|
stats["tail"] = len(result_tail)
|
||||||
|
stats["without_bscan"] = without_bscan
|
||||||
|
stats["axis_resets"] = axis_resets
|
||||||
|
|
||||||
return history_by_combo, depth_axis_by_combo
|
return history_by_combo, depth_axis_by_combo
|
||||||
|
|
||||||
@@ -213,6 +236,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_window_underfilled()
|
||||||
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 +260,48 @@ class AppWindowBscanPlotMixin:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _warn_if_bscan_window_underfilled(self) -> None:
|
||||||
|
"""Warn once when there is less retained raw material than the operator asked for.
|
||||||
|
|
||||||
|
Settings edits are recomputed across the whole image by re-feeding
|
||||||
|
`_pre_history` to the processor (see :class:`AppWindowBscanReplayMixin`), so a
|
||||||
|
wide window is no longer a coherency problem. What it can still be is an empty
|
||||||
|
promise: asking for more sweeps than were ever captured simply shows fewer.
|
||||||
|
"""
|
||||||
|
window = self._bscan_display_window_scans()
|
||||||
|
retained = len(self._pre_history)
|
||||||
|
if retained >= window:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._log_warning(
|
||||||
|
f"B-scan is set to show {window} sweeps but only {retained} are retained; "
|
||||||
|
"the image shows what history there is.",
|
||||||
|
details=(
|
||||||
|
"The GUI keeps a bounded history of preprocessed sweeps, so a window "
|
||||||
|
"wider than the run itself cannot be filled.\n"
|
||||||
|
f"Capture more sweeps, or set 'Scans to show (stopped)' to {retained} or less."
|
||||||
|
),
|
||||||
|
# Keyed on the operator-controlled window rather than the live retained count,
|
||||||
|
# so that repeated "Remove Last" does not re-warn on every click.
|
||||||
|
once_key=f"bscan_window_underfilled_{window}",
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
|
||||||
signature = self._bscan_signature()
|
signature = self._bscan_signature()
|
||||||
if signature == self._bscan_render_signature:
|
if signature == self._bscan_render_signature:
|
||||||
return
|
return
|
||||||
@@ -248,25 +311,84 @@ class AppWindowBscanPlotMixin:
|
|||||||
def _bscan_signature(self) -> tuple[object, ...]:
|
def _bscan_signature(self) -> tuple[object, ...]:
|
||||||
"""Build state signature for B-scan history cache invalidation."""
|
"""Build state signature for B-scan history cache invalidation."""
|
||||||
live_config = self._live_processing_config()
|
live_config = self._live_processing_config()
|
||||||
result_history = list(self._result_history)
|
# Must read the same source the rebuild will, or the cache decides nothing
|
||||||
|
# changed while the image would in fact be built from different collections.
|
||||||
|
result_history, _from_replay = self._bscan_source_collections()
|
||||||
return build_bscan_signature(
|
return build_bscan_signature(
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _bscan_source_collections(self) -> tuple[list[ResultCollection], bool]:
|
||||||
|
"""Return the collections the image is built from, and whether they are replayed.
|
||||||
|
|
||||||
|
A completed replay is the better source: it is exactly `window` long and every
|
||||||
|
entry went through the processor with the same settings. The runtime history is
|
||||||
|
not usable right after one, because results whose ids it never held are appended
|
||||||
|
out of order, leaving the deque unsorted for the rest of the session.
|
||||||
|
"""
|
||||||
|
replayed = getattr(self, "_bscan_replay_results", None)
|
||||||
|
if replayed:
|
||||||
|
return list(replayed), True
|
||||||
|
return list(self._result_history), False
|
||||||
|
|
||||||
def _rebuild_bscan_history_from_results(self) -> None:
|
def _rebuild_bscan_history_from_results(self) -> None:
|
||||||
"""Recompute B-scan history cache from results history buffer."""
|
"""Recompute B-scan history cache from results history buffer."""
|
||||||
result_history = list(self._result_history)
|
result_history, from_replay = self._bscan_source_collections()
|
||||||
|
window = self._bscan_display_window_scans()
|
||||||
|
stats: dict[str, object] = {}
|
||||||
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=window,
|
||||||
floor_collection_id=self._bscan_history_floor_collection_id,
|
stats=stats,
|
||||||
)
|
)
|
||||||
self._bscan_history_by_combo = history_by_combo
|
self._bscan_history_by_combo = history_by_combo
|
||||||
self._bscan_depth_axis_by_combo = depth_axis_by_combo
|
self._bscan_depth_axis_by_combo = depth_axis_by_combo
|
||||||
|
self._log_bscan_window_shortfall(
|
||||||
|
window=window,
|
||||||
|
result_history_len=len(result_history),
|
||||||
|
from_replay=from_replay,
|
||||||
|
history_by_combo=history_by_combo,
|
||||||
|
stats=stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _log_bscan_window_shortfall(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
window: int,
|
||||||
|
result_history_len: int,
|
||||||
|
from_replay: bool,
|
||||||
|
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
||||||
|
stats: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
"""Explain at DEBUG why the image holds fewer columns than were requested.
|
||||||
|
|
||||||
|
Three independent causes produce the same symptom, so each is reported as its
|
||||||
|
own number rather than a single verdict:
|
||||||
|
* `tail` — the run simply produced fewer sweeps than were asked for;
|
||||||
|
* `without_bscan` — frames processed in another mode carry no bscan payload;
|
||||||
|
* `axis_resets` — a changed depth axis restarted the deque, dropping every
|
||||||
|
column gathered before it (the usual cause after a partial replay);
|
||||||
|
* per-combo counts — the image renders one combo at a time.
|
||||||
|
"""
|
||||||
|
rendered = max((len(history) for history in history_by_combo.values()), default=0)
|
||||||
|
if rendered >= window:
|
||||||
|
return
|
||||||
|
|
||||||
|
per_combo = ", ".join(
|
||||||
|
f"in{input_pos}/out{output_pos}={len(history)}"
|
||||||
|
for (input_pos, output_pos), history in sorted(history_by_combo.items())
|
||||||
|
)
|
||||||
|
self._log_debug(
|
||||||
|
f"B-scan window not filled: rendered={rendered} of requested={window}. "
|
||||||
|
f"source={'replay' if from_replay else 'result history'} len={result_history_len}, "
|
||||||
|
f"newest-{window} tail={stats.get('tail')}, "
|
||||||
|
f"of those without a bscan payload={stats.get('without_bscan')}, "
|
||||||
|
f"depth-axis resets={stats.get('axis_resets')}. "
|
||||||
|
f"Per combo: {per_combo or 'none'}."
|
||||||
|
)
|
||||||
|
|
||||||
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
|
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
|
||||||
"""Choose combo history key to render."""
|
"""Choose combo history key to render."""
|
||||||
@@ -351,30 +473,6 @@ 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:
|
|
||||||
"""Clamp B-scan source history to C++ available replay window."""
|
|
||||||
if not self._result_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():
|
|
||||||
self._bscan_history_floor_collection_id = 0
|
|
||||||
current_floor = 0
|
|
||||||
|
|
||||||
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
|
|
||||||
if floor_candidate > current_floor:
|
|
||||||
self._bscan_history_floor_collection_id = floor_candidate
|
|
||||||
|
|
||||||
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."""
|
||||||
plot_item = self._bscan_plot.getPlotItem()
|
plot_item = self._bscan_plot.getPlotItem()
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""Re-feed retained sweeps to the processor so it recomputes the whole B-scan.
|
||||||
|
|
||||||
|
The processor keeps only ~50 preprocessed sweeps of its own (`kBscanReplayWindow` in
|
||||||
|
`data_processor.cpp`), so changing a live B-scan setting used to refresh just the newest
|
||||||
|
50 columns while everything older kept the parameters it was captured with — one image
|
||||||
|
stitched from two parameter sets.
|
||||||
|
|
||||||
|
The GUI already holds up to 1000 preprocessed sweeps in `_pre_history`, byte-identical
|
||||||
|
to what the processor consumes: `data_preprocessor` pushes the very same serialized
|
||||||
|
buffer into both the working ring and the tap the GUI reads. So instead of making the
|
||||||
|
processor hoard sweeps, we hand its own raw material back to it and let the ordinary
|
||||||
|
`pop -> process -> publish` path recompute every column with the current settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from python_app.gui.runtime.history import record_result_history
|
||||||
|
from python_app.orchestration.shm import ShmRingWriter
|
||||||
|
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection
|
||||||
|
|
||||||
|
# The results ring overwrites unread slots, so a burst must never exceed what the GUI
|
||||||
|
# drains between chunks. Comfortably below the (typically 50-slot) ring capacity.
|
||||||
|
_REPLAY_CHUNK_SIZE = 12
|
||||||
|
|
||||||
|
# Per-chunk budget. Generous: the processor may be busy, and overshooting only costs
|
||||||
|
# a redraw that reflects fewer columns.
|
||||||
|
_REPLAY_CHUNK_TIMEOUT_S = 2.0
|
||||||
|
|
||||||
|
# Restarting the timer on every edit collapses a spin-box drag into one replay.
|
||||||
|
_BSCAN_REPROCESS_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
|
||||||
|
class AppWindowBscanReplayMixin:
|
||||||
|
"""Recomputes the full visible B-scan by re-feeding sweeps to the processor."""
|
||||||
|
|
||||||
|
def _reprocess_history_through_processor(self) -> bool:
|
||||||
|
"""Re-feed the visible tail of `_pre_history` so every column is recomputed.
|
||||||
|
|
||||||
|
Returns True when a replay actually ran. Refuses (returning False) whenever the
|
||||||
|
preconditions for safely writing into the processor's input ring do not hold.
|
||||||
|
"""
|
||||||
|
# `_pump_events_during_drain` runs the event loop, so a settings edit made while
|
||||||
|
# a long replay is in flight can re-arm the debounce and fire this method inside
|
||||||
|
# itself — two nested bursts sharing one ring and one result count.
|
||||||
|
if getattr(self, "_bscan_replay_active", False):
|
||||||
|
return False
|
||||||
|
if not self._can_reprocess_history():
|
||||||
|
return False
|
||||||
|
|
||||||
|
window = self._bscan_display_window_scans()
|
||||||
|
tail = list(self._pre_history)[-window:]
|
||||||
|
# `_pre_history` and `_result_history` are fed by two different rings, each
|
||||||
|
# dropping independently when the producer outruns the GUI. A low overlap means
|
||||||
|
# the replayed results arrive under ids the result history never held, so they
|
||||||
|
# are appended rather than replacing the columns already on screen.
|
||||||
|
result_keys = {
|
||||||
|
(int(c.collection_id), int(c.monotonic_ns)) for c in self._result_history
|
||||||
|
}
|
||||||
|
overlap = sum(
|
||||||
|
1 for c in tail if (int(c.collection_id), int(c.monotonic_ns)) in result_keys
|
||||||
|
)
|
||||||
|
self._log_debug(
|
||||||
|
f"B-scan replay starting: window={window}, preprocessed history="
|
||||||
|
f"{len(self._pre_history)}, result history={len(self._result_history)}, "
|
||||||
|
f"to re-send={len(tail)}, of those already in result history={overlap}."
|
||||||
|
)
|
||||||
|
if not tail:
|
||||||
|
return False
|
||||||
|
|
||||||
|
writer = self._ensure_replay_ring_writer()
|
||||||
|
if writer is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# The processor replays its own retained sweeps on every live-config revision
|
||||||
|
# bump. We are about to send the same collections (and more), so suppress it
|
||||||
|
# rather than let it publish the newest 50 twice.
|
||||||
|
self._write_live_processing_config(reprocess_current_result=False)
|
||||||
|
|
||||||
|
# Our own event pumping would otherwise let the poll timer fire and consume the
|
||||||
|
# replayed results through `_read_all_results`, which records them to disk and
|
||||||
|
# feeds the pipeline metrics. Restart in `finally`: losing the ring poll on an
|
||||||
|
# exception would leave the GUI permanently blind.
|
||||||
|
self._timer.stop()
|
||||||
|
self._bscan_replay_active = True
|
||||||
|
sent = 0
|
||||||
|
received = 0
|
||||||
|
replayed: list = []
|
||||||
|
try:
|
||||||
|
for start in range(0, len(tail), _REPLAY_CHUNK_SIZE):
|
||||||
|
chunk = tail[start : start + _REPLAY_CHUNK_SIZE]
|
||||||
|
pushed = 0
|
||||||
|
for collection in chunk:
|
||||||
|
payload = serialize_trace_collection(collection, PREPROC_MAGIC)
|
||||||
|
if not writer.push(payload):
|
||||||
|
# Only fails when the payload exceeds the slot size, which is a
|
||||||
|
# config problem rather than a transient one: stop the burst.
|
||||||
|
self._log_warning(
|
||||||
|
"B-scan replay stopped: a preprocessed sweep does not fit the ring slot.",
|
||||||
|
details=(
|
||||||
|
f"payload={len(payload)} bytes, "
|
||||||
|
f"slot={writer.slot_size_bytes} bytes"
|
||||||
|
),
|
||||||
|
once_key="bscan_replay_payload_too_large",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
pushed += 1
|
||||||
|
sent += pushed
|
||||||
|
got = self._collect_replayed_results(
|
||||||
|
expected=pushed, timeout_s=_REPLAY_CHUNK_TIMEOUT_S, into=replayed
|
||||||
|
)
|
||||||
|
received += got
|
||||||
|
if got < pushed:
|
||||||
|
# A short chunk means the processor did not answer in time; keep
|
||||||
|
# going, but say which one so a systematic stall is visible.
|
||||||
|
self._log_debug(
|
||||||
|
f"B-scan replay chunk at offset {start}: pushed={pushed}, recovered={got}."
|
||||||
|
)
|
||||||
|
if pushed != len(chunk):
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
self._bscan_replay_active = False
|
||||||
|
self._timer.start()
|
||||||
|
|
||||||
|
# Render straight from what came back rather than from `_result_history`.
|
||||||
|
#
|
||||||
|
# The two histories are fed by different rings that drop independently, so the
|
||||||
|
# sweeps we re-sent only partly overlap the results already on record. The
|
||||||
|
# non-overlapping ones get appended to the deque even though their ids are old,
|
||||||
|
# so its newest `window` entries are a mix of freshly and stale-processed
|
||||||
|
# frames. What came back is by construction the right count and uniformly
|
||||||
|
# processed, so use it directly.
|
||||||
|
self._bscan_replay_results = replayed
|
||||||
|
|
||||||
|
# Replayed collections keep their original ids and timestamps, so the render
|
||||||
|
# signature is unchanged even though the payload values are not. Drop it or the
|
||||||
|
# cache would decide nothing needs rebuilding.
|
||||||
|
self._bscan_render_signature = None
|
||||||
|
self._sync_bscan_history_from_results()
|
||||||
|
self._draw_bscan_heatmap_from_history()
|
||||||
|
|
||||||
|
if received < sent:
|
||||||
|
self._log_warning(
|
||||||
|
f"B-scan replay recovered {received} of {sent} re-sent sweeps; "
|
||||||
|
"some columns may still show their captured settings.",
|
||||||
|
once_key=f"bscan_replay_incomplete_{sent}_{received}",
|
||||||
|
)
|
||||||
|
self._log_debug(f"B-scan replay finished: sent={sent}, recovered={received}.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _can_reprocess_history(self) -> bool:
|
||||||
|
"""Return whether re-feeding the processor's input ring is safe right now."""
|
||||||
|
if self._processing_mode.currentText() != "bscan":
|
||||||
|
return False
|
||||||
|
# `data_preprocessor` owns the preprocessed ring while acquisition runs; writing
|
||||||
|
# into it concurrently would corrupt the sequence counters.
|
||||||
|
if self._supervisor.is_running():
|
||||||
|
return False
|
||||||
|
if not self._supervisor.is_processor_running():
|
||||||
|
return False
|
||||||
|
return self._result_reader is not None
|
||||||
|
|
||||||
|
def _collect_replayed_results(
|
||||||
|
self, *, expected: int, timeout_s: float, into: list | None = None
|
||||||
|
) -> int:
|
||||||
|
"""Pop `expected` replayed results, recording them into runtime history.
|
||||||
|
|
||||||
|
Deliberately bypasses `_read_all_results`: that path also feeds the pipeline
|
||||||
|
metrics and the disk recorder, and a replay is neither new acquisition nor
|
||||||
|
something that should be written to disk a second time.
|
||||||
|
|
||||||
|
`into` also receives them in arrival order, which is what the B-scan renders —
|
||||||
|
see `_reprocess_history_through_processor` for why the runtime history alone is
|
||||||
|
not a usable source afterwards.
|
||||||
|
"""
|
||||||
|
if expected <= 0 or self._result_reader is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
received = 0
|
||||||
|
while received < expected and time.monotonic() < deadline:
|
||||||
|
collection = self._result_reader.pop_result_collection()
|
||||||
|
if collection is None:
|
||||||
|
self._pump_events_during_drain(0.005)
|
||||||
|
continue
|
||||||
|
record_result_history(self._result_history, collection)
|
||||||
|
if into is not None:
|
||||||
|
into.append(collection)
|
||||||
|
received += 1
|
||||||
|
return received
|
||||||
|
|
||||||
|
def _ensure_replay_ring_writer(self) -> ShmRingWriter | None:
|
||||||
|
"""Return a writer attached to the processor's input ring, creating it lazily.
|
||||||
|
|
||||||
|
Geometry comes from `_active_run_config` — the config the C++ side actually
|
||||||
|
started with — never from the editable `_defaults_config`: `ShmRingWriter` owns
|
||||||
|
the rings it opens and *recreates* a segment whose geometry disagrees, which
|
||||||
|
would destroy the ring under a live processor.
|
||||||
|
"""
|
||||||
|
existing = getattr(self, "_replay_ring_writer", None)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
config = getattr(self, "_active_run_config", None)
|
||||||
|
if config is None:
|
||||||
|
# Processor outlived the GUI that started it: its ring geometry is unknown,
|
||||||
|
# and guessing risks recreating the segment underneath it.
|
||||||
|
self._log_warning(
|
||||||
|
"Cannot recompute the full B-scan: this GUI session did not start the "
|
||||||
|
"pipeline, so the processor's ring geometry is unknown.",
|
||||||
|
once_key="bscan_replay_no_active_run_config",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
ring = config.rings.preprocessed
|
||||||
|
try:
|
||||||
|
self._replay_ring_writer = ShmRingWriter(
|
||||||
|
ring.name, int(ring.capacity), int(ring.slot_size_bytes)
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_exception("Failed to open the B-scan replay ring writer", exc, level="WARN")
|
||||||
|
self._replay_ring_writer = None
|
||||||
|
return self._replay_ring_writer
|
||||||
|
|
||||||
|
def _discard_bscan_replay_results(self) -> None:
|
||||||
|
"""Fall back to the runtime history as the render source.
|
||||||
|
|
||||||
|
Called whenever the replayed set stops describing what should be on screen:
|
||||||
|
acquisition resuming (live frames must win) or the history being edited.
|
||||||
|
"""
|
||||||
|
if not getattr(self, "_bscan_replay_results", None):
|
||||||
|
return
|
||||||
|
self._bscan_replay_results = []
|
||||||
|
self._bscan_render_signature = None
|
||||||
|
|
||||||
|
def _close_replay_ring_writer(self) -> None:
|
||||||
|
"""Detach from the processor's input ring (safe to call repeatedly)."""
|
||||||
|
writer = getattr(self, "_replay_ring_writer", None)
|
||||||
|
if writer is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log_exception("Failed to close the B-scan replay ring writer", exc, level="WARN")
|
||||||
|
finally:
|
||||||
|
self._replay_ring_writer = None
|
||||||
|
|
||||||
|
def _schedule_bscan_history_reprocess(self) -> None:
|
||||||
|
"""Debounce a full recompute so dragging a spin box does not send hundreds of sweeps."""
|
||||||
|
if not self._can_reprocess_history():
|
||||||
|
return
|
||||||
|
self._bscan_reprocess_timer.start(_BSCAN_REPROCESS_DEBOUNCE_MS)
|
||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from python_app.gui.controllers.app_window_plot import (
|
from python_app.gui.controllers.app_window_plot import (
|
||||||
AppWindowBscanPlotMixin,
|
AppWindowBscanPlotMixin,
|
||||||
|
AppWindowBscanReplayMixin,
|
||||||
AppWindowGprPlotMixin,
|
AppWindowGprPlotMixin,
|
||||||
AppWindowTracePlotMixin,
|
AppWindowTracePlotMixin,
|
||||||
)
|
)
|
||||||
@@ -13,6 +14,7 @@ from python_app.models.dataset_model import ResultCollection
|
|||||||
class AppWindowPlotMixin(
|
class AppWindowPlotMixin(
|
||||||
AppWindowTracePlotMixin,
|
AppWindowTracePlotMixin,
|
||||||
AppWindowBscanPlotMixin,
|
AppWindowBscanPlotMixin,
|
||||||
|
AppWindowBscanReplayMixin,
|
||||||
AppWindowGprPlotMixin,
|
AppWindowGprPlotMixin,
|
||||||
):
|
):
|
||||||
"""Routes plotting to trace, B-scan, or GPR-specific mixins."""
|
"""Routes plotting to trace, B-scan, or GPR-specific mixins."""
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ from python_app.orchestration.preprocess_assets import (
|
|||||||
preprocess_asset_channel,
|
preprocess_asset_channel,
|
||||||
preprocess_asset_display_name,
|
preprocess_asset_display_name,
|
||||||
)
|
)
|
||||||
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
|
from python_app.workflows.kamil_adc_neutral_preprocess import (
|
||||||
|
build_neutral_s21_sets,
|
||||||
|
supports_neutral_preprocess_sets,
|
||||||
|
)
|
||||||
from python_app.workflows.multi_radar_capture_workflow import (
|
from python_app.workflows.multi_radar_capture_workflow import (
|
||||||
MultiRadarCaptureBatch,
|
MultiRadarCaptureBatch,
|
||||||
MultiRadarSequentialCaptureSession,
|
MultiRadarSequentialCaptureSession,
|
||||||
@@ -216,8 +219,8 @@ class AppWindowPreprocessMixin:
|
|||||||
dialog.undo_last_requested.connect(self._undo_last_capture)
|
dialog.undo_last_requested.connect(self._undo_last_capture)
|
||||||
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
|
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
|
||||||
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
|
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
|
||||||
dialog.create_kamil_adc_neutral_sets_requested.connect(self._create_kamil_adc_neutral_sets)
|
dialog.create_neutral_sets_requested.connect(self._create_neutral_sets)
|
||||||
dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc)
|
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
|
||||||
dialog.set_radar_config_summary(
|
dialog.set_radar_config_summary(
|
||||||
directory_path=self._preprocess_radar_scan_summary.directory_path,
|
directory_path=self._preprocess_radar_scan_summary.directory_path,
|
||||||
json_file_count=self._preprocess_radar_scan_summary.json_file_count,
|
json_file_count=self._preprocess_radar_scan_summary.json_file_count,
|
||||||
@@ -292,7 +295,7 @@ class AppWindowPreprocessMixin:
|
|||||||
f"{preprocess_asset_display_name(key)}={len(names)}"
|
f"{preprocess_asset_display_name(key)}={len(names)}"
|
||||||
for key, names in available_sets.items()
|
for key, names in available_sets.items()
|
||||||
)
|
)
|
||||||
dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc)
|
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
|
||||||
self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}")
|
self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}")
|
||||||
if unavailable_selections:
|
if unavailable_selections:
|
||||||
self._log_warning(
|
self._log_warning(
|
||||||
@@ -391,8 +394,8 @@ class AppWindowPreprocessMixin:
|
|||||||
self._show_exception(f"Failed to start {kind} sequence", exc)
|
self._show_exception(f"Failed to start {kind} sequence", exc)
|
||||||
self._resume_pipeline_if_needed()
|
self._resume_pipeline_if_needed()
|
||||||
|
|
||||||
def _create_kamil_adc_neutral_sets(self) -> None:
|
def _create_neutral_sets(self) -> None:
|
||||||
"""Save neutral S21 calibration/reference sets for the current Kamil ADC settings."""
|
"""Save neutral S21 calibration/reference sets for the current radar settings."""
|
||||||
if self._capture_session is not None:
|
if self._capture_session is not None:
|
||||||
self._show_error(
|
self._show_error(
|
||||||
"Cannot create neutral sets during active capture sequence",
|
"Cannot create neutral sets during active capture sequence",
|
||||||
@@ -409,8 +412,10 @@ class AppWindowPreprocessMixin:
|
|||||||
pipeline_was_paused = False
|
pipeline_was_paused = False
|
||||||
try:
|
try:
|
||||||
config = self._build_config()
|
config = self._build_config()
|
||||||
if not config.is_kamil_adc:
|
if not supports_neutral_preprocess_sets(config):
|
||||||
self._show_error("Neutral S21 sets are available only for kamil_adc")
|
self._show_error(
|
||||||
|
"Neutral S21 sets are available only for kamil_adc and librevna_multi"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
radar_key = self._radar_key(config)
|
radar_key = self._radar_key(config)
|
||||||
@@ -425,12 +430,12 @@ class AppWindowPreprocessMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self._supervisor.is_running():
|
if self._supervisor.is_running():
|
||||||
self._log("Pipeline paused for Kamil ADC neutral-set creation")
|
self._log("Pipeline paused for neutral-set creation")
|
||||||
self._stop_run()
|
self._stop_run()
|
||||||
pipeline_was_paused = True
|
pipeline_was_paused = True
|
||||||
|
|
||||||
calibration, reference = build_kamil_adc_neutral_s21_sets(config)
|
calibration, reference = build_neutral_s21_sets(config)
|
||||||
point_count = config.radar.kamil_adc.band.points
|
point_count = int(calibration.traces[0].frequency_hz.size)
|
||||||
self._store.save_set("s21_calibration", radar_key, set_name, calibration)
|
self._store.save_set("s21_calibration", radar_key, set_name, calibration)
|
||||||
self._store.save_set("s21_reference", radar_key, set_name, reference)
|
self._store.save_set("s21_reference", radar_key, set_name, reference)
|
||||||
|
|
||||||
@@ -445,11 +450,11 @@ class AppWindowPreprocessMixin:
|
|||||||
f"Neutral S21 sets saved: {set_name} ({len(calibration.traces)} combos, {point_count} points)"
|
f"Neutral S21 sets saved: {set_name} ({len(calibration.traces)} combos, {point_count} points)"
|
||||||
)
|
)
|
||||||
self._log(
|
self._log(
|
||||||
"Kamil ADC neutral S21 sets saved: "
|
"Neutral S21 sets saved: "
|
||||||
f"set={set_name}, radar_key={radar_key}, combos={len(calibration.traces)}, points={point_count}"
|
f"set={set_name}, radar_key={radar_key}, combos={len(calibration.traces)}, points={point_count}"
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self._show_exception("Failed to create Kamil ADC neutral sets", exc)
|
self._show_exception("Failed to create neutral S21 sets", exc)
|
||||||
finally:
|
finally:
|
||||||
if pipeline_was_paused:
|
if pipeline_was_paused:
|
||||||
self._start_run()
|
self._start_run()
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class AppWindowSnapshotMixin:
|
|||||||
retained_pre=retained_pre,
|
retained_pre=retained_pre,
|
||||||
retained_result=retained_result,
|
retained_result=retained_result,
|
||||||
)
|
)
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._discard_bscan_replay_results()
|
||||||
self._clear_history_mode_caches()
|
self._clear_history_mode_caches()
|
||||||
|
|
||||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -579,6 +590,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)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class PreprocessDialog(QDialog):
|
|||||||
undo_last_requested = pyqtSignal()
|
undo_last_requested = pyqtSignal()
|
||||||
finalize_sequence_requested = pyqtSignal()
|
finalize_sequence_requested = pyqtSignal()
|
||||||
abort_sequence_requested = pyqtSignal()
|
abort_sequence_requested = pyqtSignal()
|
||||||
create_kamil_adc_neutral_sets_requested = pyqtSignal()
|
create_neutral_sets_requested = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
"""Initialize window metadata and compose dialog UI."""
|
"""Initialize window metadata and compose dialog UI."""
|
||||||
@@ -92,17 +92,18 @@ class PreprocessDialog(QDialog):
|
|||||||
self._set_name_input = QLineEdit("set_001", group)
|
self._set_name_input = QLineEdit("set_001", group)
|
||||||
refresh_button = QPushButton("Refresh Sets", group)
|
refresh_button = QPushButton("Refresh Sets", group)
|
||||||
refresh_button.clicked.connect(self.refresh_requested.emit)
|
refresh_button.clicked.connect(self.refresh_requested.emit)
|
||||||
self._kamil_adc_neutral_sets_button = QPushButton("Create Neutral S21 Sets", group)
|
self._neutral_sets_button = QPushButton("Create Neutral S21 Sets", group)
|
||||||
self._kamil_adc_neutral_sets_button.setToolTip(
|
self._neutral_sets_button.setToolTip(
|
||||||
"Save S21 calibration=1 and S21 reference=0 for the current Kamil ADC settings."
|
"Save S21 calibration=1 and S21 reference=0 for the current radar settings, "
|
||||||
|
"so the pipeline can run before any real calibration exists."
|
||||||
)
|
)
|
||||||
self._kamil_adc_neutral_sets_button.clicked.connect(
|
self._neutral_sets_button.clicked.connect(
|
||||||
self.create_kamil_adc_neutral_sets_requested.emit
|
self.create_neutral_sets_requested.emit
|
||||||
)
|
)
|
||||||
self._kamil_adc_neutral_sets_button.setVisible(False)
|
self._neutral_sets_button.setVisible(False)
|
||||||
header_row.addWidget(QLabel("Set name"))
|
header_row.addWidget(QLabel("Set name"))
|
||||||
header_row.addWidget(self._set_name_input, stretch=1)
|
header_row.addWidget(self._set_name_input, stretch=1)
|
||||||
header_row.addWidget(self._kamil_adc_neutral_sets_button)
|
header_row.addWidget(self._neutral_sets_button)
|
||||||
header_row.addWidget(refresh_button)
|
header_row.addWidget(refresh_button)
|
||||||
layout.addLayout(header_row)
|
layout.addLayout(header_row)
|
||||||
layout.addLayout(self._build_median_sweep_row(group))
|
layout.addLayout(self._build_median_sweep_row(group))
|
||||||
@@ -413,10 +414,10 @@ class PreprocessDialog(QDialog):
|
|||||||
"""Set short human-readable status line."""
|
"""Set short human-readable status line."""
|
||||||
self._status_label.setText(message)
|
self._status_label.setText(message)
|
||||||
|
|
||||||
def set_kamil_adc_neutral_sets_visible(self, visible: bool) -> None:
|
def set_neutral_sets_visible(self, visible: bool) -> None:
|
||||||
"""Show Kamil ADC neutral-set shortcut only in the matching radar mode."""
|
"""Show the neutral-set shortcut only for radar models that support it."""
|
||||||
self._kamil_adc_neutral_sets_button.setVisible(bool(visible))
|
self._neutral_sets_button.setVisible(bool(visible))
|
||||||
self._kamil_adc_neutral_sets_button.setEnabled(bool(visible))
|
self._neutral_sets_button.setEnabled(bool(visible))
|
||||||
|
|
||||||
def reset_preview(self) -> None:
|
def reset_preview(self) -> None:
|
||||||
"""Clear preview surfaces and restore default empty-state text when possible."""
|
"""Clear preview surfaces and restore default empty-state text when possible."""
|
||||||
|
|||||||
@@ -131,7 +131,14 @@ class MultiDeviceVnaController:
|
|||||||
if not self._reference_configuration_applied:
|
if not self._reference_configuration_applied:
|
||||||
self._configure_reference_clocks()
|
self._configure_reference_clocks()
|
||||||
|
|
||||||
self._drain_all_received_packets()
|
drain_started_seconds = time.monotonic()
|
||||||
|
drained_packet_count = self._drain_all_received_packets()
|
||||||
|
logger.debug(
|
||||||
|
"timing: drain discarded %d stale packet(s) in %.2f ms (t=%.1f ms)",
|
||||||
|
drained_packet_count,
|
||||||
|
(time.monotonic() - drain_started_seconds) * 1e3,
|
||||||
|
time.monotonic() * 1e3,
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self._sweep_is_running
|
self._sweep_is_running
|
||||||
@@ -334,12 +341,16 @@ class MultiDeviceVnaController:
|
|||||||
self._sweep_is_running = True
|
self._sweep_is_running = True
|
||||||
logger.debug("Sweep settings applied to all devices; sweep running")
|
logger.debug("Sweep settings applied to all devices; sweep running")
|
||||||
|
|
||||||
def _drain_all_received_packets(self) -> None:
|
def _drain_all_received_packets(self) -> int:
|
||||||
"""Empty every device's received-packet queue, in parallel for 2+ devices.
|
"""Empty every device's received-packet queue, in parallel for 2+ devices.
|
||||||
|
|
||||||
Concurrent draining keeps cross-device timing skew small so a hardware
|
Concurrent draining keeps cross-device timing skew small so a hardware
|
||||||
cycle wrap cannot slip between per-device drains and desynchronize the
|
cycle wrap cannot slip between per-device drains and desynchronize the
|
||||||
cycle counters.
|
cycle counters.
|
||||||
|
|
||||||
|
Returns the total number of discarded packets, which the caller logs: a large
|
||||||
|
count means the host was far behind the free-running stream, a near-zero count
|
||||||
|
means the drain landed right after a sweep boundary.
|
||||||
"""
|
"""
|
||||||
# Drain every device queue in parallel rather than one after another:
|
# Drain every device queue in parallel rather than one after another:
|
||||||
# serial drain leaves up to a few hundred microseconds of skew between
|
# serial drain leaves up to a few hundred microseconds of skew between
|
||||||
@@ -349,22 +360,31 @@ class MultiDeviceVnaController:
|
|||||||
# so concurrent get_nowait calls do not contend. A single device case
|
# so concurrent get_nowait calls do not contend. A single device case
|
||||||
# just runs inline to avoid the thread-spawn overhead.
|
# just runs inline to avoid the thread-spawn overhead.
|
||||||
if len(self._all_devices) < 2:
|
if len(self._all_devices) < 2:
|
||||||
for device_connection in self._all_devices:
|
return sum(
|
||||||
device_connection.drain_received_packets()
|
len(device_connection.drain_received_packets())
|
||||||
return
|
for device_connection in self._all_devices
|
||||||
|
)
|
||||||
|
|
||||||
|
drained_counts = [0] * len(self._all_devices)
|
||||||
|
|
||||||
|
def drain_one_device(device_index: int, device_connection: LibreVnaUsbBulkConnection) -> None:
|
||||||
|
"""Drain one device's queue and record how many packets it held."""
|
||||||
|
drained_counts[device_index] = len(device_connection.drain_received_packets())
|
||||||
|
|
||||||
drain_threads = [
|
drain_threads = [
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=device_connection.drain_received_packets,
|
target=drain_one_device,
|
||||||
|
args=(device_index, device_connection),
|
||||||
name=f"drain-{device_connection.serial_number}",
|
name=f"drain-{device_connection.serial_number}",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
for device_connection in self._all_devices
|
for device_index, device_connection in enumerate(self._all_devices)
|
||||||
]
|
]
|
||||||
for drain_thread in drain_threads:
|
for drain_thread in drain_threads:
|
||||||
drain_thread.start()
|
drain_thread.start()
|
||||||
for drain_thread in drain_threads:
|
for drain_thread in drain_threads:
|
||||||
drain_thread.join()
|
drain_thread.join()
|
||||||
|
return sum(drained_counts)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
|
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ def collect_complete_running_sweep_cycles(
|
|||||||
def build_cycle_tracking_handler(
|
def build_cycle_tracking_handler(
|
||||||
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
|
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
|
||||||
device_state: _DeviceCollectionState,
|
device_state: _DeviceCollectionState,
|
||||||
|
device_label: str = "device",
|
||||||
) -> Callable[[ParsedVnaDatapoint], bool]:
|
) -> Callable[[ParsedVnaDatapoint], bool]:
|
||||||
"""Wrap a cycle-aware handler with cross-device cycle tracking.
|
"""Wrap a cycle-aware handler with cross-device cycle tracking.
|
||||||
|
|
||||||
@@ -462,6 +463,11 @@ def collect_complete_running_sweep_cycles(
|
|||||||
cycle_tracking_state = {
|
cycle_tracking_state = {
|
||||||
"current_cycle_index": 0,
|
"current_cycle_index": 0,
|
||||||
"synchronized": False,
|
"synchronized": False,
|
||||||
|
# How many mid-sweep points were thrown away before the anchor was found.
|
||||||
|
# Near zero means the drain landed on a sweep boundary — the case where a
|
||||||
|
# stale point 0 could still have been in flight; a large count means the
|
||||||
|
# remainder of the in-progress sweep was safely skipped.
|
||||||
|
"pre_anchor_skipped": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
|
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
|
||||||
@@ -475,6 +481,7 @@ def collect_complete_running_sweep_cycles(
|
|||||||
|
|
||||||
if not cycle_tracking_state["synchronized"]:
|
if not cycle_tracking_state["synchronized"]:
|
||||||
if current_point_index != 0:
|
if current_point_index != 0:
|
||||||
|
cycle_tracking_state["pre_anchor_skipped"] += 1
|
||||||
return False
|
return False
|
||||||
# Candidate cycle 0. Commit it only once every device confirms it
|
# Candidate cycle 0. Commit it only once every device confirms it
|
||||||
# observed point 0 of the SAME physical sweep; otherwise reject the
|
# observed point 0 of the SAME physical sweep; otherwise reject the
|
||||||
@@ -484,6 +491,14 @@ def collect_complete_running_sweep_cycles(
|
|||||||
report_cycle_misalignment()
|
report_cycle_misalignment()
|
||||||
return False
|
return False
|
||||||
cycle_tracking_state["synchronized"] = True
|
cycle_tracking_state["synchronized"] = True
|
||||||
|
logger.debug(
|
||||||
|
"timing: %s anchored cycle 0 after skipping %d mid-sweep point(s) of %d "
|
||||||
|
"(t=%.1f ms)",
|
||||||
|
device_label,
|
||||||
|
cycle_tracking_state["pre_anchor_skipped"],
|
||||||
|
point_count,
|
||||||
|
time.monotonic() * 1e3,
|
||||||
|
)
|
||||||
cycle_aware_handler(parsed_datapoint, 0)
|
cycle_aware_handler(parsed_datapoint, 0)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -493,6 +508,12 @@ def collect_complete_running_sweep_cycles(
|
|||||||
# spurious wrap and desynchronize the cycle counter.
|
# spurious wrap and desynchronize the cycle counter.
|
||||||
if current_point_index == 0:
|
if current_point_index == 0:
|
||||||
cycle_tracking_state["current_cycle_index"] += 1
|
cycle_tracking_state["current_cycle_index"] += 1
|
||||||
|
logger.debug(
|
||||||
|
"timing: %s first point of NEXT sweep arrived (cycle -> %d, t=%.1f ms)",
|
||||||
|
device_label,
|
||||||
|
cycle_tracking_state["current_cycle_index"],
|
||||||
|
time.monotonic() * 1e3,
|
||||||
|
)
|
||||||
current_cycle_index = cycle_tracking_state["current_cycle_index"]
|
current_cycle_index = cycle_tracking_state["current_cycle_index"]
|
||||||
if current_cycle_index >= cycle_count:
|
if current_cycle_index >= cycle_count:
|
||||||
# The sweep just wrapped past the final requested cycle, closing its
|
# The sweep just wrapped past the final requested cycle, closing its
|
||||||
@@ -505,6 +526,14 @@ def collect_complete_running_sweep_cycles(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
cycle_aware_handler(parsed_datapoint, current_cycle_index)
|
cycle_aware_handler(parsed_datapoint, current_cycle_index)
|
||||||
|
if current_point_index == point_count - 1:
|
||||||
|
logger.debug(
|
||||||
|
"timing: %s last point of cycle %d arrived (index=%d, t=%.1f ms)",
|
||||||
|
device_label,
|
||||||
|
current_cycle_index,
|
||||||
|
current_point_index,
|
||||||
|
time.monotonic() * 1e3,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return handle_datapoint
|
return handle_datapoint
|
||||||
@@ -587,7 +616,9 @@ def collect_complete_running_sweep_cycles(
|
|||||||
point_index,
|
point_index,
|
||||||
] = port_receiver_value
|
] = port_receiver_value
|
||||||
|
|
||||||
return build_cycle_tracking_handler(handle_slave_datapoint, device_state)
|
return build_cycle_tracking_handler(
|
||||||
|
handle_slave_datapoint, device_state, device_label=f"slave{slave_index}"
|
||||||
|
)
|
||||||
|
|
||||||
master_device_state = _DeviceCollectionState()
|
master_device_state = _DeviceCollectionState()
|
||||||
collection_threads = [
|
collection_threads = [
|
||||||
@@ -595,7 +626,9 @@ def collect_complete_running_sweep_cycles(
|
|||||||
target=collect_datapoints_from_device,
|
target=collect_datapoints_from_device,
|
||||||
args=(
|
args=(
|
||||||
master_device_connection,
|
master_device_connection,
|
||||||
build_cycle_tracking_handler(handle_master_datapoint, master_device_state),
|
build_cycle_tracking_handler(
|
||||||
|
handle_master_datapoint, master_device_state, device_label="master"
|
||||||
|
),
|
||||||
master_device_state,
|
master_device_state,
|
||||||
),
|
),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
|
|||||||
@@ -46,13 +46,31 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
|
|||||||
if model == RunConfigModel.LIBREVNA_MULTI_MODEL:
|
if model == RunConfigModel.LIBREVNA_MULTI_MODEL:
|
||||||
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||||
|
|
||||||
return MultiDeviceLibreVnaService(
|
inner = MultiDeviceLibreVnaService(
|
||||||
master_serial=config.radar.serial,
|
master_serial=config.radar.serial,
|
||||||
slave_serials=list(config.radar.multi_device.slave_serials),
|
slave_serials=list(config.radar.multi_device.slave_serials),
|
||||||
force_external_reference=config.radar.multi_device.force_external_reference,
|
force_external_reference=config.radar.multi_device.force_external_reference,
|
||||||
recovery_attempts=config.radar.multi_device.recovery_attempts,
|
recovery_attempts=config.radar.multi_device.recovery_attempts,
|
||||||
backend_mode=config.radar.driver_mode,
|
backend_mode=config.radar.driver_mode,
|
||||||
)
|
)
|
||||||
|
out_physical = config.matrix_output_switch_positions
|
||||||
|
in_physical = config.matrix_input_switch_positions
|
||||||
|
if out_physical <= 1 and in_physical <= 1:
|
||||||
|
return inner
|
||||||
|
|
||||||
|
from python_app.hardware_full.switched_matrix_radar_service import (
|
||||||
|
SwitchedMatrixRadarService,
|
||||||
|
build_physical_switch,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SwitchedMatrixRadarService(
|
||||||
|
inner=inner,
|
||||||
|
output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode),
|
||||||
|
input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode),
|
||||||
|
inner_output_positions=RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS,
|
||||||
|
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
|
||||||
|
settling_ms=config.runtime.settling_ms,
|
||||||
|
)
|
||||||
|
|
||||||
if model == RunConfigModel.SN9000_MODEL:
|
if model == RunConfigModel.SN9000_MODEL:
|
||||||
if config.radar.driver_mode != "native":
|
if config.radar.driver_mode != "native":
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ class SwitchService:
|
|||||||
"""Switch to requested position."""
|
"""Switch to requested position."""
|
||||||
self._driver.switch_to(position)
|
self._driver.switch_to(position)
|
||||||
|
|
||||||
|
def position_count(self) -> int:
|
||||||
|
"""Return number of positions supported by the backend driver."""
|
||||||
|
return self._driver.position_count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_position(self) -> int:
|
def current_position(self) -> int:
|
||||||
"""Return current switch position reported by backend driver."""
|
"""Return current switch position reported by backend driver."""
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Matrix radar behind real GPIO switches on the stimulus and/or receiver path."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from python_app.hardware_full.matrix_radar_service import MatrixRadarService
|
||||||
|
from python_app.hardware_full.switch_service import SwitchService
|
||||||
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
|
from python_app.models.run_config_model import RadarSweepModel, SwitchModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SwitchedMatrixRadarService:
|
||||||
|
"""Widen a matrix radar's combo matrix with real switch positions.
|
||||||
|
|
||||||
|
Implements the ``MatrixRadarService`` protocol, so the producer and the
|
||||||
|
capture workflows treat it as an ordinary matrix radar that simply reports
|
||||||
|
more positions. The hardware sweep is never stopped: switches are only ever
|
||||||
|
driven BETWEEN ``acquire_collection`` calls, and the inner service's
|
||||||
|
free-running collection discards any partially swept cycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
inner: MatrixRadarService
|
||||||
|
output_switch: SwitchService | None
|
||||||
|
input_switch: SwitchService | None
|
||||||
|
inner_output_positions: int
|
||||||
|
inner_input_positions: int
|
||||||
|
settling_ms: int = 0
|
||||||
|
# Monotonic end of the previous inner collection, so the DEBUG timing trace can
|
||||||
|
# report how long the gap between "sweep collected" and "switch driven" really is
|
||||||
|
# — that gap is where a stale in-flight point 0 can still slip past the drain.
|
||||||
|
_last_inner_end_ns: int = field(init=False, default=0, repr=False)
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Open the inner radar and both switches."""
|
||||||
|
self.inner.open()
|
||||||
|
if self.output_switch is not None:
|
||||||
|
self.output_switch.open()
|
||||||
|
if self.input_switch is not None:
|
||||||
|
self.input_switch.open()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close switches first, then the inner radar; never raises."""
|
||||||
|
for switch in (self.input_switch, self.output_switch):
|
||||||
|
if switch is not None:
|
||||||
|
try:
|
||||||
|
switch.close()
|
||||||
|
except Exception as exc: # noqa: BLE001 — shutdown path
|
||||||
|
logger.warning("Switch close ignored error: %s", exc)
|
||||||
|
self.inner.close()
|
||||||
|
|
||||||
|
def configure(self, sweep: RadarSweepModel) -> None:
|
||||||
|
"""Apply sweep settings to the inner radar."""
|
||||||
|
self.inner.configure(sweep)
|
||||||
|
|
||||||
|
def recover(self) -> None:
|
||||||
|
"""Reconnect the inner radar; switches are not on the USB transport."""
|
||||||
|
self.inner.recover()
|
||||||
|
|
||||||
|
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
|
||||||
|
"""Acquire the full widened matrix, one inner collection per switch step.
|
||||||
|
|
||||||
|
A partial failure raises instead of returning a short collection: the
|
||||||
|
preprocessor requires every runtime combo to be present, so half a matrix
|
||||||
|
is worse than a dropped frame.
|
||||||
|
"""
|
||||||
|
capture_start_ns = time.monotonic_ns()
|
||||||
|
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
|
||||||
|
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
|
||||||
|
total_inputs = in_steps * self.inner_input_positions
|
||||||
|
total_outputs = out_steps * self.inner_output_positions
|
||||||
|
|
||||||
|
# Place each trace at its canonical index rather than appending. The GPR stage
|
||||||
|
# rejects a collection whose trace order differs from run.combos, and run.combos
|
||||||
|
# is built output-major (`build_full_combos`) while these loops run switch-major.
|
||||||
|
# Appending happens to agree for an output switch and to disagree for an input one.
|
||||||
|
slots: list[TraceData | None] = [None] * (total_inputs * total_outputs)
|
||||||
|
|
||||||
|
for out_k in range(out_steps):
|
||||||
|
for in_k in range(in_steps):
|
||||||
|
step_start_ns = time.monotonic_ns()
|
||||||
|
if self.output_switch is not None:
|
||||||
|
self.output_switch.switch_to(out_k)
|
||||||
|
if self.input_switch is not None:
|
||||||
|
self.input_switch.switch_to(in_k)
|
||||||
|
switched_ns = time.monotonic_ns()
|
||||||
|
# Settle AFTER the last switch change and BEFORE collecting, so the
|
||||||
|
# cycle we anchor on starts with the RF path already stable.
|
||||||
|
if self.settling_ms > 0:
|
||||||
|
time.sleep(self.settling_ms / 1000.0)
|
||||||
|
settled_ns = time.monotonic_ns()
|
||||||
|
|
||||||
|
sub = self.inner.acquire_collection(collection_id)
|
||||||
|
inner_end_ns = time.monotonic_ns()
|
||||||
|
logger.debug(
|
||||||
|
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
|
||||||
|
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
|
||||||
|
collection_id,
|
||||||
|
out_k,
|
||||||
|
in_k,
|
||||||
|
(
|
||||||
|
f"{(step_start_ns - self._last_inner_end_ns) / 1e6:.2f}"
|
||||||
|
if self._last_inner_end_ns
|
||||||
|
else "n/a"
|
||||||
|
),
|
||||||
|
(switched_ns - step_start_ns) / 1e6,
|
||||||
|
(settled_ns - switched_ns) / 1e6,
|
||||||
|
(inner_end_ns - settled_ns) / 1e6,
|
||||||
|
)
|
||||||
|
self._last_inner_end_ns = inner_end_ns
|
||||||
|
for trace in sub.traces:
|
||||||
|
input_pos = in_k * self.inner_input_positions + int(trace.combo.input)
|
||||||
|
output_pos = out_k * self.inner_output_positions + int(trace.combo.output)
|
||||||
|
slots[output_pos * total_inputs + input_pos] = replace(
|
||||||
|
trace, combo=ComboKey(input=input_pos, output=output_pos)
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(trace is None for trace in slots):
|
||||||
|
missing = sum(1 for trace in slots if trace is None)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SweepCollection(
|
||||||
|
collection_id=int(collection_id),
|
||||||
|
monotonic_ns=time.monotonic_ns(),
|
||||||
|
traces=[trace for trace in slots if trace is not None],
|
||||||
|
capture_start_ns=capture_start_ns,
|
||||||
|
capture_end_ns=time.monotonic_ns(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_physical_switch(
|
||||||
|
model: SwitchModel,
|
||||||
|
physical_positions: int,
|
||||||
|
radar_driver_mode: str,
|
||||||
|
) -> SwitchService | None:
|
||||||
|
"""Build the driver for a real switch described by a virtual switch section.
|
||||||
|
|
||||||
|
The config section carries the LOGICAL axis size and a forced "mock" mode so
|
||||||
|
the C++ loader accepts it; the real driver needs the PHYSICAL position count
|
||||||
|
and native mode. Mock radar runs keep mock switches so the whole path can be
|
||||||
|
exercised without GPIO.
|
||||||
|
"""
|
||||||
|
if physical_positions <= 1:
|
||||||
|
return None
|
||||||
|
driver_mode = "mock" if radar_driver_mode.strip().lower() == "mock" else "native"
|
||||||
|
return SwitchService.from_model(
|
||||||
|
replace(model, positions=physical_positions, driver_mode=driver_mode)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -219,6 +219,16 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
"recovery_attempts",
|
"recovery_attempts",
|
||||||
model.radar.multi_device.recovery_attempts,
|
model.radar.multi_device.recovery_attempts,
|
||||||
)
|
)
|
||||||
|
model.radar.multi_device.output_switch_positions = _read_int(
|
||||||
|
multi_device_payload,
|
||||||
|
"output_switch_positions",
|
||||||
|
model.radar.multi_device.output_switch_positions,
|
||||||
|
)
|
||||||
|
model.radar.multi_device.input_switch_positions = _read_int(
|
||||||
|
multi_device_payload,
|
||||||
|
"input_switch_positions",
|
||||||
|
model.radar.multi_device.input_switch_positions,
|
||||||
|
)
|
||||||
model.radar.kamil_adc.project_dir = _read_str(
|
model.radar.kamil_adc.project_dir = _read_str(
|
||||||
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
|
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
|
||||||
)
|
)
|
||||||
@@ -498,6 +508,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
|||||||
"slave_serials": list(model.radar.multi_device.slave_serials),
|
"slave_serials": list(model.radar.multi_device.slave_serials),
|
||||||
"force_external_reference": model.radar.multi_device.force_external_reference,
|
"force_external_reference": model.radar.multi_device.force_external_reference,
|
||||||
"recovery_attempts": model.radar.multi_device.recovery_attempts,
|
"recovery_attempts": model.radar.multi_device.recovery_attempts,
|
||||||
|
"output_switch_positions": model.radar.multi_device.output_switch_positions,
|
||||||
|
"input_switch_positions": model.radar.multi_device.input_switch_positions
|
||||||
},
|
},
|
||||||
"kamil_adc": {
|
"kamil_adc": {
|
||||||
"project_dir": model.radar.kamil_adc.project_dir,
|
"project_dir": model.radar.kamil_adc.project_dir,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class RadarMultiDeviceModel:
|
|||||||
slave_serials: list[str] = field(default_factory=list)
|
slave_serials: list[str] = field(default_factory=list)
|
||||||
force_external_reference: bool = True
|
force_external_reference: bool = True
|
||||||
recovery_attempts: int = 3
|
recovery_attempts: int = 3
|
||||||
|
output_switch_positions: int = 1 # 1 = свитча нет
|
||||||
|
input_switch_positions: int = 1 # 1 = свитча нет
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -390,6 +392,24 @@ class RunConfigModel:
|
|||||||
"""Return whether this config acquires the full virtual switch matrix per sweep."""
|
"""Return whether this config acquires the full virtual switch matrix per sweep."""
|
||||||
return self.is_multi_device or self.is_sn9000
|
return self.is_multi_device or self.is_sn9000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def matrix_output_switch_positions(self) -> int:
|
||||||
|
"""Physical positions of the real switch on the master stimulus path."""
|
||||||
|
if not self.is_multi_device:
|
||||||
|
return 1
|
||||||
|
return max(1, int(self.radar.multi_device.output_switch_positions))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def matrix_input_switch_positions(self) -> int:
|
||||||
|
"""Physical positions of the real switch on the slave receiver path."""
|
||||||
|
if not self.is_multi_device:
|
||||||
|
return 1
|
||||||
|
return max(1, int(self.radar.multi_device.input_switch_positions))
|
||||||
|
|
||||||
|
def build_runtime_combos(self) -> list[ComboModel]:
|
||||||
|
"""Build the combo matrix from the effective switch axis sizes."""
|
||||||
|
return self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_kamil_adc(self) -> bool:
|
def is_kamil_adc(self) -> bool:
|
||||||
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
||||||
@@ -446,22 +466,25 @@ class RunConfigModel:
|
|||||||
if not self.is_matrix_radar:
|
if not self.is_matrix_radar:
|
||||||
return
|
return
|
||||||
self._apply_matrix_virtual_switches()
|
self._apply_matrix_virtual_switches()
|
||||||
self.combos = self.build_matrix_radar_virtual_combos()
|
self.combos = self.build_runtime_combos()
|
||||||
|
|
||||||
def _apply_matrix_virtual_switches(self) -> None:
|
def _apply_matrix_virtual_switches(self) -> None:
|
||||||
"""Pin the canonical 2x4 virtual switch matrix used by all matrix-mode radars."""
|
"""Pin the virtual switch matrix, widened by any real switch on the path."""
|
||||||
|
out_physical = self.matrix_output_switch_positions
|
||||||
|
in_physical = self.matrix_input_switch_positions
|
||||||
|
|
||||||
self.output_switch.name = self.output_switch.name or "virtual_output"
|
self.output_switch.name = self.output_switch.name or "virtual_output"
|
||||||
self.output_switch.driver_mode = "mock"
|
self.output_switch.driver_mode = "mock"
|
||||||
self.output_switch.driver = self.output_switch.driver or "h7992"
|
self.output_switch.driver = self.output_switch.driver or "h7992"
|
||||||
self.output_switch.radar_port = 1
|
self.output_switch.radar_port = 1
|
||||||
self.output_switch.positions = self.MULTI_DEVICE_OUTPUT_POSITIONS
|
self.output_switch.positions = out_physical * self.MULTI_DEVICE_OUTPUT_POSITIONS
|
||||||
self.output_switch.default_position = 0
|
self.output_switch.default_position = 0
|
||||||
|
|
||||||
self.input_switch.name = self.input_switch.name or "virtual_input"
|
self.input_switch.name = self.input_switch.name or "virtual_input"
|
||||||
self.input_switch.driver_mode = "mock"
|
self.input_switch.driver_mode = "mock"
|
||||||
self.input_switch.driver = self.input_switch.driver or "h7992"
|
self.input_switch.driver = self.input_switch.driver or "h7992"
|
||||||
self.input_switch.radar_port = 2
|
self.input_switch.radar_port = 2
|
||||||
self.input_switch.positions = self.MULTI_DEVICE_INPUT_POSITIONS
|
self.input_switch.positions = in_physical * self.MULTI_DEVICE_INPUT_POSITIONS
|
||||||
self.input_switch.default_position = 0
|
self.input_switch.default_position = 0
|
||||||
|
|
||||||
def ensure_combos(self) -> None:
|
def ensure_combos(self) -> None:
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ def _read_log_tail(path: Path, max_bytes: int = 16384) -> str:
|
|||||||
data = handle.read()
|
data = handle.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return ""
|
return ""
|
||||||
return data.decode("utf-8", errors="replace").strip()
|
# Drop NULs: logs written by an older supervisor can carry a sparse hole from
|
||||||
|
# the pre-O_APPEND truncate bug, and a tail landing in it would otherwise turn
|
||||||
|
# an exit report (or a rolled `.prev`) into megabytes of NUL padding.
|
||||||
|
return data.replace(b"\0", b"").decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
|
||||||
class ProcessSupervisor:
|
class ProcessSupervisor:
|
||||||
@@ -232,8 +235,17 @@ class ProcessSupervisor:
|
|||||||
self._roll_log_to_prev(stdout_path)
|
self._roll_log_to_prev(stdout_path)
|
||||||
self._roll_log_to_prev(stderr_path)
|
self._roll_log_to_prev(stderr_path)
|
||||||
|
|
||||||
stdout_file = open(stdout_path, "wb")
|
# O_APPEND ("ab"), not "wb": the child inherits these fds and keeps its own
|
||||||
stderr_file = open(stderr_path, "wb")
|
# file offset. Without O_APPEND, the in-place truncate in
|
||||||
|
# `_roll_log_if_oversized` leaves that offset far past the new end of file,
|
||||||
|
# so the next write lands there and the kernel fills everything before it
|
||||||
|
# with a hole of NUL bytes — the log becomes unreadable and the size cap
|
||||||
|
# stops working entirely. O_APPEND makes the kernel seek to EOF atomically
|
||||||
|
# on every write, so a truncate genuinely restarts the file at offset 0.
|
||||||
|
# `_roll_log_to_prev` above already renamed any previous log away, so not
|
||||||
|
# truncating on open costs nothing.
|
||||||
|
stdout_file = open(stdout_path, "ab")
|
||||||
|
stderr_file = open(stderr_path, "ab")
|
||||||
try:
|
try:
|
||||||
handle = subprocess.Popen(
|
handle = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
@@ -499,6 +511,11 @@ class ProcessSupervisor:
|
|||||||
The child holds an open fd to this inode, so a rename would not redirect
|
The child holds an open fd to this inode, so a rename would not redirect
|
||||||
its writes. Instead keep one rolled generation via copy-to-`.prev` and
|
its writes. Instead keep one rolled generation via copy-to-`.prev` and
|
||||||
truncate the live inode in place, freeing the allocated disk blocks.
|
truncate the live inode in place, freeing the allocated disk blocks.
|
||||||
|
|
||||||
|
This relies on the child's fd being opened with O_APPEND (see `_spawn`):
|
||||||
|
only then does the child resume writing at offset 0 after the truncate.
|
||||||
|
With a plain write fd it would keep writing at its stale offset, punching
|
||||||
|
a multi-hundred-megabyte NUL hole and defeating the cap.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if path.stat().st_size <= _LOG_MAX_BYTES:
|
if path.stat().st_size <= _LOG_MAX_BYTES:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
|
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
|
||||||
|
from python_app.logging_setup import coerce_level
|
||||||
from python_app.models.run_config_model import RunConfigModel
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
from python_app.orchestration.shm import ShmRingWriter
|
from python_app.orchestration.shm import ShmRingWriter
|
||||||
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
|
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
|
||||||
@@ -95,6 +96,10 @@ def main() -> int:
|
|||||||
|
|
||||||
config = RunConfigModel.load_from_path(args.config)
|
config = RunConfigModel.load_from_path(args.config)
|
||||||
config.apply_device_model_constraints()
|
config.apply_device_model_constraints()
|
||||||
|
# Honor the configured verbosity so the DEBUG switch/sweep timing trace can be
|
||||||
|
# turned on from the profile instead of requiring a code edit. basicConfig above
|
||||||
|
# only installed the handler; the package logger owns the level.
|
||||||
|
logging.getLogger("python_app").setLevel(coerce_level(config.logging.level))
|
||||||
if not config.is_matrix_radar:
|
if not config.is_matrix_radar:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"matrix_raw_producer requires a matrix-mode radar.model "
|
"matrix_raw_producer requires a matrix-mode radar.model "
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""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
|
||||||
|
`collection_id` floor. Widening only the first would have changed nothing, because
|
||||||
|
the floor kept filtering older collections out for good.
|
||||||
|
|
||||||
|
The floor is gone: selection is positional, which is the only criterion that holds
|
||||||
|
when ids are sparse (the results ring drops) or restart from 1 (a new C++ run).
|
||||||
|
These tests pin the observable consequences — how many columns end up on screen —
|
||||||
|
rather than any internal counter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.gui.runtime.history import record_result_history # 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_render_signature = None
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.w._supervisor.is_running = self._original_is_running
|
||||||
|
self.w._result_history.clear()
|
||||||
|
self.w._bscan_render_signature = None
|
||||||
|
|
||||||
|
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_brings_older_frames_back(self) -> None:
|
||||||
|
# The regression this whole change exists for: a live run renders 50 columns,
|
||||||
|
# and widening the window after Stop must reach back over the retained history
|
||||||
|
# rather than stay pinned to whatever the live path last drew.
|
||||||
|
self._fill_history(300)
|
||||||
|
self._set_running(True)
|
||||||
|
self.w._sync_bscan_history_from_results()
|
||||||
|
self.assertEqual(
|
||||||
|
len(self.w._bscan_history_by_combo[(0, 0)]), self.w._bscan_cpp_replay_window
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_restarted_collection_ids_do_not_hide_the_fresh_run(self) -> None:
|
||||||
|
# Regression: Start after a stopped review made the image melt to 0 columns and
|
||||||
|
# then snap back to 50. A new C++ run numbers from 1, so entries that are newest
|
||||||
|
# by position carry the smallest ids; any id-based cut-off derived from the old
|
||||||
|
# run rejected exactly them.
|
||||||
|
self._fill_history(300)
|
||||||
|
self._set_running(False)
|
||||||
|
self.w._bscan_history_window.setValue(300)
|
||||||
|
self.w._sync_bscan_history_from_results()
|
||||||
|
|
||||||
|
self._set_running(True)
|
||||||
|
window = self.w._bscan_cpp_replay_window
|
||||||
|
for fresh in range(1, window + 1):
|
||||||
|
self.w._result_history.append(_bscan_result(fresh))
|
||||||
|
self.w._sync_bscan_history_from_results()
|
||||||
|
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window)
|
||||||
|
|
||||||
|
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. Counting in ids rather than
|
||||||
|
# in entries 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_reprocessed_results_replace_rather_than_duplicate_columns(self) -> None:
|
||||||
|
# The processor's recompute is triggered by feeding sweeps back through it, and
|
||||||
|
# it republishes them under their ORIGINAL ids. Two independent mechanisms keep
|
||||||
|
# that from doubling every column, and neither may be confused with the removed
|
||||||
|
# `collection_id` floor: a threshold cannot tell a duplicate from its original,
|
||||||
|
# since they share the id. Deduplication is by key equality.
|
||||||
|
self._set_running(False)
|
||||||
|
self.w._bscan_history_window.setValue(300)
|
||||||
|
self._fill_history(200)
|
||||||
|
self.w._sync_bscan_history_from_results()
|
||||||
|
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
|
||||||
|
|
||||||
|
# Intake: a recomputed collection replaces the entry holding the same key.
|
||||||
|
for collection in list(self.w._result_history):
|
||||||
|
record_result_history(self.w._result_history, _bscan_result(collection.collection_id))
|
||||||
|
self.assertEqual(len(self.w._result_history), 200)
|
||||||
|
|
||||||
|
# Render: a duplicate that reached the deque by another path is still collapsed,
|
||||||
|
# keeping the newer of the two.
|
||||||
|
self.w._result_history.append(_bscan_result(200))
|
||||||
|
self.w._bscan_render_signature = None
|
||||||
|
self.w._sync_bscan_history_from_results()
|
||||||
|
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 200)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""Re-feeding retained sweeps to the processor to recompute the whole B-scan.
|
||||||
|
|
||||||
|
The processor keeps only ~50 preprocessed sweeps of its own, so a live settings edit
|
||||||
|
used to refresh just the newest 50 columns. The GUI holds up to 1000 of them and hands
|
||||||
|
them back through the processor's input ring, which only works if the bytes the GUI
|
||||||
|
writes are exactly the ones the C++ side expects — that wire-format contract is what
|
||||||
|
the first test pins, without needing the pipeline running.
|
||||||
|
|
||||||
|
The guard tests pin the other half: the GUI must never write into that ring while
|
||||||
|
`data_preprocessor` owns it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
from PyQt6.QtCore import QSignalBlocker # noqa: E402
|
||||||
|
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,
|
||||||
|
SweepCollection,
|
||||||
|
TraceData,
|
||||||
|
)
|
||||||
|
from python_app.orchestration.shm import ShmRingWriter # noqa: E402
|
||||||
|
from python_app.orchestration.shm.ring_reader import ShmRingReader # noqa: E402
|
||||||
|
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection # noqa: E402
|
||||||
|
|
||||||
|
# Mirrors kPreprocessedCollectionMagic in common_cpp/ipc/src/shared_types.cpp. If this
|
||||||
|
# ever drifts, the processor silently rejects everything the GUI re-feeds.
|
||||||
|
_CPP_PREPROCESSED_MAGIC = 0x32525050
|
||||||
|
|
||||||
|
_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 _sweep(collection_id: int, *, combos: int = 3, points: int = 64) -> SweepCollection:
|
||||||
|
traces = [
|
||||||
|
TraceData(
|
||||||
|
combo=ComboKey(input=index, output=index + 1),
|
||||||
|
frequency_hz=np.linspace(1e9, 8e9, points, dtype=np.float32),
|
||||||
|
s11=(np.arange(points) + index).astype(np.complex64) + 0.5j,
|
||||||
|
s21=(np.arange(points) * 2 + index).astype(np.complex64) - 0.25j,
|
||||||
|
)
|
||||||
|
for index in range(combos)
|
||||||
|
]
|
||||||
|
return SweepCollection(
|
||||||
|
collection_id=collection_id,
|
||||||
|
monotonic_ns=collection_id * 1_000_000 + 7,
|
||||||
|
traces=traces,
|
||||||
|
capture_start_ns=collection_id * 10,
|
||||||
|
capture_end_ns=collection_id * 10 + 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bscan_result(collection_id: int, *, points: int = 32) -> ResultCollection:
|
||||||
|
payload = ResultPayload(
|
||||||
|
processing_name="bscan",
|
||||||
|
kind=1,
|
||||||
|
frequency_hz=np.linspace(0.0, 1.0, points, dtype=np.float32),
|
||||||
|
trace=(np.arange(points) + collection_id).astype(np.complex64),
|
||||||
|
)
|
||||||
|
return ResultCollection(
|
||||||
|
collection_id=collection_id,
|
||||||
|
monotonic_ns=collection_id,
|
||||||
|
blocks=[ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PreprocessedWireFormatTest(unittest.TestCase):
|
||||||
|
"""The bytes the GUI re-feeds must be the ones the C++ processor decodes."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.ring_name = f"/radar_test_{self._testMethodName}"
|
||||||
|
with suppress(OSError):
|
||||||
|
(Path("/dev/shm") / self.ring_name[1:]).unlink()
|
||||||
|
self.writer = ShmRingWriter(self.ring_name, 8, 1 << 20)
|
||||||
|
self.reader = ShmRingReader(self.ring_name)
|
||||||
|
self.addCleanup(self._cleanup)
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
with suppress(Exception):
|
||||||
|
self.reader.close()
|
||||||
|
with suppress(Exception):
|
||||||
|
self.writer.close()
|
||||||
|
with suppress(OSError):
|
||||||
|
(Path("/dev/shm") / self.ring_name[1:]).unlink()
|
||||||
|
|
||||||
|
def _assert_same(self, original: SweepCollection, decoded: SweepCollection) -> None:
|
||||||
|
self.assertEqual(decoded.collection_id, original.collection_id)
|
||||||
|
self.assertEqual(decoded.monotonic_ns, original.monotonic_ns)
|
||||||
|
self.assertEqual(decoded.capture_start_ns, original.capture_start_ns)
|
||||||
|
self.assertEqual(decoded.capture_end_ns, original.capture_end_ns)
|
||||||
|
self.assertEqual(len(decoded.traces), len(original.traces))
|
||||||
|
for left, right in zip(original.traces, decoded.traces, strict=True):
|
||||||
|
self.assertEqual((left.combo.input, left.combo.output), (right.combo.input, right.combo.output))
|
||||||
|
np.testing.assert_array_equal(left.frequency_hz, right.frequency_hz)
|
||||||
|
np.testing.assert_array_equal(left.s11, right.s11)
|
||||||
|
np.testing.assert_array_equal(left.s21, right.s21)
|
||||||
|
|
||||||
|
def test_magic_matches_the_cpp_decoder(self) -> None:
|
||||||
|
payload = serialize_trace_collection(_sweep(1), PREPROC_MAGIC)
|
||||||
|
self.assertEqual(int.from_bytes(payload[:4], "little"), _CPP_PREPROCESSED_MAGIC)
|
||||||
|
|
||||||
|
def test_round_trip_preserves_every_field(self) -> None:
|
||||||
|
original = _sweep(42)
|
||||||
|
self.assertTrue(self.writer.push(serialize_trace_collection(original, PREPROC_MAGIC)))
|
||||||
|
|
||||||
|
decoded = self.reader.pop_preprocessed_collection()
|
||||||
|
self.assertIsNotNone(decoded)
|
||||||
|
assert decoded is not None
|
||||||
|
self._assert_same(original, decoded)
|
||||||
|
|
||||||
|
def test_batch_keeps_order(self) -> None:
|
||||||
|
originals = [_sweep(cid) for cid in range(100, 105)]
|
||||||
|
for collection in originals:
|
||||||
|
self.assertTrue(self.writer.push(serialize_trace_collection(collection, PREPROC_MAGIC)))
|
||||||
|
|
||||||
|
decoded = []
|
||||||
|
while (collection := self.reader.pop_preprocessed_collection()) is not None:
|
||||||
|
decoded.append(collection)
|
||||||
|
|
||||||
|
self.assertEqual([c.collection_id for c in decoded], [c.collection_id for c in originals])
|
||||||
|
for left, right in zip(originals, decoded, strict=True):
|
||||||
|
self._assert_same(left, right)
|
||||||
|
|
||||||
|
def test_overflow_drops_oldest(self) -> None:
|
||||||
|
# Why the replay pushes in chunks instead of one burst: an undrained ring
|
||||||
|
# silently overwrites, which would punch holes into the very image we are
|
||||||
|
# trying to make coherent.
|
||||||
|
capacity = 8
|
||||||
|
for cid in range(200, 200 + capacity + 4):
|
||||||
|
self.writer.push(serialize_trace_collection(_sweep(cid), PREPROC_MAGIC))
|
||||||
|
|
||||||
|
survived = []
|
||||||
|
while (collection := self.reader.pop_preprocessed_collection()) is not None:
|
||||||
|
survived.append(collection.collection_id)
|
||||||
|
|
||||||
|
self.assertEqual(len(survived), capacity)
|
||||||
|
self.assertEqual(survived[-1], 200 + capacity + 3)
|
||||||
|
|
||||||
|
|
||||||
|
class _IdleResultReader:
|
||||||
|
"""Stands in for a connected results reader that simply has nothing to hand out."""
|
||||||
|
|
||||||
|
def pop_result_collection(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayGuardTest(unittest.TestCase):
|
||||||
|
"""The GUI must refuse to write into a ring `data_preprocessor` still owns."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.w = _window
|
||||||
|
# The 50 ms ring poll and the debounce would both run against this half-faked
|
||||||
|
# window while the test drives it by hand; park them for the duration.
|
||||||
|
self.w._timer.stop()
|
||||||
|
self.w._bscan_reprocess_timer.stop()
|
||||||
|
self.addCleanup(self.w._timer.start)
|
||||||
|
self.addCleanup(self.w._bscan_reprocess_timer.stop)
|
||||||
|
|
||||||
|
self._original_is_running = self.w._supervisor.is_running
|
||||||
|
self._original_is_processor_running = self.w._supervisor.is_processor_running
|
||||||
|
self._original_mode = self.w._processing_mode.currentText()
|
||||||
|
# Changing the mode fires the live-settings handler, which would kick off the
|
||||||
|
# very replay these tests are inspecting.
|
||||||
|
with QSignalBlocker(self.w._processing_mode):
|
||||||
|
self.w._processing_mode.setCurrentText("bscan")
|
||||||
|
self.w._supervisor.is_running = lambda: False
|
||||||
|
self.w._supervisor.is_processor_running = lambda: True
|
||||||
|
self.w._result_reader = _IdleResultReader()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.w._supervisor.is_running = self._original_is_running
|
||||||
|
self.w._supervisor.is_processor_running = self._original_is_processor_running
|
||||||
|
with QSignalBlocker(self.w._processing_mode):
|
||||||
|
self.w._processing_mode.setCurrentText(self._original_mode)
|
||||||
|
self.w._result_reader = None
|
||||||
|
|
||||||
|
def test_refuses_while_acquisition_runs(self) -> None:
|
||||||
|
self.w._supervisor.is_running = lambda: True
|
||||||
|
self.assertFalse(self.w._can_reprocess_history())
|
||||||
|
|
||||||
|
def test_refuses_when_processor_is_down(self) -> None:
|
||||||
|
self.w._supervisor.is_processor_running = lambda: False
|
||||||
|
self.assertFalse(self.w._can_reprocess_history())
|
||||||
|
|
||||||
|
def test_refuses_outside_bscan_mode(self) -> None:
|
||||||
|
self.w._processing_mode.setCurrentText("pass_through")
|
||||||
|
self.assertFalse(self.w._can_reprocess_history())
|
||||||
|
|
||||||
|
def test_refuses_without_a_results_reader(self) -> None:
|
||||||
|
self.w._result_reader = None
|
||||||
|
self.assertFalse(self.w._can_reprocess_history())
|
||||||
|
|
||||||
|
def test_allows_when_stopped_with_a_live_processor(self) -> None:
|
||||||
|
self.assertTrue(self.w._can_reprocess_history())
|
||||||
|
|
||||||
|
def test_replayed_results_are_rendered_instead_of_runtime_history(self) -> None:
|
||||||
|
"""Regression: 300 re-sent, 300 recovered, only 145 drawn.
|
||||||
|
|
||||||
|
`_pre_history` and `_result_history` come from two rings that drop
|
||||||
|
independently, so the re-sent sweeps only partly overlap the recorded results.
|
||||||
|
The non-overlapping ones get appended to the deque under old ids, leaving its
|
||||||
|
newest `window` entries a mix of freshly and stale-processed frames. Rendering
|
||||||
|
from the replayed set instead sidesteps the whole problem.
|
||||||
|
"""
|
||||||
|
window = 30
|
||||||
|
replayed = [_bscan_result(cid) for cid in range(9000, 9000 + window)]
|
||||||
|
|
||||||
|
self.w._result_history.clear()
|
||||||
|
# Stand-in for a runtime history whose ids barely overlap the replayed ones.
|
||||||
|
self.w._result_history.extend(_bscan_result(cid) for cid in range(1, 200))
|
||||||
|
self.addCleanup(self.w._result_history.clear)
|
||||||
|
|
||||||
|
self.w._bscan_replay_results = replayed
|
||||||
|
self.addCleanup(self.w._discard_bscan_replay_results)
|
||||||
|
self.w._bscan_history_window.setValue(window)
|
||||||
|
self.w._bscan_render_signature = None
|
||||||
|
self.w._sync_bscan_history_from_results()
|
||||||
|
|
||||||
|
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window)
|
||||||
|
|
||||||
|
def test_discarding_replay_falls_back_to_runtime_history(self) -> None:
|
||||||
|
self.w._bscan_replay_results = [_bscan_result(1)]
|
||||||
|
self.w._discard_bscan_replay_results()
|
||||||
|
self.assertEqual(self.w._bscan_replay_results, [])
|
||||||
|
self.assertIsNone(self.w._bscan_render_signature)
|
||||||
|
|
||||||
|
def test_replay_refuses_to_re_enter_itself(self) -> None:
|
||||||
|
# Pumping the event loop mid-replay can fire the debounce again; a nested burst
|
||||||
|
# would share the ring and corrupt the result accounting.
|
||||||
|
self.w._pre_history.clear()
|
||||||
|
self.w._pre_history.extend(_sweep(cid) for cid in range(1, 6))
|
||||||
|
self.addCleanup(self.w._pre_history.clear)
|
||||||
|
|
||||||
|
self.w._bscan_replay_active = True
|
||||||
|
self.addCleanup(setattr, self.w, "_bscan_replay_active", False)
|
||||||
|
|
||||||
|
self.assertFalse(self.w._reprocess_history_through_processor())
|
||||||
|
|
||||||
|
def test_replay_refuses_without_an_active_run_config(self) -> None:
|
||||||
|
# A GUI restarted against a still-running processor does not know the ring
|
||||||
|
# geometry, and ShmRingWriter would recreate the segment underneath it.
|
||||||
|
self.w._pre_history.clear()
|
||||||
|
self.w._pre_history.extend(_sweep(cid) for cid in range(1, 6))
|
||||||
|
self.addCleanup(self.w._pre_history.clear)
|
||||||
|
|
||||||
|
previous = getattr(self.w, "_active_run_config", None)
|
||||||
|
self.w._active_run_config = None
|
||||||
|
self.addCleanup(setattr, self.w, "_active_run_config", previous)
|
||||||
|
|
||||||
|
self.assertIsNone(self.w._ensure_replay_ring_writer())
|
||||||
|
self.assertFalse(self.w._reprocess_history_through_processor())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -219,7 +219,7 @@ class RebuildBscanHistoryTest(unittest.TestCase):
|
|||||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
||||||
self._bscan_collection(2, (0, 0), [1.0, 2.0], [11.0, 21.0]),
|
self._bscan_collection(2, (0, 0), [1.0, 2.0], [11.0, 21.0]),
|
||||||
]
|
]
|
||||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10)
|
||||||
self.assertEqual(len(by_combo[(0, 0)]), 2)
|
self.assertEqual(len(by_combo[(0, 0)]), 2)
|
||||||
self.assertTrue(np.array_equal(axes[(0, 0)], np.array([1.0, 2.0], dtype=np.float32)))
|
self.assertTrue(np.array_equal(axes[(0, 0)], np.array([1.0, 2.0], dtype=np.float32)))
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ class RebuildBscanHistoryTest(unittest.TestCase):
|
|||||||
self._bscan_collection(2, (0, 0), [1.0, 2.0], [1.0, 2.0], kind=2), # wrong kind
|
self._bscan_collection(2, (0, 0), [1.0, 2.0], [1.0, 2.0], kind=2), # wrong kind
|
||||||
self._bscan_collection(3, (0, 0), [1.0, 2.0, 3.0], [1.0, 2.0]), # size mismatch
|
self._bscan_collection(3, (0, 0), [1.0, 2.0, 3.0], [1.0, 2.0]), # size mismatch
|
||||||
]
|
]
|
||||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10)
|
||||||
self.assertEqual(by_combo, {})
|
self.assertEqual(by_combo, {})
|
||||||
|
|
||||||
def test_depth_axis_change_resets_history(self) -> None:
|
def test_depth_axis_change_resets_history(self) -> None:
|
||||||
@@ -237,17 +237,21 @@ class RebuildBscanHistoryTest(unittest.TestCase):
|
|||||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
||||||
self._bscan_collection(2, (0, 0), [1.0, 2.0, 3.0], [11.0, 21.0, 31.0]), # new depth axis
|
self._bscan_collection(2, (0, 0), [1.0, 2.0, 3.0], [11.0, 21.0, 31.0]), # new depth axis
|
||||||
]
|
]
|
||||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10)
|
||||||
self.assertEqual(len(by_combo[(0, 0)]), 1) # reset on axis change; only the latest sweep remains
|
self.assertEqual(len(by_combo[(0, 0)]), 1) # reset on axis change; only the latest sweep remains
|
||||||
self.assertEqual(axes[(0, 0)].shape, (3,))
|
self.assertEqual(axes[(0, 0)].shape, (3,))
|
||||||
|
|
||||||
def test_floor_collection_id_excludes_older(self) -> None:
|
def test_selection_is_positional_not_by_collection_id(self) -> None:
|
||||||
|
# Ids are neither dense nor monotonic across a run boundary, so the tail is
|
||||||
|
# taken by position only. Here the newest two entries carry the SMALLEST ids;
|
||||||
|
# an id-based cut-off would have dropped exactly them.
|
||||||
history = [
|
history = [
|
||||||
self._bscan_collection(1, (0, 0), [1.0], [10.0]),
|
self._bscan_collection(cid, (0, 0), [1.0], [float(cid)])
|
||||||
self._bscan_collection(2, (0, 0), [1.0], [20.0]),
|
for cid in (98, 99, 100, 1, 2)
|
||||||
]
|
]
|
||||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=1)
|
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=3)
|
||||||
self.assertEqual(len(by_combo[(0, 0)]), 1) # only collection_id > 1
|
self.assertEqual(len(by_combo[(0, 0)]), 3)
|
||||||
|
self.assertEqual([sweep[0] for sweep in by_combo[(0, 0)]], [100.0, 1.0, 2.0])
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
"""Neutral preprocessing-set helpers for Kamil ADC acquisition."""
|
"""Neutral preprocessing-set helpers — the "run without calibration" path.
|
||||||
|
|
||||||
|
A neutral pair is a calibration set carrying unit S21 (1+0j) and a reference set
|
||||||
|
carrying zero S21. The C++ through-calibrator divides measured/calibration and the
|
||||||
|
reference is subtracted, so applying both leaves the measured S21 untouched. That
|
||||||
|
lets an operator start the pipeline before any real calibration exists, which the
|
||||||
|
required-asset check in `_start_run` would otherwise refuse.
|
||||||
|
|
||||||
|
Supported models: Kamil ADC (axis from the ADC processing grid) and every
|
||||||
|
VNA-style model, including synchronized multi-device LibreVNA (axis from the
|
||||||
|
configured linear sweep grid).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,31 +28,65 @@ from python_app.models.run_config_model import ComboModel, RunConfigModel
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def build_kamil_adc_neutral_s21_sets(
|
def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool:
|
||||||
|
"""Return whether neutral S21 sets can be generated for this radar model.
|
||||||
|
|
||||||
|
Enabled for the Kamil ADC and for synchronized multi-device LibreVNA, the two
|
||||||
|
models whose emitted frequency axis is fully derivable from the config alone.
|
||||||
|
Other models still work through `build_neutral_s21_sets`, but are kept out of the
|
||||||
|
UI shortcut until their axis has been verified against real hardware.
|
||||||
|
"""
|
||||||
|
return bool(config.is_kamil_adc or config.is_multi_device)
|
||||||
|
|
||||||
|
|
||||||
|
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
|
||||||
|
"""Return the exact per-trace frequency axis the configured radar emits.
|
||||||
|
|
||||||
|
Neutral sets must line up sample-for-sample with live sweeps, so the axis comes
|
||||||
|
from the same source the acquisition path uses: the ADC processing grid for Kamil
|
||||||
|
ADC, and the configured linear sweep grid for every VNA-style model (LibreVNA
|
||||||
|
single and multi-device, SN9000, Compact-M). The C++ preprocessor re-checks this
|
||||||
|
axis against the measured one within a tolerance, so a mismatch fails loudly
|
||||||
|
instead of silently corrupting the correction.
|
||||||
|
"""
|
||||||
|
if config.is_kamil_adc:
|
||||||
|
# Single source of truth for the axis: the same grid the processor emits.
|
||||||
|
processor = KamilAdcSweepProcessor(
|
||||||
|
KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc)
|
||||||
|
)
|
||||||
|
return processor.grid_hz
|
||||||
|
|
||||||
|
points = int(config.radar.sweep.points)
|
||||||
|
if points < 1:
|
||||||
|
raise ValueError("Neutral sets require radar.sweep.points >= 1")
|
||||||
|
if points == 1:
|
||||||
|
return np.array([float(config.radar.sweep.start_hz)], dtype=np.float32)
|
||||||
|
# Mirrors both acquisition paths: the native collector seeds this same linspace
|
||||||
|
# and the mock backend generates it outright.
|
||||||
|
return np.linspace(
|
||||||
|
float(config.radar.sweep.start_hz),
|
||||||
|
float(config.radar.sweep.stop_hz),
|
||||||
|
points,
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_neutral_s21_sets(
|
||||||
config: RunConfigModel,
|
config: RunConfigModel,
|
||||||
) -> tuple[SweepCollection, SweepCollection]:
|
) -> tuple[SweepCollection, SweepCollection]:
|
||||||
"""Build neutral S21 calibration/reference collections for the Kamil ADC radar.
|
"""Build neutral S21 calibration/reference collections for the active radar.
|
||||||
|
|
||||||
The calibration uses unit S21 (1+0j) and the reference uses zero S21 across
|
Covers every combo in the effective matrix, so a matrix radar widened by real
|
||||||
every configured combo, so applying them in the preprocessing pipeline leaves
|
switches gets a neutral pair for all of its positions and the preprocessor's
|
||||||
the input S21 unchanged. The frequency axis is the exact acquisition grid
|
``validate_combos()`` is satisfied. Returns ``(calibration, reference)``.
|
||||||
(``radar.kamil_adc.band``), so neutral sets line up sample-for-sample with
|
|
||||||
live sweeps. Returns the ``(calibration, reference)`` collections.
|
|
||||||
"""
|
"""
|
||||||
if not config.is_kamil_adc:
|
|
||||||
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
|
|
||||||
|
|
||||||
combos = list(config.combos)
|
combos = list(config.combos)
|
||||||
if not combos:
|
if not combos:
|
||||||
combos = RunConfigModel.build_full_combos(
|
combos = config.build_runtime_combos()
|
||||||
config.input_switch.positions, config.output_switch.positions
|
|
||||||
)
|
|
||||||
if not combos:
|
if not combos:
|
||||||
raise ValueError("Kamil ADC neutral sets require at least one switch combo")
|
raise ValueError("Neutral sets require at least one switch combo")
|
||||||
|
|
||||||
# Single source of truth for the axis: the same grid the processor emits.
|
frequency_hz = neutral_frequency_grid_hz(config)
|
||||||
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
|
|
||||||
frequency_hz = processor.grid_hz
|
|
||||||
|
|
||||||
now_ns = time.monotonic_ns()
|
now_ns = time.monotonic_ns()
|
||||||
calibration = _neutral_collection(
|
calibration = _neutral_collection(
|
||||||
@@ -57,11 +102,27 @@ def build_kamil_adc_neutral_s21_sets(
|
|||||||
monotonic_ns=now_ns,
|
monotonic_ns=now_ns,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Built neutral Kamil ADC S21 sets: combos=%d points=%d", len(combos), int(frequency_hz.size)
|
"Built neutral S21 sets: model=%s combos=%d points=%d",
|
||||||
|
config.radar.model,
|
||||||
|
len(combos),
|
||||||
|
int(frequency_hz.size),
|
||||||
)
|
)
|
||||||
return calibration, reference
|
return calibration, reference
|
||||||
|
|
||||||
|
|
||||||
|
def build_kamil_adc_neutral_s21_sets(
|
||||||
|
config: RunConfigModel,
|
||||||
|
) -> tuple[SweepCollection, SweepCollection]:
|
||||||
|
"""Build neutral S21 sets, rejecting anything but the Kamil ADC radar.
|
||||||
|
|
||||||
|
Kept as the model-checked entry point for the ADC path; new callers that must
|
||||||
|
work for several radar models should use `build_neutral_s21_sets` instead.
|
||||||
|
"""
|
||||||
|
if not config.is_kamil_adc:
|
||||||
|
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
|
||||||
|
return build_neutral_s21_sets(config)
|
||||||
|
|
||||||
|
|
||||||
def _neutral_collection(
|
def _neutral_collection(
|
||||||
*,
|
*,
|
||||||
combos: list[ComboModel],
|
combos: list[ComboModel],
|
||||||
|
|||||||
@@ -81,14 +81,7 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
self._manual_matrix_radar_capture = (
|
self._manual_matrix_radar_capture = (
|
||||||
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
|
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
|
||||||
)
|
)
|
||||||
self._combos = (
|
self._combos = base_config.build_runtime_combos()
|
||||||
RunConfigModel.build_matrix_radar_virtual_combos()
|
|
||||||
if self._is_matrix_radar
|
|
||||||
else RunConfigModel.build_full_combos(
|
|
||||||
base_config.input_switch.positions,
|
|
||||||
base_config.output_switch.positions,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not self._combos:
|
if not self._combos:
|
||||||
raise RuntimeError("No switch combinations available for capture")
|
raise RuntimeError("No switch combinations available for capture")
|
||||||
|
|
||||||
|
|||||||
@@ -64,11 +64,7 @@ class SequentialCaptureSession:
|
|||||||
self._manual_matrix_radar_capture = (
|
self._manual_matrix_radar_capture = (
|
||||||
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
|
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
|
||||||
)
|
)
|
||||||
self._combos = (
|
self._combos = config.build_runtime_combos()
|
||||||
RunConfigModel.build_matrix_radar_virtual_combos()
|
|
||||||
if self._is_matrix_radar
|
|
||||||
else RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
|
||||||
)
|
|
||||||
if not self._combos:
|
if not self._combos:
|
||||||
raise RuntimeError("No switch combinations available for capture")
|
raise RuntimeError("No switch combinations available for capture")
|
||||||
|
|
||||||
|
|||||||
+143
-10
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"radar": {
|
"radar": {
|
||||||
"model": "librevna",
|
"model": "librevna_multi",
|
||||||
"serial": "",
|
"serial": "",
|
||||||
"remote_host": "127.0.0.1",
|
"remote_host": "127.0.0.1",
|
||||||
"remote_port": 50209,
|
"remote_port": 50209,
|
||||||
@@ -8,9 +8,14 @@
|
|||||||
"mock_signal_hz": 5000000.0,
|
"mock_signal_hz": 5000000.0,
|
||||||
"visa_library": "",
|
"visa_library": "",
|
||||||
"multi_device": {
|
"multi_device": {
|
||||||
"slave_serials": [],
|
"slave_serials": [
|
||||||
|
"20A1307D5532",
|
||||||
|
"2072306C5532"
|
||||||
|
],
|
||||||
"force_external_reference": false,
|
"force_external_reference": false,
|
||||||
"recovery_attempts": 3
|
"recovery_attempts": 3,
|
||||||
|
"output_switch_positions": 1,
|
||||||
|
"input_switch_positions": 3
|
||||||
},
|
},
|
||||||
"kamil_adc": {
|
"kamil_adc": {
|
||||||
"project_dir": "",
|
"project_dir": "",
|
||||||
@@ -20,7 +25,18 @@
|
|||||||
"env": {},
|
"env": {},
|
||||||
"startup_timeout_s": 5.0,
|
"startup_timeout_s": 5.0,
|
||||||
"sweep_timeout_s": 5.0,
|
"sweep_timeout_s": 5.0,
|
||||||
"stop_timeout_s": 2.0
|
"stop_timeout_s": 2.0,
|
||||||
|
"phase_calibration": {
|
||||||
|
"phase0_rad": 0.0,
|
||||||
|
"freq0_hz": 2046000000.0,
|
||||||
|
"phase1_rad": 300.0,
|
||||||
|
"freq1_hz": 5612000000.0
|
||||||
|
},
|
||||||
|
"band": {
|
||||||
|
"start_hz": 2100000000.0,
|
||||||
|
"stop_hz": 5500000000.0,
|
||||||
|
"points": 2048
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"laser_control": {
|
"laser_control": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -75,7 +91,7 @@
|
|||||||
"driver_mode": "mock",
|
"driver_mode": "mock",
|
||||||
"driver": "h7992",
|
"driver": "h7992",
|
||||||
"radar_port": 2,
|
"radar_port": 2,
|
||||||
"positions": 4,
|
"positions": 12,
|
||||||
"default_position": 0,
|
"default_position": 0,
|
||||||
"gpio_chip": "/dev/gpiochip0",
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
"pin_a": 22,
|
"pin_a": 22,
|
||||||
@@ -92,11 +108,14 @@
|
|||||||
"debounce_ms": 50,
|
"debounce_ms": 50,
|
||||||
"action": "capture_tmp_reference"
|
"action": "capture_tmp_reference"
|
||||||
},
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "debug"
|
||||||
|
},
|
||||||
"run": {
|
"run": {
|
||||||
"settling_ms": 0,
|
"settling_ms": 0,
|
||||||
"idle_sleep_ms": 2,
|
"idle_sleep_ms": 2,
|
||||||
"continuous": true,
|
"continuous": true,
|
||||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
"processing_live_config_path": "/home/guriy/Documents/radar_system/python_app/runtime/processing_live.json",
|
||||||
"locator_server": {
|
"locator_server": {
|
||||||
"device_id": 3,
|
"device_id": 3,
|
||||||
"protocol_version": 1,
|
"protocol_version": 1,
|
||||||
@@ -123,6 +142,38 @@
|
|||||||
"input": 3,
|
"input": 3,
|
||||||
"output": 0
|
"output": 0
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"input": 4,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 5,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 6,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 7,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 8,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 9,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 10,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 11,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"input": 0,
|
"input": 0,
|
||||||
"output": 1
|
"output": 1
|
||||||
@@ -138,17 +189,49 @@
|
|||||||
{
|
{
|
||||||
"input": 3,
|
"input": 3,
|
||||||
"output": 1
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 4,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 5,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 6,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 7,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 8,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 9,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 10,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 11,
|
||||||
|
"output": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"preprocess": {
|
"preprocess": {
|
||||||
"s21": {
|
"s21": {
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"set_name": "smoke_cal",
|
"set_name": "smoke_cal3",
|
||||||
"bundle_path": ""
|
"bundle_path": ""
|
||||||
},
|
},
|
||||||
"reference": {
|
"reference": {
|
||||||
"set_name": "smoke_ref",
|
"set_name": "smoke_cal3",
|
||||||
"bundle_path": ""
|
"bundle_path": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -219,6 +302,54 @@
|
|||||||
"x_m": 0.185,
|
"x_m": 0.185,
|
||||||
"y_m": 0.0,
|
"y_m": 0.0,
|
||||||
"z_m": 0.0
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 4,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 5,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 6,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 7,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 8,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 9,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 10,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 11,
|
||||||
|
"x_m": 0.0,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -253,7 +384,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"switches": {
|
"switches": {
|
||||||
"combo_mode": "text",
|
"combo_mode": "text",
|
||||||
"combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1",
|
"combos_text": "0:0,1:0,2:0,3:0,4:0,5:0,6:0,7:0,8:0,9:0,10:0,11:0,0:1,1:1,2:1,3:1,4:1,5:1,6:1,7:1,8:1,9:1,10:1,11:1",
|
||||||
"single_input": "0",
|
"single_input": "0",
|
||||||
"single_output": "0"
|
"single_output": "0"
|
||||||
},
|
},
|
||||||
@@ -262,6 +393,7 @@
|
|||||||
"pass_through": {
|
"pass_through": {
|
||||||
"show_magnitude": true,
|
"show_magnitude": true,
|
||||||
"show_phase": false,
|
"show_phase": false,
|
||||||
|
"unwrap_phase": false,
|
||||||
"combo_filter": "",
|
"combo_filter": "",
|
||||||
"fixed_y_enabled": false,
|
"fixed_y_enabled": false,
|
||||||
"y_min_db": -100.0,
|
"y_min_db": -100.0,
|
||||||
@@ -333,7 +465,8 @@
|
|||||||
"data_actions": {
|
"data_actions": {
|
||||||
"save_count": 10,
|
"save_count": 10,
|
||||||
"save_path": "python_app/data/snapshots",
|
"save_path": "python_app/data/snapshots",
|
||||||
"save_name": "snapshot_simulator"
|
"save_name": "snapshot_simulator",
|
||||||
|
"record_count": 100
|
||||||
},
|
},
|
||||||
"preprocess_dialog": {
|
"preprocess_dialog": {
|
||||||
"set_name": "smoke_cal",
|
"set_name": "smoke_cal",
|
||||||
|
|||||||
Reference in New Issue
Block a user