Compare commits

..
3 Commits
Author SHA1 Message Date
BogatskiyG d61b59b9a4 added a bscan extension 2026-07-31 15:46:08 +03:00
BogatskiyG 7c6cab07fc some changes and log fix 2026-07-30 19:58:53 +03:00
BogatskiyG 68bec25f17 Add real switch support for multi-device matrix radar
MultiDeviceLibreVnaService only exposes the 2x4 virtual combo matrix on
its own USB transport. When a physical GPIO switch sits on the master
stimulus and/or slave receiver path, the effective matrix is wider than
that. SwitchedMatrixRadarService wraps the inner service and drives the
extra switch(es) between acquire_collection calls, widening the combo
matrix by the physical position counts (matrix_output_switch_positions /
matrix_input_switch_positions in RunConfigModel).

Combo-matrix construction was centralized into
RunConfigModel.build_runtime_combos() so the GUI, workflows, and codec
all derive the same widened matrix instead of each computing its own
version of the virtual 2x4 layout.
2026-07-29 17:44:28 +03:00
24 changed files with 834 additions and 124 deletions
+2 -1
View File
@@ -226,4 +226,5 @@ python_app/runtime
SHARE_INTERNET_TO_PI.md
CLAUDE.md
./docs
docs/
test_end_2/
+5 -5
View File
@@ -274,7 +274,7 @@ class AppWindow(
def _init_history_state(self) -> None:
"""Initialize runtime history buffers and render-cache state."""
bscan_history_limit = self._history_limit_from_config()
bscan_cpp_replay_window = self._cpp_bscan_replay_window_from_config()
save_history_limit = self._save_history_limit_from_config()
self._raw_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
self._pre_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
@@ -282,7 +282,7 @@ class AppWindow(
# Sequence id must survive GUI restarts so history commands stay monotonic.
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
self._bscan_history_limit = bscan_history_limit
self._bscan_cpp_replay_window = bscan_cpp_replay_window
self._bscan_history_by_combo = {}
self._bscan_depth_axis_by_combo = {}
self._bscan_history_floor_collection_id = 0
@@ -305,9 +305,9 @@ class AppWindow(
self._active_processing_mode = "pass_through"
self._radar_limits: dict[str, float | int] | None = None
def _history_limit_from_config(self) -> int:
"""Return B-scan render history limit derived from configured ring capacities."""
return self._history_limit_for_config(self._defaults_config)
def _cpp_bscan_replay_window_from_config(self) -> int:
"""Return the C++ B-scan replay window for the active config."""
return self._cpp_bscan_replay_window_for_config(self._defaults_config)
def _save_history_limit_from_config(self) -> int:
"""Return maxlen for GUI snapshot-save deques (independent of ring capacities)."""
@@ -61,7 +61,7 @@ class AppWindowConfigProfileIOMixin:
"""Return the canonical virtual combo matrix shown for matrix-mode radars."""
return ",".join(
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:
@@ -121,15 +121,15 @@ class AppWindowConfigProfileIOMixin:
Save-side deques use a config-independent limit so that processing-side
ring capacities can stay small without truncating the save buffer. The
B-scan render limit still follows ring capacities to keep plot updates
responsive.
C++ replay window still follows ring capacities, since it bounds how much
of the history the processor can re-publish coherently.
"""
save_history_limit = self._save_history_limit_for_config(config)
bscan_history_limit = self._history_limit_for_config(config)
bscan_cpp_replay_window = self._cpp_bscan_replay_window_for_config(config)
self._raw_history = deque(self._raw_history, maxlen=save_history_limit)
self._pre_history = deque(self._pre_history, maxlen=save_history_limit)
self._result_history = deque(self._result_history, maxlen=save_history_limit)
self._bscan_history_limit = bscan_history_limit
self._bscan_cpp_replay_window = bscan_cpp_replay_window
self._clear_bscan_plot_history()
def _save_current_config(self) -> None:
@@ -283,6 +283,7 @@ class AppWindowConfigProfileIOMixin:
self._bscan_start_freq_mhz,
self._bscan_stop_freq_mhz,
self._bscan_subtract_mean_ascan,
self._bscan_history_window,
self._gpr_relative_permittivity,
self._gpr_tx_geometry_input,
self._gpr_rx_geometry_input,
@@ -438,6 +439,7 @@ class AppWindowConfigProfileIOMixin:
self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz))
self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz))
self._bscan_subtract_mean_ascan.setChecked(bool(gui_state.processing.bscan.subtract_mean_ascan))
self._bscan_history_window.setValue(int(gui_state.processing.bscan.history_window_scans))
self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
self._gpr_tx_geometry_input.setPlainText(
@@ -34,6 +34,13 @@ from python_app.storage.npz_store import radar_key_from_config
# history without touching the processing-side ring sizes.
GUI_SAVE_HISTORY_LIMIT: int = 1000
# Mirror of `kBscanReplayWindow` in
# data_acq_and_processing/processing/data_processor/src/data_processor.cpp. When a
# live B-scan setting changes, the C++ processor re-processes and re-publishes only
# this many of the newest collections; anything older keeps the payload it was first
# computed with. Keep the two constants in sync.
CPP_BSCAN_REPLAY_WINDOW: int = 50
class AppWindowConfigStateBuildersMixin:
"""Build stable and GUI-only config models from current widget state."""
@@ -125,7 +132,7 @@ class AppWindowConfigStateBuildersMixin:
if config.is_matrix_radar:
return ",".join(
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)
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)
@staticmethod
def _history_limit_for_config(config: RunConfigModel) -> int:
"""Return B-scan render history limit derived from config ring capacities."""
def _cpp_bscan_replay_window_for_config(config: RunConfigModel) -> int:
"""Return how many newest collections the C++ processor re-processes on a
live B-scan settings change.
Deliberately reproduces `replay_history_limit()` in `data_processor.cpp`
formula-for-formula. The ring capacities matter because `ShmRing` overwrites
the oldest unread slot on overflow, so a replay burst must fit in the results
ring for the GUI to receive all of it.
This is the single place to change if the replay window ever becomes
configurable on the C++ side.
"""
return max(
1,
min(
int(config.rings.raw_tap.capacity),
int(config.rings.preprocessed_tap.capacity),
int(config.rings.preprocessed.capacity),
int(config.rings.results.capacity),
CPP_BSCAN_REPLAY_WINDOW,
),
)
@@ -180,7 +197,7 @@ class AppWindowConfigStateBuildersMixin:
Independent of ring capacities — see :data:`GUI_SAVE_HISTORY_LIMIT`.
The `config` argument is kept for symmetry with
:meth:`_history_limit_for_config` and possible future per-profile
:meth:`_cpp_bscan_replay_window_for_config` and possible future per-profile
overrides.
"""
del config
@@ -344,6 +361,7 @@ class AppWindowConfigStateBuildersMixin:
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()),
history_window_scans=int(self._bscan_history_window.value()),
),
gpr=GuiGprStateModel(
input_positions=self._gpr_input_positions_input.text().strip(),
@@ -52,6 +52,7 @@ def build_bscan_signature(
floor_collection_id=floor_collection_id,
)
return (
int(history_limit),
str(live_config.bscan_axis),
str(live_config.bscan_channel),
float(live_config.bscan_cut_m),
@@ -213,6 +214,7 @@ class AppWindowBscanPlotMixin:
depth_max = float(np.max(depth_axis))
depth_span = max(depth_max - depth_min, 1e-6)
sweep_count = sweeps.shape[0]
self._warn_if_bscan_exceeds_replay_window(sweep_count)
sweep_width = float(max(sweep_count, 1))
x_min = 0.5
x_max = x_min + sweep_width
@@ -236,9 +238,52 @@ class AppWindowBscanPlotMixin:
)
return True
def _warn_if_bscan_exceeds_replay_window(self, sweep_count: int) -> None:
"""Warn once when the image reaches past what the C++ processor can replay.
Beyond that window a frame keeps the payload it was first computed with, so
editing Gain / Cut / Max depth / Start-Stop MHz silently leaves the older
columns on their previous settings the image mixes two parameter sets.
"""
replay_window = int(self._bscan_cpp_replay_window)
if sweep_count <= replay_window:
return
stale_count = sweep_count - replay_window
self._log_warning(
f"B-scan shows {sweep_count} sweeps but the processor replays only the newest "
f"{replay_window}; the older {stale_count} keep the settings they were captured with.",
details=(
"Changing Gain / Cut m / Max depth m / Start MHz / Stop MHz re-processes "
f"only the newest {replay_window} sweeps.\n"
f"Reduce 'Scans to show (stopped)' to {replay_window} for an image that is "
"coherent across every column."
),
# Keyed on the operator-controlled window, not the live column count, so
# that repeated "Remove Last" in stopped mode does not re-warn every click.
once_key=(
f"bscan_window_exceeds_replay_{replay_window}_"
f"{self._bscan_display_window_scans()}"
),
)
def _bscan_display_window_scans(self) -> int:
"""Return how many past sweeps the B-scan should render right now.
While acquisition runs the window stays at the C++ replay window: results
arrive continuously, the whole history is rebuilt on every new one, and a
1000-wide rebuild on the live path would cost ~20x per frame.
Once stopped, the operator reviews a frozen history, so the user-configured
window applies and may reach back over the whole GUI result deque.
"""
if self._supervisor.is_running():
return int(self._bscan_cpp_replay_window)
return max(1, int(self._bscan_history_window.value()))
def _sync_bscan_history_from_results(self) -> None:
"""Rebuild B-scan history cache when live params or inputs changed."""
self._advance_bscan_floor_to_cpp_window()
self._advance_bscan_floor_to_display_window()
signature = self._bscan_signature()
if signature == self._bscan_render_signature:
return
@@ -253,7 +298,7 @@ class AppWindowBscanPlotMixin:
live_config=live_config,
subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
result_history=result_history,
history_limit=self._bscan_history_limit,
history_limit=self._bscan_display_window_scans(),
floor_collection_id=self._bscan_history_floor_collection_id,
)
@@ -262,7 +307,7 @@ class AppWindowBscanPlotMixin:
result_history = list(self._result_history)
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
result_history=result_history,
history_limit=self._bscan_history_limit,
history_limit=self._bscan_display_window_scans(),
floor_collection_id=self._bscan_history_floor_collection_id,
)
self._bscan_history_by_combo = history_by_combo
@@ -351,29 +396,35 @@ class AppWindowBscanPlotMixin:
self._bscan_depth_axis_by_combo.clear()
self._bscan_render_signature = None
def _advance_bscan_floor_to_cpp_window(self) -> None:
"""Clamp B-scan source history to C++ available replay window."""
if not self._result_history:
def _advance_bscan_floor_to_display_window(self) -> None:
"""Clamp B-scan source history to the active display window.
The window counts RETAINED ENTRIES, so the floor is read off the n-th
newest entry rather than computed as `latest_id - window`. Collection ids
are not dense: the results ring overwrites unread slots when the producer
outruns the GUI poll loop, so the GUI keeps ids like 1..50, 81..130, ...
Subtracting the window from the newest id would then span far fewer than
`window` entries asking for 150 sweeps yielded 87.
Recomputed unconditionally rather than ratcheted upwards: widening the
window in stopped mode must be able to LOWER the floor and bring older
frames back into view. A stale floor cannot survive this way either, so
the previous special case for collection ids restarting on a new C++ run
is no longer needed.
"""
history = self._result_history
if not history:
return
cpp_window_limit = min(
int(self._defaults_config.rings.preprocessed.capacity),
int(self._defaults_config.rings.results.capacity),
)
cpp_window_limit = max(1, cpp_window_limit)
latest_collection_id = int(self._result_history[-1].collection_id)
current_floor = int(self._bscan_history_floor_collection_id)
# Collection ids restart from 1 on new C++ run; release floor only while
# acquisition is running, so manual "remove last" behavior in stopped mode
# remains deterministic.
if latest_collection_id < current_floor and self._supervisor.is_running():
window = self._bscan_display_window_scans()
if len(history) <= window:
self._bscan_history_floor_collection_id = 0
current_floor = 0
return
floor_candidate = max(0, latest_collection_id - cpp_window_limit)
if floor_candidate > current_floor:
self._bscan_history_floor_collection_id = floor_candidate
# `_result_tail` keeps entries with `collection_id > floor`, so sit the
# floor one below the oldest entry that still fits in the window.
oldest_visible = history[len(history) - window]
self._bscan_history_floor_collection_id = max(0, int(oldest_visible.collection_id) - 1)
def _ensure_phase_view_box(self) -> pg.ViewBox:
"""Create or return secondary right-axis ViewBox for phase curves."""
@@ -10,7 +10,10 @@ from python_app.orchestration.preprocess_assets import (
preprocess_asset_channel,
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 (
MultiRadarCaptureBatch,
MultiRadarSequentialCaptureSession,
@@ -216,8 +219,8 @@ class AppWindowPreprocessMixin:
dialog.undo_last_requested.connect(self._undo_last_capture)
dialog.finalize_sequence_requested.connect(self._finalize_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.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc)
dialog.create_neutral_sets_requested.connect(self._create_neutral_sets)
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
dialog.set_radar_config_summary(
directory_path=self._preprocess_radar_scan_summary.directory_path,
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)}"
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}")
if unavailable_selections:
self._log_warning(
@@ -391,8 +394,8 @@ class AppWindowPreprocessMixin:
self._show_exception(f"Failed to start {kind} sequence", exc)
self._resume_pipeline_if_needed()
def _create_kamil_adc_neutral_sets(self) -> None:
"""Save neutral S21 calibration/reference sets for the current Kamil ADC settings."""
def _create_neutral_sets(self) -> None:
"""Save neutral S21 calibration/reference sets for the current radar settings."""
if self._capture_session is not None:
self._show_error(
"Cannot create neutral sets during active capture sequence",
@@ -409,8 +412,10 @@ class AppWindowPreprocessMixin:
pipeline_was_paused = False
try:
config = self._build_config()
if not config.is_kamil_adc:
self._show_error("Neutral S21 sets are available only for kamil_adc")
if not supports_neutral_preprocess_sets(config):
self._show_error(
"Neutral S21 sets are available only for kamil_adc and librevna_multi"
)
return
radar_key = self._radar_key(config)
@@ -425,12 +430,12 @@ class AppWindowPreprocessMixin:
)
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()
pipeline_was_paused = True
calibration, reference = build_kamil_adc_neutral_s21_sets(config)
point_count = config.radar.kamil_adc.band.points
calibration, reference = build_neutral_s21_sets(config)
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_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)"
)
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}"
)
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:
if pipeline_was_paused:
self._start_run()
@@ -18,6 +18,7 @@ from PyQt6.QtWidgets import (
QWidget,
)
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget
@@ -160,6 +161,15 @@ def build_processing_group(owner) -> QGroupBox:
owner._bscan_subtract_mean_ascan = QCheckBox("Subtract mean A-scan")
owner._bscan_subtract_mean_ascan.setChecked(bool(bscan_defaults.subtract_mean_ascan))
owner._bscan_history_window = QSpinBox()
owner._bscan_history_window.setMinimum(1)
owner._bscan_history_window.setMaximum(GUI_SAVE_HISTORY_LIMIT)
owner._bscan_history_window.setValue(int(bscan_defaults.history_window_scans))
owner._bscan_history_window.setToolTip(
"How many past sweeps the B-scan shows once acquisition is stopped. While "
"running, the window stays clamped to the C++ ring capacity."
)
bscan_page = _build_processing_mode_page(
owner._processing_mode_pages,
[
@@ -169,6 +179,7 @@ def build_processing_group(owner) -> QGroupBox:
("Gain", owner._bscan_gain),
("Start MHz", owner._bscan_start_freq_mhz),
("Stop MHz", owner._bscan_stop_freq_mhz),
("Scans to show (stopped)", owner._bscan_history_window),
owner._bscan_subtract_mean_ascan,
],
split_index=4,
@@ -569,6 +580,7 @@ def build_processing_group(owner) -> QGroupBox:
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_subtract_mean_ascan.toggled.connect(owner._on_processing_live_settings_changed)
owner._bscan_history_window.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
+13 -12
View File
@@ -46,7 +46,7 @@ class PreprocessDialog(QDialog):
undo_last_requested = pyqtSignal()
finalize_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:
"""Initialize window metadata and compose dialog UI."""
@@ -92,17 +92,18 @@ class PreprocessDialog(QDialog):
self._set_name_input = QLineEdit("set_001", group)
refresh_button = QPushButton("Refresh Sets", group)
refresh_button.clicked.connect(self.refresh_requested.emit)
self._kamil_adc_neutral_sets_button = QPushButton("Create Neutral S21 Sets", group)
self._kamil_adc_neutral_sets_button.setToolTip(
"Save S21 calibration=1 and S21 reference=0 for the current Kamil ADC settings."
self._neutral_sets_button = QPushButton("Create Neutral S21 Sets", group)
self._neutral_sets_button.setToolTip(
"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.create_kamil_adc_neutral_sets_requested.emit
self._neutral_sets_button.clicked.connect(
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(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)
layout.addLayout(header_row)
layout.addLayout(self._build_median_sweep_row(group))
@@ -413,10 +414,10 @@ class PreprocessDialog(QDialog):
"""Set short human-readable status line."""
self._status_label.setText(message)
def set_kamil_adc_neutral_sets_visible(self, visible: bool) -> None:
"""Show Kamil ADC neutral-set shortcut only in the matching radar mode."""
self._kamil_adc_neutral_sets_button.setVisible(bool(visible))
self._kamil_adc_neutral_sets_button.setEnabled(bool(visible))
def set_neutral_sets_visible(self, visible: bool) -> None:
"""Show the neutral-set shortcut only for radar models that support it."""
self._neutral_sets_button.setVisible(bool(visible))
self._neutral_sets_button.setEnabled(bool(visible))
def reset_preview(self) -> None:
"""Clear preview surfaces and restore default empty-state text when possible."""
@@ -131,7 +131,14 @@ class MultiDeviceVnaController:
if not self._reference_configuration_applied:
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 (
self._sweep_is_running
@@ -334,12 +341,16 @@ class MultiDeviceVnaController:
self._sweep_is_running = True
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.
Concurrent draining keeps cross-device timing skew small so a hardware
cycle wrap cannot slip between per-device drains and desynchronize the
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:
# 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
# just runs inline to avoid the thread-spawn overhead.
if len(self._all_devices) < 2:
for device_connection in self._all_devices:
device_connection.drain_received_packets()
return
return sum(
len(device_connection.drain_received_packets())
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 = [
threading.Thread(
target=device_connection.drain_received_packets,
target=drain_one_device,
args=(device_index, device_connection),
name=f"drain-{device_connection.serial_number}",
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:
drain_thread.start()
for drain_thread in drain_threads:
drain_thread.join()
return sum(drained_counts)
@staticmethod
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(
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
device_state: _DeviceCollectionState,
device_label: str = "device",
) -> Callable[[ParsedVnaDatapoint], bool]:
"""Wrap a cycle-aware handler with cross-device cycle tracking.
@@ -462,6 +463,11 @@ def collect_complete_running_sweep_cycles(
cycle_tracking_state = {
"current_cycle_index": 0,
"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:
@@ -475,6 +481,7 @@ def collect_complete_running_sweep_cycles(
if not cycle_tracking_state["synchronized"]:
if current_point_index != 0:
cycle_tracking_state["pre_anchor_skipped"] += 1
return False
# Candidate cycle 0. Commit it only once every device confirms it
# observed point 0 of the SAME physical sweep; otherwise reject the
@@ -484,6 +491,14 @@ def collect_complete_running_sweep_cycles(
report_cycle_misalignment()
return False
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)
return True
@@ -493,6 +508,12 @@ def collect_complete_running_sweep_cycles(
# spurious wrap and desynchronize the cycle counter.
if current_point_index == 0:
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"]
if current_cycle_index >= cycle_count:
# The sweep just wrapped past the final requested cycle, closing its
@@ -505,6 +526,14 @@ def collect_complete_running_sweep_cycles(
return False
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 handle_datapoint
@@ -587,7 +616,9 @@ def collect_complete_running_sweep_cycles(
point_index,
] = 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()
collection_threads = [
@@ -595,7 +626,9 @@ def collect_complete_running_sweep_cycles(
target=collect_datapoints_from_device,
args=(
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,
),
daemon=True,
@@ -46,13 +46,31 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
if model == RunConfigModel.LIBREVNA_MULTI_MODEL:
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
return MultiDeviceLibreVnaService(
inner = MultiDeviceLibreVnaService(
master_serial=config.radar.serial,
slave_serials=list(config.radar.multi_device.slave_serials),
force_external_reference=config.radar.multi_device.force_external_reference,
recovery_attempts=config.radar.multi_device.recovery_attempts,
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 config.radar.driver_mode != "native":
@@ -66,6 +66,10 @@ class SwitchService:
"""Switch to requested 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
def current_position(self) -> int:
"""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)
)
+7
View File
@@ -244,6 +244,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.bscan.subtract_mean_ascan,
"gui.processing.bscan",
),
history_window_scans=_optional_int(
bscan_object,
"history_window_scans",
gui.processing.bscan.history_window_scans,
"gui.processing.bscan",
),
),
gpr=GuiGprStateModel(
input_positions=_optional_string(
@@ -579,6 +585,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"start_freq_mhz": gui.processing.bscan.start_freq_mhz,
"stop_freq_mhz": gui.processing.bscan.stop_freq_mhz,
"subtract_mean_ascan": gui.processing.bscan.subtract_mean_ascan,
"history_window_scans": gui.processing.bscan.history_window_scans,
},
"gpr": {
"input_positions": gui.processing.gpr.input_positions,
+4
View File
@@ -48,6 +48,10 @@ class GuiBscanStateModel:
start_freq_mhz: float = 100.0
stop_freq_mhz: float = 8800.0
subtract_mean_ascan: bool = False
# How many past sweeps the B-scan heatmap renders once acquisition is stopped.
# While running the window stays at the C++ replay window (see
# `_cpp_bscan_replay_window_for_config`); this only widens the stopped-mode view.
history_window_scans: int = 50
@dataclass(slots=True)
+12
View File
@@ -219,6 +219,16 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
"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(
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
)
@@ -493,6 +503,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"slave_serials": list(model.radar.multi_device.slave_serials),
"force_external_reference": model.radar.multi_device.force_external_reference,
"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": {
"project_dir": model.radar.kamil_adc.project_dir,
+27 -4
View File
@@ -40,6 +40,8 @@ class RadarMultiDeviceModel:
slave_serials: list[str] = field(default_factory=list)
force_external_reference: bool = True
recovery_attempts: int = 3
output_switch_positions: int = 1 # 1 = свитча нет
input_switch_positions: int = 1 # 1 = свитча нет
@dataclass(slots=True)
@@ -387,6 +389,24 @@ class RunConfigModel:
"""Return whether this config acquires the full virtual switch matrix per sweep."""
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
def is_kamil_adc(self) -> bool:
"""Return whether this config targets the external Kamil ADC acquisition path."""
@@ -443,22 +463,25 @@ class RunConfigModel:
if not self.is_matrix_radar:
return
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:
"""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.driver_mode = "mock"
self.output_switch.driver = self.output_switch.driver or "h7992"
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.input_switch.name = self.input_switch.name or "virtual_input"
self.input_switch.driver_mode = "mock"
self.input_switch.driver = self.input_switch.driver or "h7992"
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
def ensure_combos(self) -> None:
+20 -3
View File
@@ -105,7 +105,10 @@ def _read_log_tail(path: Path, max_bytes: int = 16384) -> str:
data = handle.read()
except OSError:
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:
@@ -232,8 +235,17 @@ class ProcessSupervisor:
self._roll_log_to_prev(stdout_path)
self._roll_log_to_prev(stderr_path)
stdout_file = open(stdout_path, "wb")
stderr_file = open(stderr_path, "wb")
# O_APPEND ("ab"), not "wb": the child inherits these fds and keeps its own
# 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:
handle = subprocess.Popen(
command,
@@ -499,6 +511,11 @@ class ProcessSupervisor:
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
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:
if path.stat().st_size <= _LOG_MAX_BYTES:
@@ -11,6 +11,7 @@ import threading
import time
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.orchestration.shm import ShmRingWriter
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.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:
raise RuntimeError(
"matrix_raw_producer requires a matrix-mode radar.model "
@@ -0,0 +1,140 @@
"""Configurable B-scan display window.
The B-scan used to be pinned to the C++ replay window (~50 sweeps) by two separate
mechanisms: the render-side history limit and a monotonically rising
`_bscan_history_floor_collection_id`. Widening only the first would have changed
nothing, because the floor kept filtering older collections out for good.
These tests pin the two properties that make the stopped-mode review work: the
window follows acquisition state, and the floor is recomputed (not ratcheted) so a
widened window can bring already-discarded frames back into view.
"""
from __future__ import annotations
import os
import unittest
from pathlib import Path
import numpy as np
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.models.dataset_model import ( # noqa: E402
ComboKey,
ResultBlock,
ResultCollection,
ResultPayload,
)
_app: QApplication | None = None
_window: AppWindow | None = None
def setUpModule() -> None:
global _app, _window
_app = QApplication.instance() or QApplication([])
_window = AppWindow(Path("."))
def tearDownModule() -> None:
if _window is not None:
_window.close()
def _bscan_result(collection_id: int) -> ResultCollection:
payload = ResultPayload(
processing_name="bscan",
kind=1,
frequency_hz=np.array([0.5, 1.0], dtype=np.float32),
trace=np.array([collection_id + 0j, collection_id + 0j], dtype=np.complex64),
)
block = ResultBlock(combo=ComboKey(input=0, output=0), payloads=[payload])
return ResultCollection(collection_id=collection_id, monotonic_ns=collection_id, blocks=[block])
class BscanDisplayWindowTest(unittest.TestCase):
def setUp(self) -> None:
self.w = _window
self._original_is_running = self.w._supervisor.is_running
self.w._result_history.clear()
self.w._bscan_history_floor_collection_id = 0
def tearDown(self) -> None:
self.w._supervisor.is_running = self._original_is_running
self.w._result_history.clear()
self.w._bscan_history_floor_collection_id = 0
def _set_running(self, running: bool) -> None:
self.w._supervisor.is_running = lambda: running
def _fill_history(self, count: int, *, id_step: int = 1) -> None:
for index in range(count):
self.w._result_history.append(_bscan_result(1 + index * id_step))
def test_running_acquisition_ignores_the_user_window(self) -> None:
# A live rebuild runs on every incoming result, so the live path stays pinned
# to the replay window no matter what the operator typed for stopped review.
self._set_running(True)
self.w._bscan_history_window.setValue(300)
self.assertEqual(self.w._bscan_display_window_scans(), self.w._bscan_cpp_replay_window)
def test_stopped_acquisition_uses_the_user_window(self) -> None:
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self.assertEqual(self.w._bscan_display_window_scans(), 300)
def test_widening_the_window_lowers_the_floor(self) -> None:
# The regression this whole change exists for: a run leaves the floor high,
# and widening the window afterwards must pull it back down.
self._fill_history(300)
self._set_running(True)
self.w._advance_bscan_floor_to_display_window()
raised_floor = self.w._bscan_history_floor_collection_id
self.assertEqual(raised_floor, 300 - self.w._bscan_cpp_replay_window)
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self.w._advance_bscan_floor_to_display_window()
self.assertEqual(self.w._bscan_history_floor_collection_id, 0)
def test_floor_survives_collection_ids_restarting(self) -> None:
# A new C++ run restarts ids from 1. The unconditional recompute must not
# leave a stale high floor that hides the whole fresh run.
self._fill_history(300)
self._set_running(True)
self.w._advance_bscan_floor_to_display_window()
self.assertGreater(self.w._bscan_history_floor_collection_id, 0)
self.w._result_history.clear()
self._fill_history(5)
self.w._advance_bscan_floor_to_display_window()
self.assertEqual(self.w._bscan_history_floor_collection_id, 0)
def test_window_counts_entries_not_collection_ids(self) -> None:
# The results ring overwrites unread slots when the producer outruns the GUI
# poll loop, so retained collection ids are sparse. A floor of
# `latest_id - window` then spans far fewer than `window` entries: this is
# exactly the case where asking for 150 sweeps rendered only 87.
self._fill_history(300, id_step=3)
self._set_running(False)
self.w._bscan_history_window.setValue(150)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 150)
def test_rebuild_renders_the_full_widened_window(self) -> None:
self._fill_history(300)
self._set_running(False)
self.w._bscan_history_window.setValue(300)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 300)
self.w._bscan_history_window.setValue(50)
self.w._sync_bscan_history_from_results()
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 50)
if __name__ == "__main__":
unittest.main()
@@ -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
@@ -17,31 +28,65 @@ from python_app.models.run_config_model import ComboModel, RunConfigModel
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,
) -> 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
every configured combo, so applying them in the preprocessing pipeline leaves
the input S21 unchanged. The frequency axis is the exact acquisition grid
(``radar.kamil_adc.band``), so neutral sets line up sample-for-sample with
live sweeps. Returns the ``(calibration, reference)`` collections.
Covers every combo in the effective matrix, so a matrix radar widened by real
switches gets a neutral pair for all of its positions and the preprocessor's
``validate_combos()`` is satisfied. Returns ``(calibration, reference)``.
"""
if not config.is_kamil_adc:
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
combos = list(config.combos)
if not combos:
combos = RunConfigModel.build_full_combos(
config.input_switch.positions, config.output_switch.positions
)
combos = config.build_runtime_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.
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
frequency_hz = processor.grid_hz
frequency_hz = neutral_frequency_grid_hz(config)
now_ns = time.monotonic_ns()
calibration = _neutral_collection(
@@ -57,11 +102,27 @@ def build_kamil_adc_neutral_s21_sets(
monotonic_ns=now_ns,
)
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
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(
*,
combos: list[ComboModel],
@@ -81,14 +81,7 @@ class MultiRadarSequentialCaptureSession:
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._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,
)
)
self._combos = base_config.build_runtime_combos()
if not self._combos:
raise RuntimeError("No switch combinations available for capture")
@@ -64,11 +64,7 @@ class SequentialCaptureSession:
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._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)
)
self._combos = config.build_runtime_combos()
if not self._combos:
raise RuntimeError("No switch combinations available for capture")
+144 -11
View File
@@ -1,6 +1,6 @@
{
"radar": {
"model": "librevna",
"model": "librevna_multi",
"serial": "",
"remote_host": "127.0.0.1",
"remote_port": 50209,
@@ -8,9 +8,14 @@
"mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": {
"slave_serials": [],
"slave_serials": [
"20A1307D5532",
"2072306C5532"
],
"force_external_reference": false,
"recovery_attempts": 3
"recovery_attempts": 3,
"output_switch_positions": 1,
"input_switch_positions": 3
},
"kamil_adc": {
"project_dir": "",
@@ -20,7 +25,18 @@
"env": {},
"startup_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": {
"enabled": false,
@@ -75,7 +91,7 @@
"driver_mode": "mock",
"driver": "h7992",
"radar_port": 2,
"positions": 4,
"positions": 12,
"default_position": 0,
"gpio_chip": "/dev/gpiochip0",
"pin_a": 22,
@@ -92,11 +108,14 @@
"debounce_ms": 50,
"action": "capture_tmp_reference"
},
"logging": {
"level": "debug"
},
"run": {
"settling_ms": 0,
"idle_sleep_ms": 2,
"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": {
"device_id": 3,
"protocol_version": 1,
@@ -123,6 +142,38 @@
"input": 3,
"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,
"output": 1
@@ -138,17 +189,49 @@
{
"input": 3,
"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": {
"s21": {
"calibration": {
"set_name": "smoke_cal",
"set_name": "smoke_cal3",
"bundle_path": ""
},
"reference": {
"set_name": "smoke_ref",
"set_name": "smoke_cal3",
"bundle_path": ""
}
},
@@ -219,6 +302,54 @@
"x_m": 0.185,
"y_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,
"switches": {
"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_output": "0"
},
@@ -262,6 +393,7 @@
"pass_through": {
"show_magnitude": true,
"show_phase": false,
"unwrap_phase": false,
"combo_filter": "",
"fixed_y_enabled": false,
"y_min_db": -100.0,
@@ -333,7 +465,8 @@
"data_actions": {
"save_count": 10,
"save_path": "python_app/data/snapshots",
"save_name": "snapshot_simulator"
"save_name": "snapshot_simulator",
"record_count": 100
},
"preprocess_dialog": {
"set_name": "smoke_cal",
@@ -342,4 +475,4 @@
"median_sweep_count": 5
}
}
}
}