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
@@ -11,6 +11,7 @@ from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QFileDialog
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 (
VISIBLE_PREPROCESS_ASSET_KEYS,
preprocess_asset_model,
@@ -32,12 +33,36 @@ class AppWindowConfigProfileIOMixin:
def _set_combo_selection_mode(self, mode: str) -> None:
"""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"
self._run_combos_select_button.setChecked(text_selected)
self._single_combo_select_button.setChecked(not text_selected)
self._combos_text.setEnabled(text_selected)
self._single_combo_output.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:
"""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."""
config = profile.run_config.clone()
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 = {
key: str(preprocess_asset_model(config, key).set_name)
for key in VISIBLE_PREPROCESS_ASSET_KEYS
}
radio_widgets = (
self._serial_input,
self._radar_mode,
self._start_hz_input,
self._stop_hz_input,
self._points_input,
@@ -202,8 +226,6 @@ class AppWindowConfigProfileIOMixin:
for widget in radio_widgets:
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._stop_hz_input.setText(f"{config.radar.sweep.stop_hz:g}")
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_name_input.setText(str(gui_state.data_actions.save_name))
self._defaults_config = config
self._gui_defaults = gui_state
self._selected_preprocess_sets = selected_preprocess_sets
self._selected_preprocess_radar_key = self._radar_key(config)
@@ -8,16 +8,6 @@ from python_app.hardware_full.librevna_service import LibreVnaService
class AppWindowRadarLimitsMixin:
"""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:
"""Clamp processing frequency bounds after sweep start/stop edits."""
self._reset_preprocess_selection_after_radar_key_change()
@@ -26,7 +16,7 @@ class AppWindowRadarLimitsMixin:
def _refresh_radar_limits_from_device(self) -> bool:
"""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)
if not radar_service.driver_available:
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_ifbw_label.setText("IF BW Hz")
self._radar_power_label.setText("Stimulus Power dBm")
if self._radar_mode.currentText() == "native":
self._radar_limits_hint.setText("Device limits unavailable in native mode (device not connected).")
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.")
self._radar_limits_hint.setText("Device limits are not available.")
self._power_input.setToolTip("Stimulus power configured in the active profile.")
return False
min_freq_hz = float(limits["min_frequency_hz"])
@@ -82,6 +82,11 @@ class AppWindowConfigStateBuildersMixin:
@staticmethod
def _format_combos_text_from_config(config: RunConfigModel) -> str:
"""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)
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
if len(combos) == len(full_combos) and all(
@@ -215,8 +220,18 @@ class AppWindowConfigStateBuildersMixin:
"""Build GUI-only persistent state from current widget values."""
return GuiStateModel(
switches=GuiSwitchStateModel(
combo_mode="single" if self._single_combo_select_button.isChecked() else "text",
combos_text=self._combos_text.text().strip(),
combo_mode=(
"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_output=self._single_combo_output.text().strip(),
),
@@ -292,19 +307,24 @@ class AppWindowConfigStateBuildersMixin:
config.runtime.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
if self._single_combo_select_button.isChecked():
config.combos = [
ComboModel(
input=int(self._single_combo_input.text().strip()),
output=int(self._single_combo_output.text().strip()),
)
]
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:
combo_text = self._combos_text.text()
config.combos = parse_combos_from_text(combo_text)
config.ensure_combos()
if self._switches_are_effectively_static(config):
config.combos = [ComboModel(input=0, output=0)]
if self._single_combo_select_button.isChecked():
config.combos = [
ComboModel(
input=int(self._single_combo_input.text().strip()),
output=int(self._single_combo_output.text().strip()),
)
]
else:
combo_text = self._combos_text.text()
config.combos = parse_combos_from_text(combo_text)
config.ensure_combos()
if self._switches_are_effectively_static(config):
config.combos = [ComboModel(input=0, output=0)]
for key in PREPROCESS_ASSET_KEYS:
preprocess_asset_model(config, key).bundle_path = ""
@@ -331,23 +351,36 @@ class AppWindowConfigStateBuildersMixin:
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
if config.is_multi_device
else None
),
)
def _radar_key_from_ui(self) -> str:
"""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(
model_name=self._defaults_config.radar.model,
serial=self._serial_input.text().strip(),
model_name=model_name,
serial=self._defaults_config.radar.serial,
sweep_start_hz=float(self._start_hz_input.text().strip()),
sweep_stop_hz=float(self._stop_hz_input.text().strip()),
sweep_points=int(self._points_input.text().strip()),
ifbw_hz=float(self._ifbw_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
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
"""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
both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock"
return has_single_position or both_mock
@@ -153,9 +153,9 @@ class AppWindowPipelineMixin:
self._stop_run()
try:
if self._radar_mode.currentText() == "native":
self._refresh_radar_limits_from_device()
config = self._build_config()
if config.radar.driver_mode == "native":
self._refresh_radar_limits_from_device()
run_signature = self._build_run_history_signature(config)
if processor_only_running and self._processor_requires_restart(run_signature):
self._stop_all_processes()
@@ -185,6 +185,9 @@ class AppWindowPipelineMixin:
if config.radar.driver_mode != "native":
self._log("Radar pre-configuration skipped (mock mode)")
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)
if not radar_service.driver_available:
@@ -128,10 +128,13 @@ def rebuild_bscan_history_from_results(
def pick_bscan_display_key(
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
preferred_key: tuple[int, int] | None = None,
) -> tuple[int, int] | None:
"""Choose combo key to display when multiple histories are present."""
if not history_by_combo:
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()))
@@ -267,9 +270,31 @@ class AppWindowBscanPlotMixin:
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
"""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())
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 requested_key == display_key:
return display_key
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}"
@@ -286,6 +311,26 @@ class AppWindowBscanPlotMixin:
)
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:
"""Return lookup table for current B-scan 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)
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
exported_preview = "\n".join(str(path) for path in output_paths[:8])
if len(output_paths) > 8:
@@ -2,7 +2,7 @@
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
@@ -15,22 +15,12 @@ def build_radar_group(owner) -> QGroupBox:
layout.setSpacing(8)
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._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}")
owner._points_input = QLineEdit(str(defaults.sweep.points))
owner._ifbw_input = QLineEdit(f"{defaults.sweep.if_bandwidth_hz: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._serial_input.editingFinished.connect(owner._on_radar_identity_changed)
owner._radar_mode.currentTextChanged.connect(owner._on_radar_identity_changed)
owner._power_input.setToolTip("Stimulus power configured in the active profile.")
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._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_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")
layout.addWidget(
@@ -56,7 +46,6 @@ def build_radar_group(owner) -> QGroupBox:
(owner._radar_ifbw_label, owner._ifbw_input),
(owner._radar_power_label, owner._power_input),
],
split_index=3,
)
)
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._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))
return group