"""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=input_pos, output=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=input_pos, output=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