added multidevice support

This commit is contained in:
Ayzen
2026-04-28 17:29:21 +03:00
parent 93c705a33b
commit 1ea2aabf87
37 changed files with 1860 additions and 138 deletions
@@ -2,7 +2,6 @@
import logging
from .device import LibreVNADevice
from .enums import (
HardwareFamily,
PacketType,
@@ -71,4 +70,14 @@ __all__ = [
"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())
@@ -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