added multidevice support

This commit is contained in:
Ayzen
2026-04-28 17:29:21 +03:00
parent 93c705a33b
commit 1ea2aabf87
37 changed files with 1860 additions and 138 deletions
+4 -1
View File
@@ -1,5 +1,5 @@
CXX := g++ CXX := g++
CXXFLAGS := -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread CXXFLAGS := -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread -MMD -MP
LDFLAGS := -pthread -lrt LDFLAGS := -pthread -lrt
ORCH_LDFLAGS := $(LDFLAGS) -lusb-1.0 ORCH_LDFLAGS := $(LDFLAGS) -lusb-1.0
@@ -54,6 +54,7 @@ PROCESSOR_SOURCES := \
SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o)) SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o))
PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o)) PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o)) DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d))
TARGETS := \ TARGETS := \
$(BIN_DIR)/sweep_orchestrator \ $(BIN_DIR)/sweep_orchestrator \
@@ -80,5 +81,7 @@ $(BUILD_DIR)/%.o: %.cpp
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@ $(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
-include $(DEPFILES)
clean: clean:
rm -rf $(BUILD_DIR) rm -rf $(BUILD_DIR)
+5 -5
View File
@@ -325,7 +325,7 @@ class AppWindow(
def _apply_initial_radar_limits(self) -> None: def _apply_initial_radar_limits(self) -> None:
"""Apply startup radar-limits strategy according to selected radar mode.""" """Apply startup radar-limits strategy according to selected radar mode."""
if self._radar_mode.currentText() == "native": if self._defaults_config.radar.driver_mode == "native":
self._refresh_radar_limits_from_device() self._refresh_radar_limits_from_device()
return return
self._apply_radar_limits_to_ui(None) self._apply_radar_limits_to_ui(None)
@@ -364,15 +364,15 @@ class AppWindow(
level_upper = level.upper() level_upper = level.upper()
palette = { palette = {
"INFO": ("#7fb1ff", "#dce8f8", "#8ba2be"), "INFO": ("#1d5fbf", "#1f2937", "#526277"),
"WARN": ("#f4bf4f", "#f4dca0", "#9f8b55"), "WARN": ("#9a5b00", "#5c4300", "#7a6640"),
"ERROR": ("#ff5f6d", "#ffd1d5", "#b5878c"), "ERROR": ("#c43d4d", "#6b1f2a", "#8b5d66"),
} }
accent_color, message_color, detail_color = palette.get(level_upper, palette["INFO"]) accent_color, message_color, detail_color = palette.get(level_upper, palette["INFO"])
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
header_html = ( header_html = (
f"<span style='color:{accent_color}; font-weight:700;'>{html.escape(level_upper)}</span>" f"<span style='color:{accent_color}; font-weight:700;'>{html.escape(level_upper)}</span>"
f" <span style='color:#7f94af;'>{html.escape(timestamp)}</span>" f" <span style='color:#60758d;'>{html.escape(timestamp)}</span>"
f" <span style='color:{message_color};'>{self._escape_log_text(text)}</span>" f" <span style='color:{message_color};'>{self._escape_log_text(text)}</span>"
) )
@@ -11,6 +11,7 @@ from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QFileDialog from PyQt6.QtWidgets import QFileDialog
from python_app.models.gui_profile_model import GuiProfileModel from python_app.models.gui_profile_model import GuiProfileModel
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.preprocess_assets import ( from python_app.orchestration.preprocess_assets import (
VISIBLE_PREPROCESS_ASSET_KEYS, VISIBLE_PREPROCESS_ASSET_KEYS,
preprocess_asset_model, preprocess_asset_model,
@@ -32,12 +33,36 @@ class AppWindowConfigProfileIOMixin:
def _set_combo_selection_mode(self, mode: str) -> None: def _set_combo_selection_mode(self, mode: str) -> None:
"""Highlight current combo mode and enable only the relevant editors.""" """Highlight current combo mode and enable only the relevant editors."""
if self._is_multi_device_model_selected():
self._run_combos_select_button.setChecked(True)
self._single_combo_select_button.setChecked(False)
self._combos_text.setText(self._fixed_multi_combo_text())
self._combos_text.setEnabled(False)
self._single_combo_output.setEnabled(True)
self._single_combo_input.setEnabled(True)
self._run_combos_select_button.setEnabled(False)
self._single_combo_select_button.setEnabled(False)
return
text_selected = mode != "single" text_selected = mode != "single"
self._run_combos_select_button.setChecked(text_selected) self._run_combos_select_button.setChecked(text_selected)
self._single_combo_select_button.setChecked(not text_selected) self._single_combo_select_button.setChecked(not text_selected)
self._combos_text.setEnabled(text_selected) self._combos_text.setEnabled(text_selected)
self._single_combo_output.setEnabled(not text_selected) self._single_combo_output.setEnabled(not text_selected)
self._single_combo_input.setEnabled(not text_selected) self._single_combo_input.setEnabled(not text_selected)
self._run_combos_select_button.setEnabled(True)
self._single_combo_select_button.setEnabled(True)
def _is_multi_device_model_selected(self) -> bool:
"""Return whether the loaded config targets LibreVNA multi-device acquisition."""
return bool(self._defaults_config.is_multi_device)
def _fixed_multi_combo_text(self) -> str:
"""Return the canonical virtual combo matrix shown for multi-device mode."""
return ",".join(
f"{int(combo.input)}:{int(combo.output)}"
for combo in RunConfigModel.build_multi_device_virtual_combos()
)
def _sync_pass_through_y_controls(self) -> None: def _sync_pass_through_y_controls(self) -> None:
"""Enable Y-range editors only when fixed Y mode is active.""" """Enable Y-range editors only when fixed Y mode is active."""
@@ -138,14 +163,13 @@ class AppWindowConfigProfileIOMixin:
"""Apply already parsed profile to GUI state without restarting the pipeline.""" """Apply already parsed profile to GUI state without restarting the pipeline."""
config = profile.run_config.clone() config = profile.run_config.clone()
gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config) gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config)
self._defaults_config = config
selected_preprocess_sets = { selected_preprocess_sets = {
key: str(preprocess_asset_model(config, key).set_name) key: str(preprocess_asset_model(config, key).set_name)
for key in VISIBLE_PREPROCESS_ASSET_KEYS for key in VISIBLE_PREPROCESS_ASSET_KEYS
} }
radio_widgets = ( radio_widgets = (
self._serial_input,
self._radar_mode,
self._start_hz_input, self._start_hz_input,
self._stop_hz_input, self._stop_hz_input,
self._points_input, self._points_input,
@@ -202,8 +226,6 @@ class AppWindowConfigProfileIOMixin:
for widget in radio_widgets: for widget in radio_widgets:
blockers.enter_context(QSignalBlocker(widget)) blockers.enter_context(QSignalBlocker(widget))
self._serial_input.setText(str(config.radar.serial))
self._set_combo_current_text(self._radar_mode, str(config.radar.driver_mode))
self._start_hz_input.setText(f"{config.radar.sweep.start_hz:g}") self._start_hz_input.setText(f"{config.radar.sweep.start_hz:g}")
self._stop_hz_input.setText(f"{config.radar.sweep.stop_hz:g}") self._stop_hz_input.setText(f"{config.radar.sweep.stop_hz:g}")
self._points_input.setText(str(int(config.radar.sweep.points))) self._points_input.setText(str(int(config.radar.sweep.points)))
@@ -271,7 +293,6 @@ class AppWindowConfigProfileIOMixin:
self._save_path_input.setText(str(gui_state.data_actions.save_path)) self._save_path_input.setText(str(gui_state.data_actions.save_path))
self._save_name_input.setText(str(gui_state.data_actions.save_name)) self._save_name_input.setText(str(gui_state.data_actions.save_name))
self._defaults_config = config
self._gui_defaults = gui_state self._gui_defaults = gui_state
self._selected_preprocess_sets = selected_preprocess_sets self._selected_preprocess_sets = selected_preprocess_sets
self._selected_preprocess_radar_key = self._radar_key(config) self._selected_preprocess_radar_key = self._radar_key(config)
@@ -8,16 +8,6 @@ from python_app.hardware_full.librevna_service import LibreVnaService
class AppWindowRadarLimitsMixin: class AppWindowRadarLimitsMixin:
"""Handle LibreVNA capability probing and dependent UI clamping.""" """Handle LibreVNA capability probing and dependent UI clamping."""
def _on_radar_identity_changed(self, *_args) -> None:
"""Refresh device limits when radar identity/mode changes."""
self._reset_preprocess_selection_after_radar_key_change()
if self._radar_mode.currentText() != "native":
self._apply_radar_limits_to_ui(None)
return
changed = self._refresh_radar_limits_from_device()
if changed:
self._on_processing_live_settings_changed()
def _on_radar_sweep_limits_changed(self) -> None: def _on_radar_sweep_limits_changed(self) -> None:
"""Clamp processing frequency bounds after sweep start/stop edits.""" """Clamp processing frequency bounds after sweep start/stop edits."""
self._reset_preprocess_selection_after_radar_key_change() self._reset_preprocess_selection_after_radar_key_change()
@@ -26,7 +16,7 @@ class AppWindowRadarLimitsMixin:
def _refresh_radar_limits_from_device(self) -> bool: def _refresh_radar_limits_from_device(self) -> bool:
"""Query native LibreVNA limits and apply them to GUI fields.""" """Query native LibreVNA limits and apply them to GUI fields."""
serial = self._serial_input.text().strip() serial = self._defaults_config.radar.serial
radar_service = LibreVnaService(serial=serial or None) radar_service = LibreVnaService(serial=serial or None)
if not radar_service.driver_available: if not radar_service.driver_available:
self._fallback_to_mock_mode("LibreVNA Python driver is not available for device limits query") self._fallback_to_mock_mode("LibreVNA Python driver is not available for device limits query")
@@ -56,11 +46,8 @@ class AppWindowRadarLimitsMixin:
self._radar_points_label.setText("Points") self._radar_points_label.setText("Points")
self._radar_ifbw_label.setText("IF BW Hz") self._radar_ifbw_label.setText("IF BW Hz")
self._radar_power_label.setText("Stimulus Power dBm") self._radar_power_label.setText("Stimulus Power dBm")
if self._radar_mode.currentText() == "native": self._radar_limits_hint.setText("Device limits are not available.")
self._radar_limits_hint.setText("Device limits unavailable in native mode (device not connected).") self._power_input.setToolTip("Stimulus power configured in the active profile.")
else:
self._radar_limits_hint.setText("Mock mode: device limits are not applied.")
self._power_input.setToolTip("Device power limits are available only in native mode.")
return False return False
min_freq_hz = float(limits["min_frequency_hz"]) min_freq_hz = float(limits["min_frequency_hz"])
@@ -82,6 +82,11 @@ class AppWindowConfigStateBuildersMixin:
@staticmethod @staticmethod
def _format_combos_text_from_config(config: RunConfigModel) -> str: def _format_combos_text_from_config(config: RunConfigModel) -> str:
"""Render configured combos for UI text editor, keeping full matrix as empty.""" """Render configured combos for UI text editor, keeping full matrix as empty."""
if config.is_multi_device:
return ",".join(
f"{int(combo.input)}:{int(combo.output)}"
for combo in RunConfigModel.build_multi_device_virtual_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)
if len(combos) == len(full_combos) and all( if len(combos) == len(full_combos) and all(
@@ -215,8 +220,18 @@ class AppWindowConfigStateBuildersMixin:
"""Build GUI-only persistent state from current widget values.""" """Build GUI-only persistent state from current widget values."""
return GuiStateModel( return GuiStateModel(
switches=GuiSwitchStateModel( switches=GuiSwitchStateModel(
combo_mode="single" if self._single_combo_select_button.isChecked() else "text", combo_mode=(
combos_text=self._combos_text.text().strip(), "text"
if self._is_multi_device_model_selected()
else "single"
if self._single_combo_select_button.isChecked()
else "text"
),
combos_text=(
self._fixed_multi_combo_text()
if self._is_multi_device_model_selected()
else self._combos_text.text().strip()
),
single_input=self._single_combo_input.text().strip(), single_input=self._single_combo_input.text().strip(),
single_output=self._single_combo_output.text().strip(), single_output=self._single_combo_output.text().strip(),
), ),
@@ -292,6 +307,11 @@ class AppWindowConfigStateBuildersMixin:
config.runtime.settling_ms = int(self._settling_ms.text().strip()) config.runtime.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path) config.runtime.processing_live_config_path = str(self._live_config_writer.path)
if config.is_multi_device:
if len(config.radar.multi_device.slave_serials) != 2:
raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
config.apply_device_model_constraints()
else:
if self._single_combo_select_button.isChecked(): if self._single_combo_select_button.isChecked():
config.combos = [ config.combos = [
ComboModel( ComboModel(
@@ -331,23 +351,36 @@ class AppWindowConfigStateBuildersMixin:
sweep_points=config.radar.sweep.points, sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz, ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm, power_dbm=config.radar.sweep.power_dbm,
extra_serials=(
config.radar.multi_device.slave_serials
if config.is_multi_device
else None
),
) )
def _radar_key_from_ui(self) -> str: def _radar_key_from_ui(self) -> str:
"""Build current radar key directly from radar widgets only.""" """Build current radar key directly from radar widgets only."""
model_name = self._defaults_config.radar.model or RunConfigModel.LIBREVNA_MODEL
return radar_key_from_config( return radar_key_from_config(
model_name=self._defaults_config.radar.model, model_name=model_name,
serial=self._serial_input.text().strip(), serial=self._defaults_config.radar.serial,
sweep_start_hz=float(self._start_hz_input.text().strip()), sweep_start_hz=float(self._start_hz_input.text().strip()),
sweep_stop_hz=float(self._stop_hz_input.text().strip()), sweep_stop_hz=float(self._stop_hz_input.text().strip()),
sweep_points=int(self._points_input.text().strip()), sweep_points=int(self._points_input.text().strip()),
ifbw_hz=float(self._ifbw_input.text().strip()), ifbw_hz=float(self._ifbw_input.text().strip()),
power_dbm=float(self._power_input.text().strip()), power_dbm=float(self._power_input.text().strip()),
extra_serials=(
self._defaults_config.radar.multi_device.slave_serials
if model_name == RunConfigModel.LIBREVNA_MULTI_MODEL
else None
),
) )
@staticmethod @staticmethod
def _switches_are_effectively_static(config: RunConfigModel) -> bool: def _switches_are_effectively_static(config: RunConfigModel) -> bool:
"""Return `True` when switch setup effectively yields one fixed combo.""" """Return `True` when switch setup effectively yields one fixed combo."""
if config.is_multi_device:
return False
has_single_position = config.input_switch.positions <= 1 and config.output_switch.positions <= 1 has_single_position = config.input_switch.positions <= 1 and config.output_switch.positions <= 1
both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock" both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock"
return has_single_position or both_mock return has_single_position or both_mock
@@ -153,9 +153,9 @@ class AppWindowPipelineMixin:
self._stop_run() self._stop_run()
try: try:
if self._radar_mode.currentText() == "native":
self._refresh_radar_limits_from_device()
config = self._build_config() config = self._build_config()
if config.radar.driver_mode == "native":
self._refresh_radar_limits_from_device()
run_signature = self._build_run_history_signature(config) run_signature = self._build_run_history_signature(config)
if processor_only_running and self._processor_requires_restart(run_signature): if processor_only_running and self._processor_requires_restart(run_signature):
self._stop_all_processes() self._stop_all_processes()
@@ -185,6 +185,9 @@ class AppWindowPipelineMixin:
if config.radar.driver_mode != "native": if config.radar.driver_mode != "native":
self._log("Radar pre-configuration skipped (mock mode)") self._log("Radar pre-configuration skipped (mock mode)")
return return
if config.is_multi_device:
self._log("Multi-device raw producer will configure all LibreVNA devices")
return
radar_service = LibreVnaService(serial=config.radar.serial or None) radar_service = LibreVnaService(serial=config.radar.serial or None)
if not radar_service.driver_available: if not radar_service.driver_available:
@@ -128,10 +128,13 @@ def rebuild_bscan_history_from_results(
def pick_bscan_display_key( def pick_bscan_display_key(
history_by_combo: dict[tuple[int, int], deque[np.ndarray]], history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
preferred_key: tuple[int, int] | None = None,
) -> tuple[int, int] | None: ) -> tuple[int, int] | None:
"""Choose combo key to display when multiple histories are present.""" """Choose combo key to display when multiple histories are present."""
if not history_by_combo: if not history_by_combo:
return None return None
if preferred_key is not None and preferred_key in history_by_combo:
return preferred_key
return next(iter(history_by_combo.keys())) return next(iter(history_by_combo.keys()))
@@ -267,9 +270,31 @@ class AppWindowBscanPlotMixin:
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."""
display_key = pick_bscan_display_key(self._bscan_history_by_combo) requested_key = self._requested_bscan_display_key()
display_key = pick_bscan_display_key(
self._bscan_history_by_combo,
preferred_key=requested_key,
)
available_keys = sorted(self._bscan_history_by_combo.keys()) available_keys = sorted(self._bscan_history_by_combo.keys())
if requested_key is not None and requested_key != display_key and available_keys:
combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys)
details = "\n".join(
f"- in{input_pos}/out{output_pos}"
for input_pos, output_pos in available_keys
)
self._log_warning(
f"B-scan display combo in{requested_key[0]}/out{requested_key[1]} is not available; "
f"rendering in{display_key[0]}/out{display_key[1]} instead.",
details=details,
once_key=(
f"bscan_requested_combo_missing_{requested_key[0]}_{requested_key[1]}_"
f"{combo_signature}"
),
)
return display_key
if display_key is not None and len(available_keys) > 1: if display_key is not None and len(available_keys) > 1:
if requested_key == display_key:
return display_key
combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys) combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys)
details = "\n".join( details = "\n".join(
f"- in{input_pos}/out{output_pos}" f"- in{input_pos}/out{output_pos}"
@@ -286,6 +311,26 @@ class AppWindowBscanPlotMixin:
) )
return display_key return display_key
def _requested_bscan_display_key(self) -> tuple[int, int] | None:
"""Return the multi-device B-scan display combo requested by the GUI."""
if not (
hasattr(self, "_is_multi_device_model_selected")
and self._is_multi_device_model_selected()
and hasattr(self, "_single_combo_input")
and hasattr(self, "_single_combo_output")
):
return None
input_text = self._single_combo_input.text().strip()
output_text = self._single_combo_output.text().strip()
if not input_text or not output_text:
return None
try:
return int(input_text), int(output_text)
except ValueError:
return None
def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray: def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray:
"""Return lookup table for current B-scan axis mode.""" """Return lookup table for current B-scan axis mode."""
return bscan_lookup_table(axis_mode) return bscan_lookup_table(axis_mode)
@@ -102,7 +102,9 @@ class AppWindowSnapshotMixin:
config_profile_path = self._vna_json_config_profile_path(output_dir) config_profile_path = self._vna_json_config_profile_path(output_dir)
try: try:
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False) # Snapshot and VNA JSON exports intentionally share the same output directory stem.
# Refresh the companion GUI profile in place so a prior dataset save does not block JSON export.
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=True)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
exported_preview = "\n".join(str(path) for path in output_paths[:8]) exported_preview = "\n".join(str(path) for path in output_paths[:8])
if len(output_paths) > 8: if len(output_paths) > 8:
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from PyQt6.QtWidgets import QComboBox, QGroupBox, QLabel, QLineEdit, QVBoxLayout from PyQt6.QtWidgets import QGroupBox, QLabel, QLineEdit, QVBoxLayout
from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget
@@ -15,22 +15,12 @@ def build_radar_group(owner) -> QGroupBox:
layout.setSpacing(8) layout.setSpacing(8)
defaults = owner._defaults_config.radar defaults = owner._defaults_config.radar
owner._serial_input = QLineEdit(defaults.serial)
owner._serial_input.setPlaceholderText("Optional: empty = auto-detect first LibreVNA")
owner._radar_mode = QComboBox()
owner._radar_mode.addItems(["mock", "native"])
owner._radar_mode.setToolTip("mock: synthetic signal, native: real LibreVNA hardware")
owner._set_combo_current_text(owner._radar_mode, defaults.driver_mode)
owner._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}") owner._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}")
owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}") owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}")
owner._points_input = QLineEdit(str(defaults.sweep.points)) owner._points_input = QLineEdit(str(defaults.sweep.points))
owner._ifbw_input = QLineEdit(f"{defaults.sweep.if_bandwidth_hz:g}") owner._ifbw_input = QLineEdit(f"{defaults.sweep.if_bandwidth_hz:g}")
owner._power_input = QLineEdit(f"{defaults.sweep.power_dbm:g}") owner._power_input = QLineEdit(f"{defaults.sweep.power_dbm:g}")
owner._power_input.setToolTip("Device power limits are available only in native mode.") owner._power_input.setToolTip("Stimulus power configured in the active profile.")
owner._serial_input.editingFinished.connect(owner._on_radar_identity_changed)
owner._radar_mode.currentTextChanged.connect(owner._on_radar_identity_changed)
owner._start_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) owner._start_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
owner._stop_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) owner._stop_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
owner._points_input.editingFinished.connect(owner._on_radar_sweep_limits_changed) owner._points_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
@@ -43,7 +33,7 @@ def build_radar_group(owner) -> QGroupBox:
owner._radar_ifbw_label = QLabel("IF BW Hz") owner._radar_ifbw_label = QLabel("IF BW Hz")
owner._radar_power_label = QLabel("Stimulus Power dBm") owner._radar_power_label = QLabel("Stimulus Power dBm")
owner._radar_limits_hint = QLabel("Mock mode: device limits are not applied.") owner._radar_limits_hint = QLabel("Device limits are not available.")
owner._radar_limits_hint.setObjectName("hintLabel") owner._radar_limits_hint.setObjectName("hintLabel")
layout.addWidget( layout.addWidget(
@@ -56,7 +46,6 @@ def build_radar_group(owner) -> QGroupBox:
(owner._radar_ifbw_label, owner._ifbw_input), (owner._radar_ifbw_label, owner._ifbw_input),
(owner._radar_power_label, owner._power_input), (owner._radar_power_label, owner._power_input),
], ],
split_index=3,
) )
) )
layout.addWidget(owner._radar_limits_hint) layout.addWidget(owner._radar_limits_hint)
@@ -63,6 +63,8 @@ def build_switch_group(owner) -> QGroupBox:
owner._run_combos_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("text")) owner._run_combos_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("text"))
owner._single_combo_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("single")) owner._single_combo_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("single"))
owner._single_combo_output.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._single_combo_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._set_combo_selection_mode(str(switch_defaults.combo_mode)) owner._set_combo_selection_mode(str(switch_defaults.combo_mode))
return group return group
+2 -1
View File
@@ -21,7 +21,8 @@ def main() -> int:
"""Run Qt event loop and show main radar control window.""" """Run Qt event loop and show main radar control window."""
app = QApplication(sys.argv) app = QApplication(sys.argv)
apply_light_theme(app) apply_light_theme(app)
pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#334155") # PyQtGraph foreground controls axis lines, tick text, labels, and titles.
pg.setConfigOptions(antialias=True, background="#ffffff", foreground="#ffffff")
window = AppWindow(PROJECT_ROOT) window = AppWindow(PROJECT_ROOT)
window.showMaximized() window.showMaximized()
return app.exec() return app.exec()
+3
View File
@@ -66,8 +66,11 @@ def build_run_history_signature(
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos) combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS) preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS)
return ( return (
str(config.radar.model),
str(config.radar.driver_mode), str(config.radar.driver_mode),
str(config.radar.serial), str(config.radar.serial),
tuple(str(value) for value in config.radar.multi_device.slave_serials),
bool(config.radar.multi_device.force_external_reference),
float(config.radar.sweep.start_hz), float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_hz), float(config.radar.sweep.stop_hz),
int(config.radar.sweep.points), int(config.radar.sweep.points),
+3
View File
@@ -103,6 +103,9 @@ QTextEdit#runtimeLogBox {
font-family: "DejaVu Sans Mono"; font-family: "DejaVu Sans Mono";
font-size: 12px; font-size: 12px;
padding: 6px 8px; padding: 6px 8px;
color: #1f2937;
background-color: #fbfdff;
border-color: #c9d4e1;
} }
QComboBox::drop-down { QComboBox::drop-down {
@@ -2,7 +2,6 @@
import logging import logging
from .device import LibreVNADevice
from .enums import ( from .enums import (
HardwareFamily, HardwareFamily,
PacketType, PacketType,
@@ -71,4 +70,14 @@ __all__ = [
"VNASweepSettings", "VNASweepSettings",
] ]
def __getattr__(name: str):
"""Load USB-backed device class only when native hardware access is requested."""
if name == "LibreVNADevice":
from .device import LibreVNADevice
return LibreVNADevice
raise AttributeError(name)
logging.getLogger(__name__).addHandler(logging.NullHandler()) logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -0,0 +1,23 @@
"""Synchronized multi-device LibreVNA driver."""
from python_app.hardware_full.librevna_multi_device_driver.models import SweepConfiguration, SweepMeasurementResult
__all__ = [
"MultiDeviceVnaController",
"SweepConfiguration",
"SweepMeasurementResult",
"list_connected_device_serial_numbers",
]
def __getattr__(name: str):
"""Lazily import native USB pieces only when they are actually needed."""
if name == "MultiDeviceVnaController":
from python_app.hardware_full.librevna_multi_device_driver.controller import MultiDeviceVnaController
return MultiDeviceVnaController
if name == "list_connected_device_serial_numbers":
from python_app.hardware_full.librevna_multi_device_driver.transport import list_connected_device_serial_numbers
return list_connected_device_serial_numbers
raise AttributeError(name)
@@ -0,0 +1,253 @@
"""Controller for synchronized multi-device LibreVNA sweeps."""
from __future__ import annotations
from collections.abc import Iterator, Sequence
from dataclasses import replace
from typing import Optional
import time
from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import (
collect_complete_running_sweep_cycles,
)
from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
SweepMeasurementResult,
)
from python_app.hardware_full.librevna_multi_device_driver.protocol import (
PacketType,
build_reference_settings_payload,
build_sweep_settings_payload,
)
from python_app.hardware_full.librevna_multi_device_driver.transport import LibreVnaUsbBulkConnection
class MultiDeviceVnaController:
"""Coordinate one master LibreVNA and receiver slave LibreVNAs."""
def __init__(
self,
master_serial_number: str,
slave_serial_numbers: Sequence[str] = (),
force_external_reference: bool = True,
) -> None:
"""Open all configured devices and initialize controller state."""
self._master_device: LibreVnaUsbBulkConnection | None = None
self._slave_devices: list[LibreVnaUsbBulkConnection] = []
self._all_devices: list[LibreVnaUsbBulkConnection] = []
self._synchronization_enabled = bool(slave_serial_numbers)
self._force_external_reference = bool(force_external_reference)
self._reference_configuration_applied = False
self._last_applied_sweep_configuration: Optional[SweepConfiguration] = None
self._last_master_stimulus_ports: tuple[int, ...] | None = None
self._reconfigure_delay_s = 0.005
self._sweep_is_running = False
self._is_closed = False
try:
self._master_device = LibreVnaUsbBulkConnection(master_serial_number)
self._slave_devices = [
LibreVnaUsbBulkConnection(slave_serial_number)
for slave_serial_number in slave_serial_numbers
]
self._all_devices = [self._master_device, *self._slave_devices]
except Exception:
self.close()
raise
def __enter__(self) -> MultiDeviceVnaController:
"""Return this controller as a context manager resource."""
return self
def __exit__(self, *_args: object) -> None:
"""Close all devices when leaving a context manager scope."""
self.close()
def close(self) -> None:
"""Stop sweeping and close every opened device transport."""
if self._is_closed:
return
self._is_closed = True
self._send_idle_to_all_devices()
for device_connection in self._all_devices:
device_connection.close()
def stop_continuous_sweep(self) -> None:
"""Stop the currently running sweep without closing device transports."""
if self._is_closed:
return
self._send_idle_to_all_devices()
def configure_continuous_sweep(
self,
sweep_configuration: SweepConfiguration,
*,
master_stimulus_ports: Sequence[int] = (1, 2),
) -> None:
"""Apply reference/sweep settings and leave devices sweeping."""
if self._is_closed:
raise RuntimeError("Controller is already closed")
stimulus_ports = self._normalize_master_stimulus_ports(master_stimulus_ports)
if not self._reference_configuration_applied:
self._configure_reference_clocks()
if (
self._sweep_is_running
and self._last_applied_sweep_configuration == sweep_configuration
and self._last_master_stimulus_ports == stimulus_ports
):
return
if self._sweep_is_running:
self._send_idle_to_all_devices()
time.sleep(self._reconfigure_delay_s)
self._drain_all_received_packets()
self._configure_sweep_on_all_devices(
sweep_configuration,
master_stimulus_ports=stimulus_ports,
)
def collect_running_sweep_cycles(
self,
cycle_count: int = 1,
*,
datapoint_timeout_seconds: float | None = None,
) -> SweepMeasurementResult:
"""Collect complete cycles from the already-running synchronized sweep."""
if self._is_closed:
raise RuntimeError("Controller is already closed")
if (
not self._sweep_is_running
or self._last_applied_sweep_configuration is None
or self._last_master_stimulus_ports is None
or self._master_device is None
):
raise RuntimeError("No running sweep is configured. Call configure_continuous_sweep() first.")
try:
return collect_complete_running_sweep_cycles(
master_device_connection=self._master_device,
slave_device_connections=self._slave_devices,
active_sweep_configuration=self._last_applied_sweep_configuration,
cycle_count=cycle_count,
master_stimulus_ports=self._last_master_stimulus_ports,
datapoint_timeout_seconds=datapoint_timeout_seconds,
)
except Exception:
self._send_idle_to_all_devices()
raise
def iter_running_sweep_cycles(self) -> Iterator[SweepMeasurementResult]:
"""Yield complete sweep cycles forever from the configured sweep."""
while True:
yield self.collect_running_sweep_cycles(1)
def _send_command_and_wait_for_acknowledgement(
self,
device_connection: LibreVnaUsbBulkConnection,
packet_type: int,
payload: bytes = b"",
timeout_seconds: float = 3.0,
retry_count: int = 1,
) -> None:
last_error: Exception | None = None
for _attempt_index in range(retry_count + 1):
device_connection.send_packet(packet_type, payload)
try:
device_connection.wait_for_acknowledgement(timeout_seconds=timeout_seconds)
return
except Exception as exc: # noqa: BLE001
last_error = exc
assert last_error is not None
raise last_error
def _try_send_command_without_failing(
self,
device_connection: LibreVnaUsbBulkConnection,
packet_type: int,
payload: bytes = b"",
timeout_seconds: float = 3.0,
retry_count: int = 1,
) -> None:
try:
self._send_command_and_wait_for_acknowledgement(
device_connection,
packet_type,
payload,
timeout_seconds=timeout_seconds,
retry_count=retry_count,
)
except Exception:
pass
def _send_idle_to_all_devices(self) -> None:
for device_connection in self._all_devices:
self._try_send_command_without_failing(
device_connection,
PacketType.SET_IDLE,
timeout_seconds=3.0,
retry_count=1,
)
self._sweep_is_running = False
def _configure_reference_clocks(self) -> None:
for device_connection in self._all_devices:
self._send_command_and_wait_for_acknowledgement(
device_connection,
PacketType.REFERENCE_SETTINGS,
build_reference_settings_payload(0, self._force_external_reference),
timeout_seconds=3.0,
retry_count=1,
)
time.sleep(0.05)
self._reference_configuration_applied = True
def _configure_sweep_on_all_devices(
self,
sweep_configuration: SweepConfiguration,
*,
master_stimulus_ports: tuple[int, ...],
) -> None:
if self._master_device is None:
raise RuntimeError("Master device is not open")
sweep_configuration_commands = [
*[(slave_device, False) for slave_device in self._slave_devices],
(self._master_device, True),
]
for device_connection, is_synchronization_master in sweep_configuration_commands:
self._send_command_and_wait_for_acknowledgement(
device_connection,
PacketType.SWEEP_SETTINGS,
build_sweep_settings_payload(
sweep_configuration,
is_synchronization_master=is_synchronization_master,
synchronization_enabled=self._synchronization_enabled,
master_stimulus_ports=master_stimulus_ports,
),
timeout_seconds=3.0,
retry_count=1,
)
self._last_applied_sweep_configuration = replace(sweep_configuration)
self._last_master_stimulus_ports = master_stimulus_ports
self._sweep_is_running = True
def _drain_all_received_packets(self) -> None:
for device_connection in self._all_devices:
device_connection.drain_received_packets()
@staticmethod
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
stimulus_ports = tuple(int(port) for port in master_stimulus_ports)
if not stimulus_ports:
raise ValueError("master_stimulus_ports must not be empty")
if len(stimulus_ports) > 2 or set(stimulus_ports) - {1, 2}:
raise ValueError("master_stimulus_ports may contain only ports 1 and 2")
if len(set(stimulus_ports)) != len(stimulus_ports):
raise ValueError("master_stimulus_ports must not contain duplicates")
return stimulus_ports
@@ -0,0 +1,309 @@
"""Collect complete synchronized sweep cycles from already-running devices."""
from __future__ import annotations
from collections.abc import Callable, Sequence
import queue
import threading
import time
import numpy as np
from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
SweepMeasurementResult,
)
from python_app.hardware_full.librevna_multi_device_driver.protocol import (
PacketType,
ParsedVnaDatapoint,
find_receiver_value,
parse_vna_datapoint_payload,
)
from python_app.hardware_full.librevna_multi_device_driver.transport import LibreVnaUsbBulkConnection
LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS = 1.5
def collect_complete_running_sweep_cycles(
*,
master_device_connection: LibreVnaUsbBulkConnection,
slave_device_connections: Sequence[LibreVnaUsbBulkConnection],
active_sweep_configuration: SweepConfiguration,
cycle_count: int,
master_stimulus_ports: Sequence[int],
datapoint_timeout_seconds: float | None = None,
) -> SweepMeasurementResult:
"""Collect complete cycles from an already-running synchronized sweep."""
cycle_count = int(cycle_count)
if cycle_count < 1:
raise ValueError("cycle_count must be >= 1")
stimulus_ports = tuple(int(port) for port in master_stimulus_ports)
if not stimulus_ports:
raise ValueError("master_stimulus_ports must not be empty")
if len(stimulus_ports) > 2 or set(stimulus_ports) - {1, 2}:
raise ValueError("master_stimulus_ports may contain only ports 1 and 2")
if len(set(stimulus_ports)) != len(stimulus_ports):
raise ValueError("master_stimulus_ports must not contain duplicates")
stage_by_master_port = {port: stage for stage, port in enumerate(stimulus_ports)}
all_device_connections = [master_device_connection, *slave_device_connections]
point_count = active_sweep_configuration.points
frequencies_hz = np.zeros(point_count, dtype=np.float64)
master_reference_measurements_by_port = {
port: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
for port in stimulus_ports
}
raw_receiver_measurements_by_s_parameter = {
name: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
for name in build_forward_s_parameter_names(len(slave_device_connections), stimulus_ports)
}
collection_errors: list[Exception] = []
datapoint_counts_by_device_serial = {
device_connection.serial_number: 0
for device_connection in all_device_connections
}
stop_collection_requested = threading.Event()
if datapoint_timeout_seconds is None:
datapoint_timeout_seconds = LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS
else:
datapoint_timeout_seconds = max(0.5, float(datapoint_timeout_seconds))
def collect_datapoints_from_device(
device_connection: LibreVnaUsbBulkConnection,
handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
) -> None:
datapoints_received = 0
expected_datapoint_count = cycle_count * point_count
last_datapoint_timestamp = time.monotonic()
while datapoints_received < expected_datapoint_count:
if stop_collection_requested.is_set():
return
remaining_timeout_seconds = (last_datapoint_timestamp + datapoint_timeout_seconds) - time.monotonic()
if remaining_timeout_seconds <= 0:
collection_errors.append(
TimeoutError(
f"No datapoints from {device_connection.serial_number} for "
f"{datapoint_timeout_seconds:.1f} s "
f"(received {datapoints_received}/{point_count})"
)
)
stop_collection_requested.set()
return
try:
packet_type, payload = device_connection.receive_packet(
timeout_seconds=min(1.0, remaining_timeout_seconds)
)
except (queue.Empty, TimeoutError) as exc:
if stop_collection_requested.is_set():
return
if isinstance(exc, queue.Empty):
continue
collection_errors.append(exc)
stop_collection_requested.set()
return
except Exception as exc: # noqa: BLE001
collection_errors.append(exc)
stop_collection_requested.set()
return
if packet_type != PacketType.VNA_DATAPOINT:
continue
parsed_datapoint = parse_vna_datapoint_payload(payload)
if parsed_datapoint and 0 <= parsed_datapoint.point_index < point_count:
last_datapoint_timestamp = time.monotonic()
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
if datapoint_was_consumed:
datapoint_counts_by_device_serial[device_connection.serial_number] += 1
datapoints_received += 1
def build_cycle_tracking_handler(
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
) -> Callable[[ParsedVnaDatapoint], bool]:
cycle_tracking_state = {
"current_cycle_index": 0,
"previous_point_index": -1,
"has_seen_cycle_start": False,
}
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
current_point_index = parsed_datapoint.point_index
if not cycle_tracking_state["has_seen_cycle_start"]:
if current_point_index != 0:
cycle_tracking_state["previous_point_index"] = current_point_index
return False
cycle_tracking_state["has_seen_cycle_start"] = True
cycle_tracking_state["previous_point_index"] = current_point_index
cycle_aware_handler(parsed_datapoint, 0)
return True
if (
cycle_tracking_state["previous_point_index"] >= 0
and current_point_index < cycle_tracking_state["previous_point_index"]
):
cycle_tracking_state["current_cycle_index"] += 1
cycle_tracking_state["previous_point_index"] = current_point_index
current_cycle_index = cycle_tracking_state["current_cycle_index"]
if current_cycle_index >= cycle_count:
return False
cycle_aware_handler(parsed_datapoint, current_cycle_index)
return True
return handle_datapoint
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
point_index = parsed_datapoint.point_index
frequencies_hz[point_index] = parsed_datapoint.frequency_hz
for master_stimulus_port, stage_index in stage_by_master_port.items():
reference_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
is_reference_receiver=True,
required_port_number=master_stimulus_port,
)
if reference_receiver_value is None:
reference_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
is_reference_receiver=True,
)
if reference_receiver_value is None:
continue
master_reference_measurements_by_port[master_stimulus_port][
cycle_index,
point_index,
] = reference_receiver_value
receiver_port_number = master_stimulus_port
s_parameter_name = master_reflection_s_parameter_name(master_stimulus_port)
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
is_reference_receiver=False,
required_port_number=receiver_port_number,
)
if port_receiver_value is not None:
raw_receiver_measurements_by_s_parameter[s_parameter_name][
cycle_index,
point_index,
] = port_receiver_value
def build_slave_datapoint_handler(slave_index: int) -> Callable[[ParsedVnaDatapoint], bool]:
receiver_base_port = 2 * slave_index + 3
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
point_index = parsed_datapoint.point_index
for master_stimulus_port, stage_index in stage_by_master_port.items():
first_s_parameter_name = f"S{receiver_base_port}{master_stimulus_port}"
second_s_parameter_name = f"S{receiver_base_port + 1}{master_stimulus_port}"
for receiver_port_number, s_parameter_name in (
(1, first_s_parameter_name),
(2, second_s_parameter_name),
):
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=stage_index,
is_reference_receiver=False,
required_port_number=receiver_port_number,
)
if port_receiver_value is not None:
raw_receiver_measurements_by_s_parameter[s_parameter_name][
cycle_index,
point_index,
] = port_receiver_value
return build_cycle_tracking_handler(handle_slave_datapoint)
collection_threads = [
threading.Thread(
target=collect_datapoints_from_device,
args=(master_device_connection, build_cycle_tracking_handler(handle_master_datapoint)),
daemon=True,
name="collect-master",
)
]
for slave_index, slave_device_connection in enumerate(slave_device_connections):
collection_threads.append(
threading.Thread(
target=collect_datapoints_from_device,
args=(slave_device_connection, build_slave_datapoint_handler(slave_index)),
daemon=True,
name=f"collect-slave{slave_index}",
)
)
for collection_thread in collection_threads:
collection_thread.start()
for collection_thread in collection_threads:
collection_thread.join()
if collection_errors:
raise RuntimeError(f"Sweep collection failed: {collection_errors[0]}") from collection_errors[0]
if slave_device_connections and min(datapoint_counts_by_device_serial.values(), default=0) == 0:
raise RuntimeError(
"No datapoints received from at least one device; hardware trigger sync did not start. "
"Check Trigger Out/In loop and 10 MHz reference wiring."
)
return SweepMeasurementResult(
frequencies_hz=frequencies_hz,
s_parameters=calculate_last_cycle_s_parameters(
raw_receiver_measurements_by_s_parameter,
master_reference_measurements_by_port,
),
)
def master_reflection_s_parameter_name(master_stimulus_port: int) -> str:
"""Return master reflection trace name for active output port."""
if master_stimulus_port == 1:
return "S11"
return "S22"
def build_forward_s_parameter_names(slave_device_count: int, master_stimulus_ports: Sequence[int]) -> list[str]:
"""Return S-parameter names for the configured forward multi-device topology."""
names: list[str] = []
for master_stimulus_port in master_stimulus_ports:
names.append(master_reflection_s_parameter_name(master_stimulus_port))
for slave_index in range(slave_device_count):
receiver_base_port = 2 * slave_index + 3
names.extend(
[
f"S{receiver_base_port}{master_stimulus_port}",
f"S{receiver_base_port + 1}{master_stimulus_port}",
]
)
return names
def calculate_last_cycle_s_parameters(
raw_receiver_measurements_by_s_parameter: dict[str, np.ndarray],
master_reference_measurements_by_port: dict[int, np.ndarray],
) -> dict[str, np.ndarray]:
"""Convert raw receiver captures from the final sweep cycle into S-parameters."""
s_parameters: dict[str, np.ndarray] = {}
for s_parameter_name, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
master_stimulus_port = int(s_parameter_name[-1])
master_reference_measurements = master_reference_measurements_by_port[master_stimulus_port]
if np.isnan(master_reference_measurements).any():
missing_reference_count = int(np.isnan(master_reference_measurements).sum())
raise RuntimeError(
f"Master port {master_stimulus_port} reference missing for {missing_reference_count} datapoints"
)
if np.isnan(raw_receiver_measurements).any():
missing_measurement_count = int(np.isnan(raw_receiver_measurements).sum())
raise RuntimeError(
f"Measurement {s_parameter_name} missing for {missing_measurement_count} datapoints"
)
s_parameters[s_parameter_name.lower()] = raw_receiver_measurements[-1] / master_reference_measurements[-1]
return s_parameters
@@ -0,0 +1,27 @@
"""Models used by synchronized multi-device LibreVNA acquisition."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True, slots=True)
class SweepConfiguration:
"""User-facing configuration for one VNA sweep."""
start_hz: int
stop_hz: int
points: int
if_bandwidth: int
power_dbm: float
dwell_us: int = 0
@dataclass(slots=True)
class SweepMeasurementResult:
"""Measured synchronized sweep for one active master output port."""
frequencies_hz: np.ndarray
s_parameters: dict[str, np.ndarray]
@@ -0,0 +1,152 @@
"""Small LibreVNA protocol subset used by synchronized multi-device sweeps."""
from __future__ import annotations
import struct
from collections.abc import Sequence
from dataclasses import dataclass
from python_app.hardware_full.librevna_multi_device_driver.models import SweepConfiguration
class PacketType:
"""Packet identifiers used by the multi-device sweep controller."""
SWEEP_SETTINGS = 2
ACKNOWLEDGE = 7
NEGATIVE_ACKNOWLEDGE = 10
REFERENCE_SETTINGS = 11
SET_IDLE = 20
VNA_DATAPOINT = 27
SYNC_MODE_HARDWARE_TRIGGER = 3
FRAME_HEADER_MAGIC = 0x5A
@dataclass(frozen=True, slots=True)
class ParsedVnaDatapoint:
"""Decoded contents of one VNADatapoint payload."""
frequency_hz: int
point_index: int
receiver_values_by_description_mask: dict[int, complex]
def build_reference_settings_payload(output_frequency_hz: int, force_external_reference: bool) -> bytes:
"""Build the ReferenceSettings payload."""
automatic_reference_switch = 0
external_input_configuration_bits = (
automatic_reference_switch << 0
| int(bool(force_external_reference)) << 1
)
return struct.pack("<IB", int(output_frequency_hz), external_input_configuration_bits)
def build_sweep_settings_payload(
sweep_configuration: SweepConfiguration,
*,
is_synchronization_master: bool,
synchronization_enabled: bool,
master_stimulus_ports: Sequence[int],
) -> bytes:
"""Build protocol-v14 SweepSettings for staged master outputs or synchronized receivers."""
stimulus_ports = tuple(int(port) for port in master_stimulus_ports)
if not stimulus_ports:
raise ValueError("master_stimulus_ports must not be empty")
if len(stimulus_ports) > 2 or set(stimulus_ports) - {1, 2}:
raise ValueError("master_stimulus_ports may contain only ports 1 and 2")
if len(set(stimulus_ports)) != len(stimulus_ports):
raise ValueError("master_stimulus_ports must not contain duplicates")
excitation_power_centidecibels_milliwatt = int(round(sweep_configuration.power_dbm * 100.0))
dwell_time_microseconds = max(0, min(int(sweep_configuration.dwell_us), 0xFFFF))
synchronization_mode = SYNC_MODE_HARDWARE_TRIGGER if synchronization_enabled else 0
last_stage_index = len(stimulus_ports) - 1
inactive_stage_index = min(last_stage_index + 1, 7)
configuration_bits = (
0 << 0
| int(bool(is_synchronization_master)) << 1
| 1 << 2
| 1 << 3
| 0 << 4
| synchronization_mode << 5
)
if is_synchronization_master:
stage_by_port = {port: stage for stage, port in enumerate(stimulus_ports)}
port_1_stimulus_stage = stage_by_port.get(1, inactive_stage_index)
port_2_stimulus_stage = stage_by_port.get(2, inactive_stage_index)
else:
port_1_stimulus_stage = inactive_stage_index
port_2_stimulus_stage = inactive_stage_index
stage_configuration_bits = (
(last_stage_index & 0x07)
| (port_1_stimulus_stage & 0x07) << 3
| (port_2_stimulus_stage & 0x07) << 6
)
return struct.pack(
"<QQHIhBHhH",
int(round(sweep_configuration.start_hz)),
int(round(sweep_configuration.stop_hz)),
int(sweep_configuration.points),
int(round(sweep_configuration.if_bandwidth)),
excitation_power_centidecibels_milliwatt,
configuration_bits,
stage_configuration_bits,
excitation_power_centidecibels_milliwatt,
dwell_time_microseconds,
)
def parse_vna_datapoint_payload(payload: bytes) -> ParsedVnaDatapoint | None:
"""Decode one VNADatapoint payload into a structured Python object."""
fixed_header_length_bytes = 12
per_value_storage_length_bytes = 9
if len(payload) < fixed_header_length_bytes:
return None
frequency_hz, _power_level_centidecibels_milliwatt, point_index = struct.unpack_from("<QhH", payload, 0)
value_count = (len(payload) - fixed_header_length_bytes) // per_value_storage_length_bytes
if value_count == 0:
return None
real_components = struct.unpack_from(f"<{value_count}f", payload, 12)
imaginary_components = struct.unpack_from(f"<{value_count}f", payload, 12 + 4 * value_count)
description_masks = payload[12 + 8 * value_count : 12 + 9 * value_count]
receiver_values_by_description_mask = {
int(description_masks[value_index]): complex(
real_components[value_index],
imaginary_components[value_index],
)
for value_index in range(value_count)
}
return ParsedVnaDatapoint(
frequency_hz=int(frequency_hz),
point_index=int(point_index),
receiver_values_by_description_mask=receiver_values_by_description_mask,
)
def find_receiver_value(
receiver_values_by_description_mask: dict[int, complex],
*,
stage_index: int,
is_reference_receiver: bool,
required_port_number: int = 0,
) -> complex | None:
"""Look up one complex receiver value by stage, receiver type, and optional port."""
required_port_bit = (1 << (required_port_number - 1)) if required_port_number else 0
for description_mask, complex_value in receiver_values_by_description_mask.items():
if (description_mask >> 5) != stage_index:
continue
if bool(description_mask & 0x10) != is_reference_receiver:
continue
if required_port_bit and not (description_mask & required_port_bit):
continue
return complex_value
return None
@@ -0,0 +1,112 @@
"""USB transport wrapper for one LibreVNA in a synchronized device group."""
from __future__ import annotations
import logging
import queue
import threading
import time
from python_app.hardware_full.librevna_driver.enums import PacketType as NativePacketType
from python_app.hardware_full.librevna_driver.models import Packet
from python_app.hardware_full.librevna_driver.protocol import FrameScanner, encode_frame
from python_app.hardware_full.librevna_driver.transport.usb import USBTransport
from python_app.hardware_full.librevna_multi_device_driver.protocol import PacketType
logger = logging.getLogger(__name__)
class LibreVnaUsbBulkConnection:
"""Minimal packet transport for one LibreVNA device."""
def __init__(self, serial_number: str) -> None:
if not serial_number:
raise ValueError("serial_number is required for multi-device acquisition")
self.serial_number = serial_number
self._scanner = FrameScanner()
self._received_packets: queue.Queue[tuple[int, bytes]] = queue.Queue()
self._fatal_error: Exception | None = None
self._fatal_lock = threading.Lock()
self._transport = USBTransport(
on_data=self._on_data,
on_disconnect=self._on_disconnect,
read_chunk_size=4096,
)
self._transport.connect(serial=serial_number, timeout_s=2.0)
def close(self) -> None:
"""Close USB resources."""
self._transport.disconnect()
def drain_received_packets(self) -> list[tuple[int, bytes]]:
"""Remove all already-buffered packets without blocking."""
drained_packets: list[tuple[int, bytes]] = []
while True:
try:
drained_packets.append(self._received_packets.get_nowait())
except queue.Empty:
return drained_packets
def send_packet(self, packet_type: int, payload: bytes = b"") -> None:
"""Send one framed packet."""
self._raise_if_failed()
native_packet_type = NativePacketType(int(packet_type))
self._transport.write(
encode_frame(Packet(native_packet_type, payload)),
timeout_s=1.0,
)
def receive_packet(self, timeout_seconds: float = 2.0) -> tuple[int, bytes]:
"""Wait for the next received packet."""
deadline = time.monotonic() + timeout_seconds
while True:
self._raise_if_failed()
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Packet not received from {self.serial_number} within {timeout_seconds} s")
try:
return self._received_packets.get(timeout=min(0.2, remaining))
except queue.Empty:
continue
def wait_for_acknowledgement(self, timeout_seconds: float = 2.0) -> None:
"""Wait for an ACK packet while ignoring unrelated asynchronous packets."""
deadline = time.monotonic() + timeout_seconds
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"ACK not received from {self.serial_number} within {timeout_seconds} s")
packet_type, _payload = self.receive_packet(timeout_seconds=remaining)
if packet_type == PacketType.ACKNOWLEDGE:
return
if packet_type == PacketType.NEGATIVE_ACKNOWLEDGE:
raise RuntimeError(f"Device {self.serial_number} returned NACK")
def _on_data(self, chunk: bytes) -> None:
try:
packets = self._scanner.feed(chunk)
except Exception as exc: # noqa: BLE001
self._set_fatal_error(exc)
return
for packet in packets:
self._received_packets.put((int(packet.type), bytes(packet.payload)))
def _on_disconnect(self, exc: Exception) -> None:
self._set_fatal_error(exc)
def _set_fatal_error(self, exc: Exception) -> None:
with self._fatal_lock:
if self._fatal_error is None:
logger.error("LibreVNA USB transport failed for %s: %s", self.serial_number, exc)
self._fatal_error = exc
def _raise_if_failed(self) -> None:
with self._fatal_lock:
if self._fatal_error is None:
return
raise RuntimeError(f"USB transport failed for {self.serial_number}: {self._fatal_error}") from self._fatal_error
def list_connected_device_serial_numbers() -> list[str]:
"""Return serial numbers of all connected LibreVNA devices."""
return [device.serial for device in USBTransport.list_devices()]
@@ -0,0 +1,222 @@
"""High-level service for LibreVNA multi-device acquisition."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import math
import time
from typing import TYPE_CHECKING
import numpy as np
from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS
from python_app.hardware_full.librevna_multi_device_driver.models import SweepConfiguration
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
if TYPE_CHECKING:
from python_app.hardware_full.librevna_multi_device_driver.controller import MultiDeviceVnaController
logger = logging.getLogger(__name__)
_INPUT_S_PARAMETERS_BY_OUTPUT: dict[int, tuple[str, ...]] = {
0: ("s31", "s41", "s51", "s61"),
1: ("s32", "s42", "s52", "s62"),
}
@dataclass(slots=True)
class MultiDeviceLibreVnaService:
"""Acquire a virtual 2x4 switch matrix from synchronized LibreVNA devices."""
master_serial: str
slave_serials: list[str]
force_external_reference: bool = True
recovery_attempts: int = 3
backend_mode: str = "auto"
_controller: "MultiDeviceVnaController | None" = field(init=False, default=None, repr=False)
_sweep_configuration: SweepConfiguration | None = field(init=False, default=None, repr=False)
_using_mock_backend: bool = field(init=False, default=False, repr=False)
_mock_phase: float = field(init=False, default=0.0, repr=False)
def __post_init__(self) -> None:
"""Validate static topology and backend selection."""
self.master_serial = str(self.master_serial).strip()
self.slave_serials = [str(value).strip() for value in self.slave_serials if str(value).strip()]
if len(self.slave_serials) != 2:
raise ValueError("LibreVNA multi-device mode requires exactly two slave serial numbers")
self.recovery_attempts = max(0, int(self.recovery_attempts))
mode = self.backend_mode.strip().lower()
if mode not in {"auto", "native", "mock"}:
raise ValueError(f"Unsupported multi-device backend mode: {self.backend_mode}")
self.backend_mode = mode
self._using_mock_backend = mode == "mock"
@property
def using_mock_backend(self) -> bool:
"""Return whether this service is generating synthetic data."""
return self._using_mock_backend
def open(self) -> None:
"""Open native device transports when not in mock mode."""
if self._using_mock_backend or self._controller is not None:
return
try:
from python_app.hardware_full.librevna_multi_device_driver.controller import MultiDeviceVnaController
self._controller = MultiDeviceVnaController(
master_serial_number=self.master_serial,
slave_serial_numbers=self.slave_serials,
force_external_reference=self.force_external_reference,
)
except Exception:
if self.backend_mode == "native":
raise
self._using_mock_backend = True
self._controller = None
def close(self) -> None:
"""Close native device transports."""
if self._controller is not None:
self._controller.close()
self._controller = None
def recover(self) -> None:
"""Reopen native device transports after a failed acquisition."""
if self._using_mock_backend:
return
self.close()
time.sleep(0.25)
self.open()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store sweep settings for subsequent full-matrix acquisitions."""
self._sweep_configuration = SweepConfiguration(
start_hz=int(round(float(sweep.start_hz))),
stop_hz=int(round(float(sweep.stop_hz))),
points=int(sweep.points),
if_bandwidth=int(round(float(sweep.if_bandwidth_hz))),
power_dbm=float(sweep.power_dbm),
)
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire one complete virtual 2x4 matrix in canonical combo order."""
if self._sweep_configuration is None:
raise RuntimeError("Multi-device service is not configured")
capture_start_ns = time.monotonic_ns()
if self._using_mock_backend:
collection = self._acquire_mock_collection(collection_id, capture_start_ns)
else:
collection = self._acquire_native_collection_with_recovery(collection_id, capture_start_ns)
collection.capture_end_ns = time.monotonic_ns()
return collection
def _acquire_native_collection_with_recovery(
self,
collection_id: int,
capture_start_ns: int,
) -> SweepCollection:
last_error: Exception | None = None
for attempt_index in range(self.recovery_attempts + 1):
try:
return self._acquire_native_collection(collection_id, capture_start_ns)
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt_index >= self.recovery_attempts:
break
logger.warning(
"multi-device acquisition failed, reconnecting devices (%d/%d): %s",
attempt_index + 1,
self.recovery_attempts,
exc,
exc_info=True,
)
self.recover()
assert last_error is not None
raise last_error
def _acquire_native_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
if self._controller is None:
raise RuntimeError("Multi-device controller is not open")
assert self._sweep_configuration is not None
self._controller.configure_continuous_sweep(self._sweep_configuration)
result = self._controller.collect_running_sweep_cycles(
1,
datapoint_timeout_seconds=LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS,
)
normalized_s_parameters = {
str(name).lower(): np.asarray(values, dtype=np.complex64)
for name, values in result.s_parameters.items()
}
frequencies = np.asarray(result.frequencies_hz, dtype=np.float32)
traces: list[TraceData] = []
for output_pos in range(RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS):
reflection = self._required_s_parameter(
normalized_s_parameters,
"s11" if output_pos == 0 else "s22",
)
for input_pos, s_parameter_name in enumerate(_INPUT_S_PARAMETERS_BY_OUTPUT[output_pos]):
traces.append(
TraceData(
combo=ComboKey(input_pos=input_pos, output_pos=output_pos),
frequency_hz=frequencies,
s11=reflection,
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name),
)
)
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
)
def _acquire_mock_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
assert self._sweep_configuration is not None
points = int(self._sweep_configuration.points)
frequencies = np.linspace(
self._sweep_configuration.start_hz,
self._sweep_configuration.stop_hz,
points,
dtype=np.float32,
)
base_phase = 2.0 * math.pi * np.linspace(0.0, 1.0, points, dtype=np.float32) + self._mock_phase
traces: list[TraceData] = []
for output_pos in range(RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS):
reflected_phase = base_phase * (0.55 + 0.05 * output_pos) + 0.7 * (output_pos + 1)
s11 = (
(0.22 + 0.04 * output_pos) * np.cos(reflected_phase)
+ 1j * (0.22 + 0.04 * output_pos) * np.sin(reflected_phase)
).astype(np.complex64)
for input_pos in range(RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS):
gain = 0.45 + 0.08 * input_pos + 0.03 * output_pos
phase = base_phase * (1.0 + 0.03 * input_pos) + (0.4 * input_pos + 0.9 * output_pos)
s21 = (gain * np.cos(phase) + 1j * gain * np.sin(phase)).astype(np.complex64)
traces.append(
TraceData(
combo=ComboKey(input_pos=input_pos, output_pos=output_pos),
frequency_hz=frequencies,
s11=s11,
s21=s21,
)
)
self._mock_phase += 0.05
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
)
@staticmethod
def _required_s_parameter(s_parameters: dict[str, np.ndarray], name: str) -> np.ndarray:
values = s_parameters.get(name)
if values is None:
raise RuntimeError(f"Multi-device sweep is missing required {name.upper()} trace")
return values
+32
View File
@@ -53,6 +53,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
run_payload.get("locator_server", run_payload.get("locator")), run_payload.get("locator_server", run_payload.get("locator")),
"run.locator_server", "run.locator_server",
) )
multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device")
model.radar.model = str(radar_payload.get("model", model.radar.model)) model.radar.model = str(radar_payload.get("model", model.radar.model))
model.radar.serial = str(radar_payload.get("serial", model.radar.serial)) model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
@@ -66,9 +67,34 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
sweep_payload.get("if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz) sweep_payload.get("if_bandwidth_hz", model.radar.sweep.if_bandwidth_hz)
) )
model.radar.sweep.power_dbm = float(sweep_payload.get("stimulus_power_dbm", model.radar.sweep.power_dbm)) model.radar.sweep.power_dbm = float(sweep_payload.get("stimulus_power_dbm", model.radar.sweep.power_dbm))
slave_serials_payload = multi_device_payload.get(
"slave_serials",
multi_device_payload.get("slave_serial_numbers", model.radar.multi_device.slave_serials),
)
if isinstance(slave_serials_payload, list):
model.radar.multi_device.slave_serials = [str(value).strip() for value in slave_serials_payload if str(value).strip()]
elif isinstance(slave_serials_payload, str):
model.radar.multi_device.slave_serials = [
value.strip()
for value in slave_serials_payload.split(",")
if value.strip()
]
model.radar.multi_device.force_external_reference = bool(
multi_device_payload.get(
"force_external_reference",
model.radar.multi_device.force_external_reference,
)
)
model.radar.multi_device.recovery_attempts = int(
multi_device_payload.get(
"recovery_attempts",
model.radar.multi_device.recovery_attempts,
)
)
load_switch_payload(port1_payload, model.output_switch) load_switch_payload(port1_payload, model.output_switch)
load_switch_payload(port2_payload, model.input_switch) load_switch_payload(port2_payload, model.input_switch)
model.apply_device_model_constraints()
model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms)) model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms))
model.runtime.idle_sleep_ms = int(run_payload.get("idle_sleep_ms", model.runtime.idle_sleep_ms)) model.runtime.idle_sleep_ms = int(run_payload.get("idle_sleep_ms", model.runtime.idle_sleep_ms))
@@ -177,6 +203,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
x_m=float(entry_payload.get("x_m", 0.0)), x_m=float(entry_payload.get("x_m", 0.0)),
) )
) )
model.apply_device_model_constraints()
validate_gpr_model( validate_gpr_model(
model.gpr, model.gpr,
input_switch_positions=model.input_switch.positions, input_switch_positions=model.input_switch.positions,
@@ -214,6 +241,11 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"serial": model.radar.serial, "serial": model.radar.serial,
"driver_mode": model.radar.driver_mode, "driver_mode": model.radar.driver_mode,
"mock_signal_hz": model.radar.mock_signal_hz, "mock_signal_hz": model.radar.mock_signal_hz,
"multi_device": {
"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,
},
"sweep": { "sweep": {
"start_hz": model.radar.sweep.start_hz, "start_hz": model.radar.sweep.start_hz,
"stop_hz": model.radar.sweep.stop_hz, "stop_hz": model.radar.sweep.stop_hz,
+2
View File
@@ -10,6 +10,7 @@ from python_app.models.run_config_schema import (
PreprocessAssetModel, PreprocessAssetModel,
PreprocessNotchModel, PreprocessNotchModel,
PreprocessModel, PreprocessModel,
RadarMultiDeviceModel,
RadarModel, RadarModel,
RadarSweepModel, RadarSweepModel,
RingEndpointModel, RingEndpointModel,
@@ -36,6 +37,7 @@ __all__ = [
"PreprocessAssetModel", "PreprocessAssetModel",
"PreprocessNotchModel", "PreprocessNotchModel",
"PreprocessModel", "PreprocessModel",
"RadarMultiDeviceModel",
"RadarModel", "RadarModel",
"RadarSweepModel", "RadarSweepModel",
"RingEndpointModel", "RingEndpointModel",
+53 -1
View File
@@ -28,15 +28,25 @@ class RadarSweepModel:
power_dbm: float = -30.0 power_dbm: float = -30.0
@dataclass(slots=True)
class RadarMultiDeviceModel:
"""Multi-device LibreVNA topology settings."""
slave_serials: list[str] = field(default_factory=list)
force_external_reference: bool = True
recovery_attempts: int = 3
@dataclass(slots=True) @dataclass(slots=True)
class RadarModel: class RadarModel:
"""Radar section of run configuration.""" """Radar section of run configuration."""
model: str = "" model: str = "librevna"
serial: str = "" serial: str = ""
driver_mode: str = "mock" driver_mode: str = "mock"
mock_signal_hz: float = 1_000_000.0 mock_signal_hz: float = 1_000_000.0
sweep: RadarSweepModel = field(default_factory=RadarSweepModel) sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
@dataclass(slots=True) @dataclass(slots=True)
@@ -190,6 +200,11 @@ class RunConfigModel:
gpr: GprModel = field(default_factory=GprModel) gpr: GprModel = field(default_factory=GprModel)
combos: list[ComboModel] = field(default_factory=list) combos: list[ComboModel] = field(default_factory=list)
LIBREVNA_MODEL = "librevna"
LIBREVNA_MULTI_MODEL = "librevna_multi"
MULTI_DEVICE_INPUT_POSITIONS = 4
MULTI_DEVICE_OUTPUT_POSITIONS = 2
@staticmethod @staticmethod
def build_full_combos(input_positions: int, output_positions: int) -> list[ComboModel]: def build_full_combos(input_positions: int, output_positions: int) -> list[ComboModel]:
"""Build full Cartesian product of input/output switch positions.""" """Build full Cartesian product of input/output switch positions."""
@@ -199,8 +214,45 @@ class RunConfigModel:
for input_pos in range(input_positions) for input_pos in range(input_positions)
] ]
@classmethod
def build_multi_device_virtual_combos(cls) -> list[ComboModel]:
"""Build fixed virtual combo matrix for one master and two slave devices."""
return cls.build_full_combos(
cls.MULTI_DEVICE_INPUT_POSITIONS,
cls.MULTI_DEVICE_OUTPUT_POSITIONS,
)
@property
def is_multi_device(self) -> bool:
"""Return whether this config targets synchronized multi-device acquisition."""
return self.radar.model == self.LIBREVNA_MULTI_MODEL
def apply_device_model_constraints(self) -> None:
"""Apply only required wire-format constraints for the selected device model."""
if not self.is_multi_device:
return
self.radar.model = self.LIBREVNA_MULTI_MODEL
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.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.default_position = 0
self.combos = self.build_multi_device_virtual_combos()
def ensure_combos(self) -> None: def ensure_combos(self) -> None:
"""Populate combos with full matrix when no explicit run combos are set.""" """Populate combos with full matrix when no explicit run combos are set."""
if self.is_multi_device:
self.apply_device_model_constraints()
return
if self.combos: if self.combos:
return return
self.combos = self.build_full_combos(self.input_switch.positions, self.output_switch.positions) self.combos = self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
+32 -5
View File
@@ -3,9 +3,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json
from pathlib import Path from pathlib import Path
import shlex import shlex
import subprocess import subprocess
import sys
import time import time
from typing import Iterable from typing import Iterable
from typing import Sequence from typing import Sequence
@@ -83,6 +85,7 @@ class ProcessSupervisor:
if self.is_running(): if self.is_running():
raise RuntimeError("Acquisition processes are already running") raise RuntimeError("Acquisition processes are already running")
acquisition_command = self._acquisition_command(config_path)
command_specs = { command_specs = {
"data_processor": [ "data_processor": [
str(self._project_root / "build/bin/data_processor"), str(self._project_root / "build/bin/data_processor"),
@@ -94,11 +97,7 @@ class ProcessSupervisor:
"--config", "--config",
str(config_path), str(config_path),
], ],
"sweep_orchestrator": [ "sweep_orchestrator": acquisition_command,
str(self._project_root / "build/bin/sweep_orchestrator"),
"--config",
str(config_path),
],
} }
processor_was_running = self.is_processor_running() processor_was_running = self.is_processor_running()
required_processes: list[str] = ["data_preprocessor"] required_processes: list[str] = ["data_preprocessor"]
@@ -168,6 +167,34 @@ class ProcessSupervisor:
handle=handle, handle=handle,
) )
def _acquisition_command(self, config_path: Path) -> list[str]:
"""Return acquisition producer command selected by radar.model."""
radar_model = self._read_radar_model(config_path)
if radar_model == "librevna_multi":
return [
sys.executable,
"-m",
"python_app.scripts.multi_device_raw_producer",
"--config",
str(config_path),
]
return [
str(self._project_root / "build/bin/sweep_orchestrator"),
"--config",
str(config_path),
]
@staticmethod
def _read_radar_model(config_path: Path) -> str:
"""Read `radar.model` cheaply without constructing the full config model."""
payload = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return "librevna"
radar_payload = payload.get("radar")
if not isinstance(radar_payload, dict):
return "librevna"
return str(radar_payload.get("model", "librevna"))
def _stop_processes(self, names: Iterable[str]) -> None: def _stop_processes(self, names: Iterable[str]) -> None:
"""Gracefully terminate processes, then force-kill on timeout.""" """Gracefully terminate processes, then force-kill on timeout."""
ordered_names = list(names) ordered_names = list(names)
+2
View File
@@ -9,6 +9,7 @@ from python_app.orchestration.shm.decoder import (
decode_trace_collection, decode_trace_collection,
) )
from python_app.orchestration.shm.ring_reader import ShmRingReader from python_app.orchestration.shm.ring_reader import ShmRingReader
from python_app.orchestration.shm.ring_writer import ShmRingWriter
__all__ = [ __all__ = [
"ByteCursor", "ByteCursor",
@@ -16,6 +17,7 @@ __all__ = [
"RAW_MAGIC", "RAW_MAGIC",
"RESULT_MAGIC", "RESULT_MAGIC",
"ShmRingReader", "ShmRingReader",
"ShmRingWriter",
"decode_result_collection", "decode_result_collection",
"decode_trace_collection", "decode_trace_collection",
] ]
+119
View File
@@ -0,0 +1,119 @@
"""POSIX shared-memory ring writer compatible with the C++ IPC ring."""
from __future__ import annotations
import mmap
import os
from pathlib import Path
import struct
from typing import Final
_HEADER_SIZE: Final[int] = 64
_SLOT_HEADER_SIZE: Final[int] = 16
_MAGIC: Final[bytes] = b"RDRRING2"
_VERSION: Final[int] = 1
class ShmRingWriter:
"""Write binary payloads into the shared-memory ring used by C++ workers."""
def __init__(self, ring_name: str, capacity: int, slot_size_bytes: int) -> None:
"""Open or create a POSIX SHM ring by name."""
if not ring_name.startswith("/"):
raise ValueError("ring_name must start with '/'")
if capacity <= 0:
raise ValueError("capacity must be > 0")
if slot_size_bytes <= 0:
raise ValueError("slot_size_bytes must be > 0")
self._ring_name = ring_name
self._capacity = int(capacity)
self._slot_size_bytes = int(slot_size_bytes)
self._mapped_size = _HEADER_SIZE + self._capacity * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
self._path = Path("/dev/shm") / ring_name[1:]
created = not self._path.exists()
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660)
self._file = os.fdopen(fd, "r+b", buffering=0)
if created or self._path.stat().st_size != self._mapped_size:
self._file.truncate(self._mapped_size)
created = True
self._mmap = mmap.mmap(self._file.fileno(), self._mapped_size)
if created:
self._initialize_header()
else:
self._validate_header()
def close(self) -> None:
"""Close mmap and file handle."""
self._mmap.close()
self._file.close()
def push(self, payload: bytes) -> bool:
"""Push one payload with overwrite-oldest semantics on overflow."""
if len(payload) > self._slot_size_bytes:
return False
write_seq = self._read_u64(24)
read_seq = self._read_u64(32)
if max(0, write_seq - read_seq) >= self._capacity:
self._write_u64(32, read_seq + 1)
dropped = self._read_u64(40)
self._write_u64(40, dropped + 1)
index = write_seq % self._capacity
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
payload_offset = slot_offset + _SLOT_HEADER_SIZE
self._write_u32(slot_offset, len(payload))
self._write_u32(slot_offset + 4, 0)
self._mmap[payload_offset : payload_offset + len(payload)] = payload
self._write_u64(slot_offset + 8, write_seq + 1)
self._write_u64(24, write_seq + 1)
return True
@property
def ring_name(self) -> str:
"""Return the POSIX SHM ring name."""
return self._ring_name
@property
def slot_size_bytes(self) -> int:
"""Return maximum payload size per slot."""
return self._slot_size_bytes
def _initialize_header(self) -> None:
self._mmap[:] = b"\x00" * self._mapped_size
self._mmap[:8] = _MAGIC
self._write_u32(8, _VERSION)
self._write_u32(12, self._capacity)
self._write_u32(16, self._slot_size_bytes)
self._write_u32(20, 0)
self._write_u64(24, 0)
self._write_u64(32, 0)
self._write_u64(40, 0)
def _validate_header(self) -> None:
magic = self._mmap[:8]
version = self._read_u32(8)
capacity = self._read_u32(12)
slot_size_bytes = self._read_u32(16)
if magic != _MAGIC:
raise RuntimeError(f"Shared memory ring magic mismatch for {self._ring_name}")
if version != _VERSION:
raise RuntimeError(f"Shared memory ring version mismatch for {self._ring_name}")
if capacity != self._capacity or slot_size_bytes != self._slot_size_bytes:
raise RuntimeError(f"Shared memory ring geometry mismatch for {self._ring_name}")
def _read_u32(self, offset: int) -> int:
return struct.unpack_from("<I", self._mmap, offset)[0]
def _read_u64(self, offset: int) -> int:
return struct.unpack_from("<Q", self._mmap, offset)[0]
def _write_u32(self, offset: int, value: int) -> None:
struct.pack_into("<I", self._mmap, offset, int(value))
def _write_u64(self, offset: int, value: int) -> None:
struct.pack_into("<Q", self._mmap, offset, int(value))
+2
View File
@@ -9,6 +9,7 @@ from python_app.orchestration.shm.decoder import (
decode_trace_collection, decode_trace_collection,
) )
from python_app.orchestration.shm.ring_reader import ShmRingReader from python_app.orchestration.shm.ring_reader import ShmRingReader
from python_app.orchestration.shm.ring_writer import ShmRingWriter
__all__ = [ __all__ = [
"ByteCursor", "ByteCursor",
@@ -16,6 +17,7 @@ __all__ = [
"RAW_MAGIC", "RAW_MAGIC",
"RESULT_MAGIC", "RESULT_MAGIC",
"ShmRingReader", "ShmRingReader",
"ShmRingWriter",
"decode_result_collection", "decode_result_collection",
"decode_trace_collection", "decode_trace_collection",
] ]
+5
View File
@@ -114,6 +114,11 @@ def main() -> int:
sweep_points=config.radar.sweep.points, sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz, ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm, power_dbm=config.radar.sweep.power_dbm,
extra_serials=(
config.radar.multi_device.slave_serials
if config.is_multi_device
else None
),
) )
s21_calibration_set = build_synthetic_collection(config, value_scale=1.0, s11_scale=0.15, s11_phase_offset=0.4) s21_calibration_set = build_synthetic_collection(config, value_scale=1.0, s11_scale=0.15, s11_phase_offset=0.4)
@@ -0,0 +1,95 @@
"""Raw acquisition producer for LibreVNA multi-device mode."""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
import signal
import threading
import time
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
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
logger = logging.getLogger(__name__)
def main() -> int:
"""Run producer process until config or signal requests exit."""
parser = argparse.ArgumentParser(description="Publish LibreVNA multi-device raw sweeps to SHM rings")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
stop_requested = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_requested.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
config = RunConfigModel.load_from_path(args.config)
config.apply_device_model_constraints()
if not config.is_multi_device:
raise RuntimeError("multi_device_raw_producer requires radar.model='librevna_multi'")
raw_writer = ShmRingWriter(
config.rings.raw.name,
config.rings.raw.capacity,
config.rings.raw.slot_size_bytes,
)
raw_tap_writer = ShmRingWriter(
config.rings.raw_tap.name,
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes,
)
radar = 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,
)
try:
radar.open()
radar.configure(config.radar.sweep)
collection_id = 1
while not stop_requested.is_set():
collection_start = time.monotonic()
collection = radar.acquire_collection(collection_id=collection_id)
payload = serialize_trace_collection(collection, RAW_MAGIC)
if not raw_writer.push(payload):
raise RuntimeError(
f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}"
)
if not raw_tap_writer.push(payload):
raise RuntimeError(
f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}"
)
if not config.runtime.continuous:
break
collection_duration_s = time.monotonic() - collection_start
if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0:
logger.info(
"multi-device collection %d acquired in %.3f s",
collection_id,
collection_duration_s,
)
collection_id += 1
finally:
radar.close()
raw_tap_writer.close()
raw_writer.close()
logger.info("multi-device raw producer stopped")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from collections.abc import Sequence
def radar_key_from_config( def radar_key_from_config(
@@ -13,9 +14,13 @@ def radar_key_from_config(
sweep_points: int, sweep_points: int,
ifbw_hz: float, ifbw_hz: float,
power_dbm: float, power_dbm: float,
extra_serials: Sequence[str] | None = None,
) -> str: ) -> str:
"""Build deterministic key for calibration/reference set lookup.""" """Build deterministic key for calibration/reference set lookup."""
serial_part = serial or "no_serial" serial_parts = [serial or "no_serial"]
if extra_serials:
serial_parts.extend(str(value).strip() or "no_serial" for value in extra_serials)
serial_part = "_".join(sanitize_path_component(value) for value in serial_parts)
start_token = _format_float_for_key(sweep_start_hz) start_token = _format_float_for_key(sweep_start_hz)
stop_token = _format_float_for_key(sweep_stop_hz) stop_token = _format_float_for_key(sweep_stop_hz)
ifbw_token = _format_float_for_key(ifbw_hz) ifbw_token = _format_float_for_key(ifbw_hz)
@@ -17,6 +17,13 @@ def capture_calibration_set(
store: NpzStore, store: NpzStore,
) -> tuple[str, SweepCollection]: ) -> tuple[str, SweepCollection]:
"""Capture all switch combinations and persist them as calibration set.""" """Capture all switch combinations and persist them as calibration set."""
if config.is_multi_device:
raise RuntimeError(
"LibreVNA multi-device S21 through calibration is not supported by this one-shot full-set helper. "
"Use the sequential preprocess capture flow so each virtual combo can be connected through "
"and captured explicitly."
)
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
radar = LibreVnaService(serial=config.radar.serial or None) radar = LibreVnaService(serial=config.radar.serial or None)
@@ -9,12 +9,17 @@ import time
import numpy as np import numpy as np
from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
from python_app.hardware_full.switch_service import SwitchService from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore from python_app.storage.npz_store import NpzStore
from python_app.workflows.radar_config_variants import RadarConfigVariant from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import SequentialCaptureState from python_app.workflows.sequential_capture_workflow import (
MULTI_DEVICE_MANUAL_CAPTURE_KINDS,
SequentialCaptureState,
select_trace_for_combo,
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -63,10 +68,16 @@ class MultiRadarSequentialCaptureSession:
self._kind = kind self._kind = kind
self._set_name = set_name self._set_name = set_name
self._radar_variants = list(radar_variants) self._radar_variants = list(radar_variants)
self._combos = RunConfigModel.build_full_combos( self._is_multi_device = base_config.is_multi_device
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._combos = (
RunConfigModel.build_multi_device_virtual_combos()
if self._is_multi_device
else RunConfigModel.build_full_combos(
base_config.input_switch.positions, base_config.input_switch.positions,
base_config.output_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")
@@ -78,6 +89,17 @@ class MultiRadarSequentialCaptureSession:
self._next_index = 0 self._next_index = 0
self._opened = False self._opened = False
if self._is_multi_device:
self._radar = MultiDeviceLibreVnaService(
master_serial=base_config.radar.serial,
slave_serials=list(base_config.radar.multi_device.slave_serials),
force_external_reference=base_config.radar.multi_device.force_external_reference,
recovery_attempts=base_config.radar.multi_device.recovery_attempts,
backend_mode=base_config.radar.driver_mode,
)
self._input_switch = None
self._output_switch = None
else:
self._radar = LibreVnaService(serial=base_config.radar.serial or None) self._radar = LibreVnaService(serial=base_config.radar.serial or None)
self._input_switch = SwitchService( self._input_switch = SwitchService(
name=base_config.input_switch.name, name=base_config.input_switch.name,
@@ -118,7 +140,9 @@ class MultiRadarSequentialCaptureSession:
try: try:
self._radar.open() self._radar.open()
self._radar.configure(self._base_config.radar.sweep) self._radar.configure(self._base_config.radar.sweep)
if self._input_switch is not None:
self._input_switch.open() self._input_switch.open()
if self._output_switch is not None:
self._output_switch.open() self._output_switch.open()
except Exception: except Exception:
self.close() self.close()
@@ -127,8 +151,10 @@ class MultiRadarSequentialCaptureSession:
def close(self) -> None: def close(self) -> None:
"""Close all opened hardware resources.""" """Close all opened hardware resources."""
with suppress(Exception): with suppress(Exception):
if self._output_switch is not None:
self._output_switch.close() self._output_switch.close()
with suppress(Exception): with suppress(Exception):
if self._input_switch is not None:
self._input_switch.close() self._input_switch.close()
with suppress(Exception): with suppress(Exception):
self._radar.close() self._radar.close()
@@ -140,7 +166,11 @@ class MultiRadarSequentialCaptureSession:
return SequentialCaptureState( return SequentialCaptureState(
kind=self._kind, kind=self._kind,
set_name=self._set_name, set_name=self._set_name,
captured_count=len(self._captured_batches), captured_count=(
self._next_index
if self._is_multi_device and not self._manual_multi_device_capture
else len(self._captured_batches)
),
total_count=len(self._combos), total_count=len(self._combos),
current_combo=current_combo, current_combo=current_combo,
can_undo=bool(self._captured_batches), can_undo=bool(self._captured_batches),
@@ -156,6 +186,39 @@ class MultiRadarSequentialCaptureSession:
if combo is None: if combo is None:
raise RuntimeError("Capture session is already complete") raise RuntimeError("Capture session is already complete")
if self._is_multi_device:
traces: list[TraceData] = []
variant_labels: list[str] = []
for variant in self._radar_variants:
self._radar.configure(variant.config.radar.sweep)
if self._base_config.runtime.settling_ms > 0:
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
collection = self._radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError(f"Multi-device variant {variant.display_name} returned no traces")
if self._manual_multi_device_capture:
trace = select_trace_for_combo(collection, combo)
self._traces_by_radar_key[variant.radar_key].append(trace)
traces.append(trace)
else:
self._traces_by_radar_key[variant.radar_key].extend(collection.traces)
traces.append(collection.traces[-1])
variant_labels.append(variant.display_name)
batch = MultiRadarCaptureBatch(
combo=combo,
traces=tuple(traces),
variant_labels=tuple(variant_labels),
)
self._captured_batches.append(batch)
if self._manual_multi_device_capture:
self._next_index += 1
else:
self._next_index = len(self._combos)
return batch
assert self._input_switch is not None
assert self._output_switch is not None
self._output_switch.switch_to(combo.output) self._output_switch.switch_to(combo.output)
self._input_switch.switch_to(combo.input) self._input_switch.switch_to(combo.input)
if self._base_config.runtime.settling_ms > 0: if self._base_config.runtime.settling_ms > 0:
@@ -194,6 +257,17 @@ class MultiRadarSequentialCaptureSession:
if not self._captured_batches or self._next_index <= 0: if not self._captured_batches or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo") raise RuntimeError("No captured combo is available to undo")
if self._is_multi_device and not self._manual_multi_device_capture:
removed_batch = self._captured_batches[-1]
for variant in self._radar_variants:
traces = self._traces_by_radar_key[variant.radar_key]
if len(traces) < len(self._combos):
raise RuntimeError("Capture session state is inconsistent; missing multi-device traces")
del traces[-len(self._combos) :]
self._next_index = 0
self._captured_batches.pop()
return removed_batch
expected_combo = self._combos[self._next_index - 1] expected_combo = self._combos[self._next_index - 1]
removed_batch = self._captured_batches[-1] removed_batch = self._captured_batches[-1]
if ( if (
@@ -145,6 +145,11 @@ def _load_radar_config_variant(path: Path, *, base_config: RunConfigModel) -> Ra
sweep_points=config.radar.sweep.points, sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz, ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm, power_dbm=config.radar.sweep.power_dbm,
extra_serials=(
config.radar.multi_device.slave_serials
if config.is_multi_device
else None
),
) )
return RadarConfigVariant( return RadarConfigVariant(
source_path=path, source_path=path,
@@ -5,6 +5,7 @@ from __future__ import annotations
import time import time
from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
from python_app.hardware_full.switch_service import SwitchService from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RunConfigModel from python_app.models.run_config_model import RunConfigModel
@@ -17,6 +18,34 @@ def capture_reference_set(
store: NpzStore, store: NpzStore,
) -> tuple[str, SweepCollection]: ) -> tuple[str, SweepCollection]:
"""Capture all switch combinations and persist them as reference set.""" """Capture all switch combinations and persist them as reference set."""
if config.is_multi_device:
radar = 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,
)
try:
radar.open()
radar.configure(config.radar.sweep)
collection = radar.acquire_collection(collection_id=1)
finally:
radar.close()
radar_key = radar_key_from_config(
model_name=config.radar.model,
serial=config.radar.serial,
sweep_start_hz=config.radar.sweep.start_hz,
sweep_stop_hz=config.radar.sweep.stop_hz,
sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm,
extra_serials=config.radar.multi_device.slave_serials,
)
store.save_set("s21_reference", radar_key, set_name, collection)
return radar_key, collection
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
radar = LibreVnaService(serial=config.radar.serial or None) radar = LibreVnaService(serial=config.radar.serial or None)
@@ -9,11 +9,14 @@ import time
import numpy as np import numpy as np
from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
from python_app.hardware_full.switch_service import SwitchService from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore, radar_key_from_config from python_app.storage.npz_store import NpzStore, radar_key_from_config
MULTI_DEVICE_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"})
@dataclass(slots=True) @dataclass(slots=True)
class SequentialCaptureState: class SequentialCaptureState:
@@ -42,7 +45,13 @@ class SequentialCaptureSession:
self._config = config self._config = config
self._kind = kind self._kind = kind
self._set_name = set_name self._set_name = set_name
self._combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions) self._is_multi_device = config.is_multi_device
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
self._combos = (
RunConfigModel.build_multi_device_virtual_combos()
if self._is_multi_device
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")
@@ -50,6 +59,17 @@ class SequentialCaptureSession:
self._next_index = 0 self._next_index = 0
self._opened = False self._opened = False
if self._is_multi_device:
self._radar = 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,
)
self._input_switch = None
self._output_switch = None
else:
self._radar = LibreVnaService(serial=config.radar.serial or None) self._radar = LibreVnaService(serial=config.radar.serial or None)
self._input_switch = SwitchService( self._input_switch = SwitchService(
name=config.input_switch.name, name=config.input_switch.name,
@@ -90,7 +110,9 @@ class SequentialCaptureSession:
try: try:
self._radar.open() self._radar.open()
self._radar.configure(self._config.radar.sweep) self._radar.configure(self._config.radar.sweep)
if self._input_switch is not None:
self._input_switch.open() self._input_switch.open()
if self._output_switch is not None:
self._output_switch.open() self._output_switch.open()
except Exception: except Exception:
self.close() self.close()
@@ -99,8 +121,10 @@ class SequentialCaptureSession:
def close(self) -> None: def close(self) -> None:
"""Close all opened hardware resources.""" """Close all opened hardware resources."""
with suppress(Exception): with suppress(Exception):
if self._output_switch is not None:
self._output_switch.close() self._output_switch.close()
with suppress(Exception): with suppress(Exception):
if self._input_switch is not None:
self._input_switch.close() self._input_switch.close()
with suppress(Exception): with suppress(Exception):
self._radar.close() self._radar.close()
@@ -127,6 +151,22 @@ class SequentialCaptureSession:
if combo is None: if combo is None:
raise RuntimeError("Capture session is already complete") raise RuntimeError("Capture session is already complete")
if self._is_multi_device:
collection = self._radar.acquire_collection(collection_id=1)
if self._manual_multi_device_capture:
trace = select_trace_for_combo(collection, combo)
self._traces.append(trace)
self._next_index += 1
return trace
self._traces.extend(collection.traces)
self._next_index = len(self._combos)
if not collection.traces:
raise RuntimeError("Multi-device capture returned no traces")
return collection.traces[-1]
assert self._input_switch is not None
assert self._output_switch is not None
self._output_switch.switch_to(combo.output) self._output_switch.switch_to(combo.output)
self._input_switch.switch_to(combo.input) self._input_switch.switch_to(combo.input)
if self._config.runtime.settling_ms > 0: if self._config.runtime.settling_ms > 0:
@@ -150,6 +190,14 @@ class SequentialCaptureSession:
if not self._traces or self._next_index <= 0: if not self._traces or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo") raise RuntimeError("No captured combo is available to undo")
if self._is_multi_device and not self._manual_multi_device_capture:
if len(self._traces) != len(self._combos):
raise RuntimeError("Capture session state is inconsistent; multi-device trace matrix is incomplete")
removed_trace = self._traces[-1]
self._traces.clear()
self._next_index = 0
return removed_trace
expected_combo = self._combos[self._next_index - 1] expected_combo = self._combos[self._next_index - 1]
removed_trace = self._traces[-1] removed_trace = self._traces[-1]
if ( if (
@@ -193,6 +241,11 @@ class SequentialCaptureSession:
sweep_points=self._config.radar.sweep.points, sweep_points=self._config.radar.sweep.points,
ifbw_hz=self._config.radar.sweep.if_bandwidth_hz, ifbw_hz=self._config.radar.sweep.if_bandwidth_hz,
power_dbm=self._config.radar.sweep.power_dbm, power_dbm=self._config.radar.sweep.power_dbm,
extra_serials=(
self._config.radar.multi_device.slave_serials
if self._config.is_multi_device
else None
),
) )
store.save_set(self._kind, radar_key, self._set_name, collection) store.save_set(self._kind, radar_key, self._set_name, collection)
return radar_key, collection return radar_key, collection
@@ -202,3 +255,14 @@ class SequentialCaptureSession:
if self._next_index >= len(self._combos): if self._next_index >= len(self._combos):
return None return None
return self._combos[self._next_index] return self._combos[self._next_index]
def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> TraceData:
"""Return the trace matching a virtual combo from a full multi-device capture."""
for trace in collection.traces:
if (
int(trace.combo.input_pos) == int(combo.input)
and int(trace.combo.output_pos) == int(combo.output)
):
return trace
raise RuntimeError(f"Multi-device capture is missing trace for input={combo.input}, output={combo.output}")
+15 -12
View File
@@ -1,9 +1,17 @@
{ {
"radar": { "radar": {
"model": "librevna", "model": "librevna_multi",
"serial": "", "serial": "207730885532",
"driver_mode": "native", "driver_mode": "native",
"mock_signal_hz": 5000000.0, "mock_signal_hz": 5000000.0,
"multi_device": {
"slave_serials": [
"20A1307D5532",
"2072306C5532"
],
"force_external_reference": true,
"recovery_attempts": 3
},
"sweep": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
@@ -57,10 +65,10 @@
{"input": 1, "output": 0}, {"input": 1, "output": 0},
{"input": 2, "output": 0}, {"input": 2, "output": 0},
{"input": 3, "output": 0}, {"input": 3, "output": 0},
{"input": 0, "output": 3}, {"input": 0, "output": 1},
{"input": 1, "output": 3}, {"input": 1, "output": 1},
{"input": 2, "output": 3}, {"input": 2, "output": 1},
{"input": 3, "output": 3} {"input": 3, "output": 1}
] ]
}, },
"preprocess": { "preprocess": {
@@ -97,11 +105,6 @@
"notch": { "notch": {
"enabled": true, "enabled": true,
"bands_hz": [ "bands_hz": [
[790000000.0, 850000000.0],
[1790000000.0, 1890000000.0],
[1970000000.0, 2050000000.0],
[2080000000.0, 2560000000.0],
[2650000000.0, 2700000000.0]
], ],
"taper_width_hz": 40000000.0, "taper_width_hz": 40000000.0,
"taper_type": "cosine" "taper_type": "cosine"
@@ -116,7 +119,7 @@
"x_m": 0.905 "x_m": 0.905
}, },
{ {
"output_pos": 3, "output_pos": 1,
"x_m": -0.905 "x_m": -0.905
} }
], ],