added multidevice support
This commit is contained in:
@@ -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()]
|
||||
Reference in New Issue
Block a user