added multidevice support
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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,19 +307,24 @@ 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 self._single_combo_select_button.isChecked():
|
if config.is_multi_device:
|
||||||
config.combos = [
|
if len(config.radar.multi_device.slave_serials) != 2:
|
||||||
ComboModel(
|
raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
|
||||||
input=int(self._single_combo_input.text().strip()),
|
config.apply_device_model_constraints()
|
||||||
output=int(self._single_combo_output.text().strip()),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
combo_text = self._combos_text.text()
|
if self._single_combo_select_button.isChecked():
|
||||||
config.combos = parse_combos_from_text(combo_text)
|
config.combos = [
|
||||||
config.ensure_combos()
|
ComboModel(
|
||||||
if self._switches_are_effectively_static(config):
|
input=int(self._single_combo_input.text().strip()),
|
||||||
config.combos = [ComboModel(input=0, output=0)]
|
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:
|
for key in PREPROCESS_ASSET_KEYS:
|
||||||
preprocess_asset_model(config, key).bundle_path = ""
|
preprocess_asset_model(config, key).bundle_path = ""
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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,9 +68,15 @@ 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
|
||||||
base_config.input_switch.positions,
|
self._manual_multi_device_capture = self._is_multi_device and kind in MULTI_DEVICE_MANUAL_CAPTURE_KINDS
|
||||||
base_config.output_switch.positions,
|
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.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,27 +89,38 @@ class MultiRadarSequentialCaptureSession:
|
|||||||
self._next_index = 0
|
self._next_index = 0
|
||||||
self._opened = False
|
self._opened = False
|
||||||
|
|
||||||
self._radar = LibreVnaService(serial=base_config.radar.serial or None)
|
if self._is_multi_device:
|
||||||
self._input_switch = SwitchService(
|
self._radar = MultiDeviceLibreVnaService(
|
||||||
name=base_config.input_switch.name,
|
master_serial=base_config.radar.serial,
|
||||||
positions=base_config.input_switch.positions,
|
slave_serials=list(base_config.radar.multi_device.slave_serials),
|
||||||
mode=base_config.input_switch.driver_mode,
|
force_external_reference=base_config.radar.multi_device.force_external_reference,
|
||||||
driver=base_config.input_switch.driver,
|
recovery_attempts=base_config.radar.multi_device.recovery_attempts,
|
||||||
gpio_chip=base_config.input_switch.gpio_chip,
|
backend_mode=base_config.radar.driver_mode,
|
||||||
pin_a=base_config.input_switch.pin_a,
|
)
|
||||||
pin_b=base_config.input_switch.pin_b,
|
self._input_switch = None
|
||||||
invert_logic=base_config.input_switch.invert_logic,
|
self._output_switch = None
|
||||||
)
|
else:
|
||||||
self._output_switch = SwitchService(
|
self._radar = LibreVnaService(serial=base_config.radar.serial or None)
|
||||||
name=base_config.output_switch.name,
|
self._input_switch = SwitchService(
|
||||||
positions=base_config.output_switch.positions,
|
name=base_config.input_switch.name,
|
||||||
mode=base_config.output_switch.driver_mode,
|
positions=base_config.input_switch.positions,
|
||||||
driver=base_config.output_switch.driver,
|
mode=base_config.input_switch.driver_mode,
|
||||||
gpio_chip=base_config.output_switch.gpio_chip,
|
driver=base_config.input_switch.driver,
|
||||||
pin_a=base_config.output_switch.pin_a,
|
gpio_chip=base_config.input_switch.gpio_chip,
|
||||||
pin_b=base_config.output_switch.pin_b,
|
pin_a=base_config.input_switch.pin_a,
|
||||||
invert_logic=base_config.output_switch.invert_logic,
|
pin_b=base_config.input_switch.pin_b,
|
||||||
)
|
invert_logic=base_config.input_switch.invert_logic,
|
||||||
|
)
|
||||||
|
self._output_switch = SwitchService(
|
||||||
|
name=base_config.output_switch.name,
|
||||||
|
positions=base_config.output_switch.positions,
|
||||||
|
mode=base_config.output_switch.driver_mode,
|
||||||
|
driver=base_config.output_switch.driver,
|
||||||
|
gpio_chip=base_config.output_switch.gpio_chip,
|
||||||
|
pin_a=base_config.output_switch.pin_a,
|
||||||
|
pin_b=base_config.output_switch.pin_b,
|
||||||
|
invert_logic=base_config.output_switch.invert_logic,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def kind(self) -> str:
|
def kind(self) -> str:
|
||||||
@@ -118,8 +140,10 @@ 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)
|
||||||
self._input_switch.open()
|
if self._input_switch is not None:
|
||||||
self._output_switch.open()
|
self._input_switch.open()
|
||||||
|
if self._output_switch is not None:
|
||||||
|
self._output_switch.open()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.close()
|
self.close()
|
||||||
raise
|
raise
|
||||||
@@ -127,9 +151,11 @@ 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):
|
||||||
self._output_switch.close()
|
if self._output_switch is not None:
|
||||||
|
self._output_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self._input_switch.close()
|
if self._input_switch is not None:
|
||||||
|
self._input_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self._radar.close()
|
self._radar.close()
|
||||||
self._opened = False
|
self._opened = False
|
||||||
@@ -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,27 +59,38 @@ class SequentialCaptureSession:
|
|||||||
self._next_index = 0
|
self._next_index = 0
|
||||||
self._opened = False
|
self._opened = False
|
||||||
|
|
||||||
self._radar = LibreVnaService(serial=config.radar.serial or None)
|
if self._is_multi_device:
|
||||||
self._input_switch = SwitchService(
|
self._radar = MultiDeviceLibreVnaService(
|
||||||
name=config.input_switch.name,
|
master_serial=config.radar.serial,
|
||||||
positions=config.input_switch.positions,
|
slave_serials=list(config.radar.multi_device.slave_serials),
|
||||||
mode=config.input_switch.driver_mode,
|
force_external_reference=config.radar.multi_device.force_external_reference,
|
||||||
driver=config.input_switch.driver,
|
recovery_attempts=config.radar.multi_device.recovery_attempts,
|
||||||
gpio_chip=config.input_switch.gpio_chip,
|
backend_mode=config.radar.driver_mode,
|
||||||
pin_a=config.input_switch.pin_a,
|
)
|
||||||
pin_b=config.input_switch.pin_b,
|
self._input_switch = None
|
||||||
invert_logic=config.input_switch.invert_logic,
|
self._output_switch = None
|
||||||
)
|
else:
|
||||||
self._output_switch = SwitchService(
|
self._radar = LibreVnaService(serial=config.radar.serial or None)
|
||||||
name=config.output_switch.name,
|
self._input_switch = SwitchService(
|
||||||
positions=config.output_switch.positions,
|
name=config.input_switch.name,
|
||||||
mode=config.output_switch.driver_mode,
|
positions=config.input_switch.positions,
|
||||||
driver=config.output_switch.driver,
|
mode=config.input_switch.driver_mode,
|
||||||
gpio_chip=config.output_switch.gpio_chip,
|
driver=config.input_switch.driver,
|
||||||
pin_a=config.output_switch.pin_a,
|
gpio_chip=config.input_switch.gpio_chip,
|
||||||
pin_b=config.output_switch.pin_b,
|
pin_a=config.input_switch.pin_a,
|
||||||
invert_logic=config.output_switch.invert_logic,
|
pin_b=config.input_switch.pin_b,
|
||||||
)
|
invert_logic=config.input_switch.invert_logic,
|
||||||
|
)
|
||||||
|
self._output_switch = SwitchService(
|
||||||
|
name=config.output_switch.name,
|
||||||
|
positions=config.output_switch.positions,
|
||||||
|
mode=config.output_switch.driver_mode,
|
||||||
|
driver=config.output_switch.driver,
|
||||||
|
gpio_chip=config.output_switch.gpio_chip,
|
||||||
|
pin_a=config.output_switch.pin_a,
|
||||||
|
pin_b=config.output_switch.pin_b,
|
||||||
|
invert_logic=config.output_switch.invert_logic,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def kind(self) -> str:
|
def kind(self) -> str:
|
||||||
@@ -90,8 +110,10 @@ class SequentialCaptureSession:
|
|||||||
try:
|
try:
|
||||||
self._radar.open()
|
self._radar.open()
|
||||||
self._radar.configure(self._config.radar.sweep)
|
self._radar.configure(self._config.radar.sweep)
|
||||||
self._input_switch.open()
|
if self._input_switch is not None:
|
||||||
self._output_switch.open()
|
self._input_switch.open()
|
||||||
|
if self._output_switch is not None:
|
||||||
|
self._output_switch.open()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.close()
|
self.close()
|
||||||
raise
|
raise
|
||||||
@@ -99,9 +121,11 @@ 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):
|
||||||
self._output_switch.close()
|
if self._output_switch is not None:
|
||||||
|
self._output_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self._input_switch.close()
|
if self._input_switch is not None:
|
||||||
|
self._input_switch.close()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self._radar.close()
|
self._radar.close()
|
||||||
self._opened = False
|
self._opened = False
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user