288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""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__)
|
|
|
|
# Delays applied between successive USB reopen attempts inside recover(). Picked
|
|
# to give libusb time to re-enumerate a stuck device while staying short enough
|
|
# that a healthy reconnect feels instant. The total worst-case wait is the sum
|
|
# of all entries (1.75 s today) plus the cost of close()/open() themselves.
|
|
_REOPEN_BACKOFF_SECONDS: tuple[float, ...] = (0.25, 0.5, 1.0)
|
|
|
|
_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 as exc:
|
|
# Never silently latch to synthetic data: a deployed appliance must wait
|
|
# for the real device, not record fakes. Synthetic data requires an
|
|
# explicit backend_mode='mock' (selected in __post_init__); both 'auto'
|
|
# and 'native' re-raise so the producer's wait-for-device retry keeps
|
|
# trying until the hardware appears.
|
|
logger.warning("Multi-device open failed (backend_mode=%s): %s", self.backend_mode, exc)
|
|
raise
|
|
|
|
def close(self) -> None:
|
|
"""Close native device transports; never raises.
|
|
|
|
Recovery loops rely on `close()` being safe to call on a half-open or
|
|
already-broken controller. We swallow any transport-level exception here
|
|
and just drop the reference so the next `open()` starts fresh.
|
|
"""
|
|
controller = self._controller
|
|
self._controller = None
|
|
if controller is None:
|
|
return
|
|
try:
|
|
controller.close()
|
|
except Exception as exc: # noqa: BLE001 — recovery path, never propagate
|
|
logger.warning("Multi-device close() ignored transport error: %s", exc)
|
|
|
|
def recover(self) -> None:
|
|
"""Reopen native device transports after a failed acquisition.
|
|
|
|
Tries several short backoffs so a transient USB stall does not kill the
|
|
producer on the very first retry. Raises the last error only after
|
|
every attempt failed — the outer acquisition loop is expected to count
|
|
these as recovery_attempts.
|
|
"""
|
|
if self._using_mock_backend:
|
|
return
|
|
self.close()
|
|
|
|
last_error: Exception | None = None
|
|
for attempt_index, delay_s in enumerate(_REOPEN_BACKOFF_SECONDS, start=1):
|
|
time.sleep(delay_s)
|
|
try:
|
|
self.open()
|
|
if self._controller is not None:
|
|
logger.info(
|
|
"Multi-device reopen succeeded on attempt %d/%d (after %.2fs)",
|
|
attempt_index,
|
|
len(_REOPEN_BACKOFF_SECONDS),
|
|
delay_s,
|
|
)
|
|
return
|
|
except Exception as exc: # noqa: BLE001 — propagate only the last failure
|
|
last_error = exc
|
|
logger.warning(
|
|
"Multi-device reopen attempt %d/%d failed after %.2fs: %s",
|
|
attempt_index,
|
|
len(_REOPEN_BACKOFF_SECONDS),
|
|
delay_s,
|
|
exc,
|
|
)
|
|
self.close() # tidy partially-opened state before next try
|
|
|
|
if last_error is not None:
|
|
raise last_error
|
|
raise RuntimeError("Multi-device recover() exhausted all reopen attempts")
|
|
|
|
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,
|
|
)
|
|
# recover() may itself fail when libusb cannot re-enumerate the
|
|
# device fast enough; treat that as the same kind of recovery
|
|
# attempt and try again on the next loop iteration, so a
|
|
# transient USB hiccup cannot kill the whole producer.
|
|
try:
|
|
self.recover()
|
|
except Exception as recover_exc: # noqa: BLE001
|
|
last_error = recover_exc
|
|
logger.warning(
|
|
"multi-device recover() failed (%d/%d): %s",
|
|
attempt_index + 1,
|
|
self.recovery_attempts,
|
|
recover_exc,
|
|
exc_info=True,
|
|
)
|
|
|
|
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
|