Add real switch support for multi-device matrix radar

MultiDeviceLibreVnaService only exposes the 2x4 virtual combo matrix on
its own USB transport. When a physical GPIO switch sits on the master
stimulus and/or slave receiver path, the effective matrix is wider than
that. SwitchedMatrixRadarService wraps the inner service and drives the
extra switch(es) between acquire_collection calls, widening the combo
matrix by the physical position counts (matrix_output_switch_positions /
matrix_input_switch_positions in RunConfigModel).

Combo-matrix construction was centralized into
RunConfigModel.build_runtime_combos() so the GUI, workflows, and codec
all derive the same widened matrix instead of each computing its own
version of the virtual 2x4 layout.
This commit is contained in:
2026-07-29 17:44:28 +03:00
parent f967da7f53
commit 68bec25f17
11 changed files with 341 additions and 32 deletions
+1 -1
View File
@@ -226,4 +226,4 @@ python_app/runtime
SHARE_INTERNET_TO_PI.md
CLAUDE.md
./docs
docs/
@@ -61,7 +61,7 @@ class AppWindowConfigProfileIOMixin:
"""Return the canonical virtual combo matrix shown for matrix-mode radars."""
return ",".join(
f"{int(combo.input)}:{int(combo.output)}"
for combo in RunConfigModel.build_matrix_radar_virtual_combos()
for combo in self._defaults_config.build_runtime_combos()
)
def _sync_pass_through_y_controls(self) -> None:
@@ -125,7 +125,7 @@ class AppWindowConfigStateBuildersMixin:
if config.is_matrix_radar:
return ",".join(
f"{int(combo.input)}:{int(combo.output)}"
for combo in RunConfigModel.build_matrix_radar_virtual_combos()
for combo in config.build_runtime_combos()
)
combos = list(config.combos)
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
@@ -46,13 +46,31 @@ def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
if model == RunConfigModel.LIBREVNA_MULTI_MODEL:
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
return MultiDeviceLibreVnaService(
inner = MultiDeviceLibreVnaService(
master_serial=config.radar.serial,
slave_serials=list(config.radar.multi_device.slave_serials),
force_external_reference=config.radar.multi_device.force_external_reference,
recovery_attempts=config.radar.multi_device.recovery_attempts,
backend_mode=config.radar.driver_mode,
)
out_physical = config.matrix_output_switch_positions
in_physical = config.matrix_input_switch_positions
if out_physical <= 1 and in_physical <= 1:
return inner
from python_app.hardware_full.switched_matrix_radar_service import (
SwitchedMatrixRadarService,
build_physical_switch,
)
return SwitchedMatrixRadarService(
inner=inner,
output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode),
input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode),
inner_output_positions=RunConfigModel.MULTI_DEVICE_OUTPUT_POSITIONS,
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
settling_ms=config.runtime.settling_ms,
)
if model == RunConfigModel.SN9000_MODEL:
if config.radar.driver_mode != "native":
@@ -66,6 +66,10 @@ class SwitchService:
"""Switch to requested position."""
self._driver.switch_to(position)
def position_count(self) -> int:
"""Return number of positions supported by the backend driver."""
return self._driver.position_count()
@property
def current_position(self) -> int:
"""Return current switch position reported by backend driver."""
@@ -0,0 +1,130 @@
"""Matrix radar behind real GPIO switches on the stimulus and/or receiver path."""
from __future__ import annotations
from dataclasses import dataclass, replace
import logging
import time
from python_app.hardware_full.matrix_radar_service import MatrixRadarService
from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel, SwitchModel
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class SwitchedMatrixRadarService:
"""Widen a matrix radar's combo matrix with real switch positions.
Implements the ``MatrixRadarService`` protocol, so the producer and the
capture workflows treat it as an ordinary matrix radar that simply reports
more positions. The hardware sweep is never stopped: switches are only ever
driven BETWEEN ``acquire_collection`` calls, and the inner service's
free-running collection discards any partially swept cycle.
"""
inner: MatrixRadarService
output_switch: SwitchService | None
input_switch: SwitchService | None
inner_output_positions: int
inner_input_positions: int
settling_ms: int = 0
def open(self) -> None:
"""Open the inner radar and both switches."""
self.inner.open()
if self.output_switch is not None:
self.output_switch.open()
if self.input_switch is not None:
self.input_switch.open()
def close(self) -> None:
"""Close switches first, then the inner radar; never raises."""
for switch in (self.input_switch, self.output_switch):
if switch is not None:
try:
switch.close()
except Exception as exc: # noqa: BLE001 — shutdown path
logger.warning("Switch close ignored error: %s", exc)
self.inner.close()
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings to the inner radar."""
self.inner.configure(sweep)
def recover(self) -> None:
"""Reconnect the inner radar; switches are not on the USB transport."""
self.inner.recover()
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire the full widened matrix, one inner collection per switch step.
A partial failure raises instead of returning a short collection: the
preprocessor requires every runtime combo to be present, so half a matrix
is worse than a dropped frame.
"""
capture_start_ns = time.monotonic_ns()
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
total_inputs = in_steps * self.inner_input_positions
total_outputs = out_steps * self.inner_output_positions
# Place each trace at its canonical index rather than appending. The GPR stage
# rejects a collection whose trace order differs from run.combos, and run.combos
# is built output-major (`build_full_combos`) while these loops run switch-major.
# Appending happens to agree for an output switch and to disagree for an input one.
slots: list[TraceData | None] = [None] * (total_inputs * total_outputs)
for out_k in range(out_steps):
if self.output_switch is not None:
self.output_switch.switch_to(out_k)
for in_k in range(in_steps):
if self.input_switch is not None:
self.input_switch.switch_to(in_k)
# Settle AFTER the last switch change and BEFORE collecting, so the
# cycle we anchor on starts with the RF path already stable.
if self.settling_ms > 0:
time.sleep(self.settling_ms / 1000.0)
sub = self.inner.acquire_collection(collection_id)
for trace in sub.traces:
input_pos = in_k * self.inner_input_positions + int(trace.combo.input)
output_pos = out_k * self.inner_output_positions + int(trace.combo.output)
slots[output_pos * total_inputs + input_pos] = replace(
trace, combo=ComboKey(input=input_pos, output=output_pos)
)
if any(trace is None for trace in slots):
missing = sum(1 for trace in slots if trace is None)
raise RuntimeError(
f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing"
)
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=[trace for trace in slots if trace is not None],
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
)
def build_physical_switch(
model: SwitchModel,
physical_positions: int,
radar_driver_mode: str,
) -> SwitchService | None:
"""Build the driver for a real switch described by a virtual switch section.
The config section carries the LOGICAL axis size and a forced "mock" mode so
the C++ loader accepts it; the real driver needs the PHYSICAL position count
and native mode. Mock radar runs keep mock switches so the whole path can be
exercised without GPIO.
"""
if physical_positions <= 1:
return None
driver_mode = "mock" if radar_driver_mode.strip().lower() == "mock" else "native"
return SwitchService.from_model(
replace(model, positions=physical_positions, driver_mode=driver_mode)
)
+12
View File
@@ -219,6 +219,16 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
"recovery_attempts",
model.radar.multi_device.recovery_attempts,
)
model.radar.multi_device.output_switch_positions = _read_int(
multi_device_payload,
"output_switch_positions",
model.radar.multi_device.output_switch_positions,
)
model.radar.multi_device.input_switch_positions = _read_int(
multi_device_payload,
"input_switch_positions",
model.radar.multi_device.input_switch_positions,
)
model.radar.kamil_adc.project_dir = _read_str(
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
)
@@ -493,6 +503,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"slave_serials": list(model.radar.multi_device.slave_serials),
"force_external_reference": model.radar.multi_device.force_external_reference,
"recovery_attempts": model.radar.multi_device.recovery_attempts,
"output_switch_positions": model.radar.multi_device.output_switch_positions,
"input_switch_positions": model.radar.multi_device.input_switch_positions
},
"kamil_adc": {
"project_dir": model.radar.kamil_adc.project_dir,
+27 -4
View File
@@ -40,6 +40,8 @@ class RadarMultiDeviceModel:
slave_serials: list[str] = field(default_factory=list)
force_external_reference: bool = True
recovery_attempts: int = 3
output_switch_positions: int = 1 # 1 = свитча нет
input_switch_positions: int = 1 # 1 = свитча нет
@dataclass(slots=True)
@@ -387,6 +389,24 @@ class RunConfigModel:
"""Return whether this config acquires the full virtual switch matrix per sweep."""
return self.is_multi_device or self.is_sn9000
@property
def matrix_output_switch_positions(self) -> int:
"""Physical positions of the real switch on the master stimulus path."""
if not self.is_multi_device:
return 1
return max(1, int(self.radar.multi_device.output_switch_positions))
@property
def matrix_input_switch_positions(self) -> int:
"""Physical positions of the real switch on the slave receiver path."""
if not self.is_multi_device:
return 1
return max(1, int(self.radar.multi_device.input_switch_positions))
def build_runtime_combos(self) -> list[ComboModel]:
"""Build the combo matrix from the effective switch axis sizes."""
return self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
@property
def is_kamil_adc(self) -> bool:
"""Return whether this config targets the external Kamil ADC acquisition path."""
@@ -443,22 +463,25 @@ class RunConfigModel:
if not self.is_matrix_radar:
return
self._apply_matrix_virtual_switches()
self.combos = self.build_matrix_radar_virtual_combos()
self.combos = self.build_runtime_combos()
def _apply_matrix_virtual_switches(self) -> None:
"""Pin the canonical 2x4 virtual switch matrix used by all matrix-mode radars."""
"""Pin the virtual switch matrix, widened by any real switch on the path."""
out_physical = self.matrix_output_switch_positions
in_physical = self.matrix_input_switch_positions
self.output_switch.name = self.output_switch.name or "virtual_output"
self.output_switch.driver_mode = "mock"
self.output_switch.driver = self.output_switch.driver or "h7992"
self.output_switch.radar_port = 1
self.output_switch.positions = self.MULTI_DEVICE_OUTPUT_POSITIONS
self.output_switch.positions = out_physical * self.MULTI_DEVICE_OUTPUT_POSITIONS
self.output_switch.default_position = 0
self.input_switch.name = self.input_switch.name or "virtual_input"
self.input_switch.driver_mode = "mock"
self.input_switch.driver = self.input_switch.driver or "h7992"
self.input_switch.radar_port = 2
self.input_switch.positions = self.MULTI_DEVICE_INPUT_POSITIONS
self.input_switch.positions = in_physical * self.MULTI_DEVICE_INPUT_POSITIONS
self.input_switch.default_position = 0
def ensure_combos(self) -> None:
@@ -81,14 +81,7 @@ class MultiRadarSequentialCaptureSession:
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar
else RunConfigModel.build_full_combos(
base_config.input_switch.positions,
base_config.output_switch.positions,
)
)
self._combos = base_config.build_runtime_combos()
if not self._combos:
raise RuntimeError("No switch combinations available for capture")
@@ -64,11 +64,7 @@ class SequentialCaptureSession:
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = (
RunConfigModel.build_matrix_radar_virtual_combos()
if self._is_matrix_radar
else RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
)
self._combos = config.build_runtime_combos()
if not self._combos:
raise RuntimeError("No switch combinations available for capture")
+143 -10
View File
@@ -1,6 +1,6 @@
{
"radar": {
"model": "librevna",
"model": "librevna_multi",
"serial": "",
"remote_host": "127.0.0.1",
"remote_port": 50209,
@@ -8,9 +8,14 @@
"mock_signal_hz": 5000000.0,
"visa_library": "",
"multi_device": {
"slave_serials": [],
"slave_serials": [
"20A1307D5532",
"2072306C5532"
],
"force_external_reference": false,
"recovery_attempts": 3
"recovery_attempts": 3,
"output_switch_positions": 1,
"input_switch_positions": 3
},
"kamil_adc": {
"project_dir": "",
@@ -20,7 +25,18 @@
"env": {},
"startup_timeout_s": 5.0,
"sweep_timeout_s": 5.0,
"stop_timeout_s": 2.0
"stop_timeout_s": 2.0,
"phase_calibration": {
"phase0_rad": 0.0,
"freq0_hz": 2046000000.0,
"phase1_rad": 300.0,
"freq1_hz": 5612000000.0
},
"band": {
"start_hz": 2100000000.0,
"stop_hz": 5500000000.0,
"points": 2048
}
},
"laser_control": {
"enabled": false,
@@ -75,7 +91,7 @@
"driver_mode": "mock",
"driver": "h7992",
"radar_port": 2,
"positions": 4,
"positions": 12,
"default_position": 0,
"gpio_chip": "/dev/gpiochip0",
"pin_a": 22,
@@ -92,11 +108,14 @@
"debounce_ms": 50,
"action": "capture_tmp_reference"
},
"logging": {
"level": "info"
},
"run": {
"settling_ms": 0,
"idle_sleep_ms": 2,
"continuous": true,
"processing_live_config_path": "python_app/runtime/processing_live.json",
"processing_live_config_path": "/home/guriy/Documents/radar_system/python_app/runtime/processing_live.json",
"locator_server": {
"device_id": 3,
"protocol_version": 1,
@@ -123,6 +142,38 @@
"input": 3,
"output": 0
},
{
"input": 4,
"output": 0
},
{
"input": 5,
"output": 0
},
{
"input": 6,
"output": 0
},
{
"input": 7,
"output": 0
},
{
"input": 8,
"output": 0
},
{
"input": 9,
"output": 0
},
{
"input": 10,
"output": 0
},
{
"input": 11,
"output": 0
},
{
"input": 0,
"output": 1
@@ -138,17 +189,49 @@
{
"input": 3,
"output": 1
},
{
"input": 4,
"output": 1
},
{
"input": 5,
"output": 1
},
{
"input": 6,
"output": 1
},
{
"input": 7,
"output": 1
},
{
"input": 8,
"output": 1
},
{
"input": 9,
"output": 1
},
{
"input": 10,
"output": 1
},
{
"input": 11,
"output": 1
}
]
},
"preprocess": {
"s21": {
"calibration": {
"set_name": "smoke_cal",
"set_name": "smoke_cal3",
"bundle_path": ""
},
"reference": {
"set_name": "smoke_ref",
"set_name": "smoke_cal3",
"bundle_path": ""
}
},
@@ -219,6 +302,54 @@
"x_m": 0.185,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 4,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 5,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 6,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 7,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 8,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 9,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 10,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
},
{
"input_pos": 11,
"x_m": 0.0,
"y_m": 0.0,
"z_m": 0.0
}
]
},
@@ -253,7 +384,7 @@
"version": 1,
"switches": {
"combo_mode": "text",
"combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1",
"combos_text": "0:0,1:0,2:0,3:0,4:0,5:0,6:0,7:0,8:0,9:0,10:0,11:0,0:1,1:1,2:1,3:1,4:1,5:1,6:1,7:1,8:1,9:1,10:1,11:1",
"single_input": "0",
"single_output": "0"
},
@@ -262,6 +393,7 @@
"pass_through": {
"show_magnitude": true,
"show_phase": false,
"unwrap_phase": false,
"combo_filter": "",
"fixed_y_enabled": false,
"y_min_db": -100.0,
@@ -333,7 +465,8 @@
"data_actions": {
"save_count": 10,
"save_path": "python_app/data/snapshots",
"save_name": "snapshot_simulator"
"save_name": "snapshot_simulator",
"record_count": 100
},
"preprocess_dialog": {
"set_name": "smoke_cal",