Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -1,23 +0,0 @@
"""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)
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Iterator, Sequence
from collections.abc import Sequence
from contextlib import suppress
from dataclasses import replace
from typing import Optional
@@ -11,7 +11,7 @@ import threading
import time
from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import (
collect_complete_running_sweep_cycles,
collect_complete_running_sweep_cycle,
)
from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
@@ -109,13 +109,6 @@ class MultiDeviceVnaController:
device_connection.close()
logger.debug("Multi-device controller closed")
def stop_continuous_sweep(self) -> None:
"""Stop the currently running sweep without closing device transports."""
if self._is_closed:
return
logger.info("Stopping continuous sweep")
self._send_idle_to_all_devices()
def configure_continuous_sweep(
self,
sweep_configuration: SweepConfiguration,
@@ -179,13 +172,12 @@ class MultiDeviceVnaController:
master_stimulus_ports=stimulus_ports,
)
def collect_running_sweep_cycles(
def collect_running_sweep_cycle(
self,
cycle_count: int = 1,
*,
datapoint_timeout_seconds: float | None = None,
) -> SweepMeasurementResult:
"""Collect complete cycles from the already-running synchronized sweep."""
"""Collect one complete cycle from the already-running synchronized sweep."""
if self._is_closed:
raise RuntimeError("Controller is already closed")
if (
@@ -197,11 +189,10 @@ class MultiDeviceVnaController:
raise RuntimeError("No running sweep is configured. Call configure_continuous_sweep() first.")
try:
return collect_complete_running_sweep_cycles(
return collect_complete_running_sweep_cycle(
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,
switch_substage_count=self._switch_substage_count,
datapoint_timeout_seconds=datapoint_timeout_seconds,
@@ -211,11 +202,6 @@ class MultiDeviceVnaController:
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,
@@ -14,7 +14,6 @@ from python_app.hardware_full.librevna_multi_device_driver.exceptions import (
TransientCollectionError,
)
from python_app.hardware_full.librevna_multi_device_driver.models import (
OutputChannel,
SweepConfiguration,
SweepMeasurementResult,
build_output_channels,
@@ -52,7 +51,7 @@ _MIN_CYCLE_ALIGNMENT_TOLERANCE_SECONDS = 0.5e-3
# Below this magnitude a master reference value is treated as a corrupt incident
# signal rather than a real measurement: dividing by it would explode the
# S-parameter into noise. Mirrors the single-device C++ driver's reference guard.
# S-parameter into noise. Both acquisition drivers reject such references.
_MIN_REFERENCE_MAGNITUDE = 1e-12
# A dropped USB frame leaves a single sweep point undelivered. Instead of
@@ -76,10 +75,12 @@ class _DeviceCollectionState:
points are zero-filled when the S-parameters are computed.
"""
__slots__ = ("last_cycle_closed",)
__slots__ = ("last_cycle_closed", "capture_start_ns", "capture_end_ns")
def __init__(self) -> None:
self.last_cycle_closed = False
self.capture_start_ns = 0
self.capture_end_ns = 0
class _CrossDeviceCycleSynchronizer:
@@ -168,20 +169,16 @@ def _is_transient_collection_error(error: BaseException) -> bool:
return isinstance(error, (TransientCollectionError, TimeoutError))
def collect_complete_running_sweep_cycles(
def collect_complete_running_sweep_cycle(
*,
master_device_connection: LibreVnaUsbBulkConnection,
slave_device_connections: Sequence[LibreVnaUsbBulkConnection],
active_sweep_configuration: SweepConfiguration,
cycle_count: int,
master_stimulus_ports: Sequence[int],
switch_substage_count: int = 1,
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")
"""Collect one complete cycle from an already-running synchronized sweep."""
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
# One output per (master port, stimulus switch position). With no per-point
# switching this is one output per port, exactly as before.
@@ -202,18 +199,14 @@ def collect_complete_running_sweep_cycles(
)
else:
frequencies_hz = np.array([float(active_sweep_configuration.start_hz)], dtype=np.float64)
# Keyed by 1-based OUTPUT number, not by master port: with per-point switching two
# outputs share a stimulus port but are measured in different stages, so each needs
# its own incident reference.
master_reference_measurements_by_output = {
channel.number: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
channel.index: np.full(point_count, np.nan + 1j * np.nan, dtype=complex)
for channel in output_channels
}
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), output_channels
)
(input_pos, channel.index): np.full(point_count, np.nan + 1j * np.nan, dtype=complex)
for channel in output_channels
for input_pos in range(2 * len(slave_device_connections))
}
collection_errors: list[Exception] = []
@@ -315,12 +308,12 @@ def collect_complete_running_sweep_cycles(
``stop_collection_requested`` is set so the other collector threads stop too.
"""
datapoints_received = 0
expected_datapoint_count = cycle_count * point_count
expected_datapoint_count = point_count
loop_start_timestamp = time.monotonic()
# Tracks the last time we actually accepted a datapoint into the cycle.
# Crucially, *not* updated on rejected datapoints — a device that keeps
# streaming valid-looking frames the handler ignores (e.g. waiting on
# point_index=0, or after cycle_count has been reached) must still hit
# point_index=0, or after the cycle has closed) must still hit
# the per-device timeout and trigger recovery instead of looping forever.
last_consumed_timestamp = loop_start_timestamp
has_consumed_any_datapoint = False
@@ -449,15 +442,14 @@ def collect_complete_running_sweep_cycles(
datapoints_received += 1
def build_cycle_tracking_handler(
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
cycle_aware_handler: Callable[[ParsedVnaDatapoint], None],
device_state: _DeviceCollectionState,
device_label: str = "device",
) -> Callable[[ParsedVnaDatapoint], bool]:
"""Wrap a cycle-aware handler with cross-device cycle tracking.
Returns a per-datapoint handler that anchors cycle 0 on the first
``point_index == 0`` seen, advances the cycle counter on each point-index
wrap, drops datapoints past ``cycle_count``, and reports whether each
``point_index == 0`` seen, closes on the next point-index wrap, and reports whether each
datapoint was consumed.
"""
# In steady state the controller leaves the hardware sweep FREE-RUNNING
@@ -468,10 +460,8 @@ def collect_complete_running_sweep_cycles(
# the drain can let a wrap slip between devices, that first point 0 is only
# accepted once `_CrossDeviceCycleSynchronizer` confirms every device saw
# point 0 of the SAME physical sweep; a mismatch rejects the collection for
# a transient retry. From the agreed anchor, each subsequent wrap advances
# the cycle counter normally.
# a transient retry. The next wrap closes the agreed cycle.
cycle_tracking_state = {
"current_cycle_index": 0,
"synchronized": False,
# How many mid-sweep points were thrown away before the anchor was found.
# Near zero means the drain landed on a sweep boundary — the case where a
@@ -483,11 +473,12 @@ def collect_complete_running_sweep_cycles(
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
"""Track the cycle index for one datapoint and dispatch it to the handler.
Returns ``True`` when the datapoint was consumed (within ``cycle_count``)
Returns ``True`` when the datapoint was consumed (within the first cycle)
and ``False`` when it was ignored (pre-sync straggler or past the last
requested cycle).
"""
current_point_index = parsed_datapoint.point_index
received_ns = time.monotonic_ns()
if not cycle_tracking_state["synchronized"]:
if current_point_index != 0:
@@ -497,10 +488,11 @@ def collect_complete_running_sweep_cycles(
# observed point 0 of the SAME physical sweep; otherwise reject the
# whole collection so the caller re-drains and re-collects rather
# than pairing a reference and measurement from different sweeps.
if not cycle_synchronizer.confirm_aligned_cycle_zero(time.monotonic()):
if not cycle_synchronizer.confirm_aligned_cycle_zero(received_ns / 1e9):
report_cycle_misalignment()
return False
cycle_tracking_state["synchronized"] = True
device_state.capture_start_ns = device_state.capture_end_ns = received_ns
logger.debug(
"timing: %s anchored cycle 0 after skipping %d mid-sweep point(s) of %d "
"(t=%.1f ms)",
@@ -509,53 +501,21 @@ def collect_complete_running_sweep_cycles(
point_count,
time.monotonic() * 1e3,
)
cycle_aware_handler(parsed_datapoint, 0)
cycle_aware_handler(parsed_datapoint)
return True
# A new sweep cycle begins exactly when the point index wraps to 0.
# Keying on ==0 (rather than "the index decreased") means a corrupt
# mid-cycle index that slipped past the in-band gate cannot fabricate a
# spurious wrap and desynchronize the cycle counter.
if current_point_index == 0:
cycle_tracking_state["current_cycle_index"] += 1
logger.debug(
"timing: %s first point of NEXT sweep arrived (cycle -> %d, t=%.1f ms)",
device_label,
cycle_tracking_state["current_cycle_index"],
time.monotonic() * 1e3,
)
current_cycle_index = cycle_tracking_state["current_cycle_index"]
if current_cycle_index >= cycle_count:
# The sweep just wrapped past the final requested cycle, closing its
# collection window. Any of its points we never received are now
# confirmed dropped frames, not points still in flight, so flag the
# device done and let the collector thread stop immediately. The
# missing points are emitted as zero when the S-parameters are
# computed; delivered points keep their real values.
# A second point 0 closes this cycle; absent points are now losses.
device_state.last_cycle_closed = True
return False
cycle_aware_handler(parsed_datapoint, current_cycle_index)
if current_point_index == point_count - 1:
logger.debug(
"timing: %s last point of cycle %d arrived (index=%d, t=%.1f ms)",
device_label,
current_cycle_index,
current_point_index,
time.monotonic() * 1e3,
)
cycle_aware_handler(parsed_datapoint)
device_state.capture_end_ns = received_ns
return True
return handle_datapoint
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Store the master device's frequency, reference, and reflection values.
Records the sweep-point frequency and, per output channel, the reference
receiver value and the matching reflection into the cycle/point measurement
buffers. Two outputs may share a stimulus port (different switch positions);
they are told apart by the stage tag.
"""
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> None:
"""Store the frequency and incident reference for each output stage."""
point_index = parsed_datapoint.point_index
frequencies_hz[point_index] = parsed_datapoint.frequency_hz
@@ -575,45 +535,20 @@ def collect_complete_running_sweep_cycles(
if reference_receiver_value is None:
continue
master_reference_measurements_by_output[channel.number][
cycle_index,
master_reference_measurements_by_output[channel.index][
point_index,
] = reference_receiver_value
s_parameter_name = master_reflection_s_parameter_name(channel)
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=channel.stage_index,
is_reference_receiver=False,
required_port_number=channel.stimulus_port,
)
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, device_state: _DeviceCollectionState
) -> Callable[[ParsedVnaDatapoint], bool]:
"""Build a cycle-tracking datapoint handler for the given slave device.
The slave's two receivers map to the virtual receiver ports numbered just
above the outputs (see :func:`slave_receiver_base_port`), producing one
forward S-parameter per output channel.
"""
receiver_base_port = slave_receiver_base_port(slave_index, output_channels)
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Collect both physical receivers of this slave in the configured order."""
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> None:
"""Store this slave's forward receiver values into the measurement buffers."""
point_index = parsed_datapoint.point_index
for channel in output_channels:
first_s_parameter_name = f"S{receiver_base_port}{channel.number}"
second_s_parameter_name = f"S{receiver_base_port + 1}{channel.number}"
for receiver_port_number, s_parameter_name in (
(1, first_s_parameter_name),
(2, second_s_parameter_name),
):
for receiver_port_number in (1, 2):
combo = (2 * slave_index + receiver_port_number - 1, channel.index)
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=channel.stage_index,
@@ -621,8 +556,7 @@ def collect_complete_running_sweep_cycles(
required_port_number=receiver_port_number,
)
if port_receiver_value is not None:
raw_receiver_measurements_by_s_parameter[s_parameter_name][
cycle_index,
raw_receiver_measurements_by_s_parameter[combo][
point_index,
] = port_receiver_value
@@ -661,8 +595,7 @@ def collect_complete_running_sweep_cycles(
)
logger.debug(
"Collecting %d sweep cycle(s) of %d points from %d device(s) (datapoint_timeout=%.1fs)",
cycle_count,
"Collecting one sweep of %d points from %d device(s) (datapoint_timeout=%.1fs)",
point_count,
len(all_device_connections),
datapoint_timeout_seconds,
@@ -737,54 +670,20 @@ def collect_complete_running_sweep_cycles(
logger.debug("Sweep cycle collection complete (per-device counts: %s)", datapoint_counts_by_device_serial)
return SweepMeasurementResult(
frequencies_hz=frequencies_hz,
s_parameters=calculate_last_cycle_s_parameters(
s_parameters=calculate_s_parameters(
raw_receiver_measurements_by_s_parameter,
master_reference_measurements_by_output,
),
capture_start_ns=master_device_state.capture_start_ns,
capture_end_ns=master_device_state.capture_end_ns,
)
def slave_receiver_base_port(slave_index: int, output_channels: Sequence[OutputChannel]) -> int:
"""Return the first virtual receiver port number of one slave device.
Receivers are numbered directly above the outputs, so the whole topology reads
as one virtual VNA whose first ``N`` ports transmit and whose remaining ports
receive. Numbering them from a fixed 3 instead would make a 4-output setup
collide with itself: the reflection of output 3 and the forward term
``receiver 3 <- output 3`` would both be called ``S33``. With two outputs this
still yields the historical ports 3..6.
"""
return len(output_channels) + 2 * slave_index + 1
def master_reflection_s_parameter_name(channel: OutputChannel) -> str:
"""Return the reflection trace name for one output channel (``S11``, ``S22``, ...)."""
return f"S{channel.number}{channel.number}"
def build_forward_s_parameter_names(
slave_device_count: int, output_channels: Sequence[OutputChannel]
) -> list[str]:
"""Return S-parameter names for the configured forward multi-device topology."""
names: list[str] = []
for channel in output_channels:
names.append(master_reflection_s_parameter_name(channel))
for slave_index in range(slave_device_count):
receiver_base_port = slave_receiver_base_port(slave_index, output_channels)
names.extend(
[
f"S{receiver_base_port}{channel.number}",
f"S{receiver_base_port + 1}{channel.number}",
]
)
return names
def calculate_last_cycle_s_parameters(
raw_receiver_measurements_by_s_parameter: dict[str, np.ndarray],
def calculate_s_parameters(
raw_receiver_measurements_by_s_parameter: dict[tuple[int, int], np.ndarray],
master_reference_measurements_by_output: dict[int, np.ndarray],
) -> dict[str, np.ndarray]:
"""Convert raw receiver captures from the final sweep cycle into S-parameters.
) -> dict[tuple[int, int], np.ndarray]:
"""Convert raw receiver captures from the sweep cycle into S-parameters.
Points whose value never arrived (a dropped USB frame, still ``NaN``) or whose
incident reference is unusable (missing or a corrupt ~zero magnitude) cannot be
@@ -794,45 +693,40 @@ def calculate_last_cycle_s_parameters(
genuine fault rather than a stray dropped frame, and the cycle is rejected as
transient so the caller recovers instead of returning a mostly-zero trace.
"""
s_parameters: dict[str, np.ndarray] = {}
zero_filled_points_by_trace: dict[str, int] = {}
s_parameters: dict[tuple[int, int], np.ndarray] = {}
zero_filled_points_by_trace: dict[tuple[int, int], int] = {}
point_count = 0
for s_parameter_name, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
# The trailing digit of every name is the 1-based OUTPUT number, which is what
# selects the incident reference (see build_forward_s_parameter_names).
output_number = int(s_parameter_name[-1])
master_reference_measurements = master_reference_measurements_by_output[output_number]
for combo, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
master_reference_measurements = master_reference_measurements_by_output[combo[1]]
last_cycle_reference = master_reference_measurements[-1]
last_cycle_measurement = raw_receiver_measurements[-1]
point_count = last_cycle_measurement.size
point_count = raw_receiver_measurements.size
# Without a valid incident reference no trace at that point can be
# normalized; without this trace's receiver value there is nothing to
# normalize. Either way the point is unmeasurable and emitted as zero.
reference_unusable_mask = np.isnan(last_cycle_reference) | (
np.abs(last_cycle_reference) <= _MIN_REFERENCE_MAGNITUDE
reference_unusable_mask = ~np.isfinite(master_reference_measurements) | (
np.abs(master_reference_measurements) <= _MIN_REFERENCE_MAGNITUDE
)
measurement_missing_mask = np.isnan(last_cycle_measurement)
measurement_missing_mask = ~np.isfinite(raw_receiver_measurements)
zero_fill_mask = reference_unusable_mask | measurement_missing_mask
zero_filled_count = int(zero_fill_mask.sum())
if point_count and zero_filled_count > _MAX_ZERO_FILLED_POINTS_FRACTION * point_count:
raise TransientCollectionError(
f"Trace {s_parameter_name} is unmeasurable at {zero_filled_count}/{point_count} "
f"Trace {combo} is unmeasurable at {zero_filled_count}/{point_count} "
f"points (missing reference/measurement); rejecting cycle for recovery"
)
# Substitute neutral operands so the division never produces NaN/inf, then
# force the unmeasurable points to zero. Delivered points are untouched.
safe_reference = np.where(reference_unusable_mask, 1.0 + 0j, last_cycle_reference)
safe_measurement = np.where(measurement_missing_mask, 0.0 + 0j, last_cycle_measurement)
safe_reference = np.where(reference_unusable_mask, 1.0 + 0j, master_reference_measurements)
safe_measurement = np.where(measurement_missing_mask, 0.0 + 0j, raw_receiver_measurements)
s_parameter_values = safe_measurement / safe_reference
s_parameter_values[zero_fill_mask] = 0.0
if zero_filled_count:
zero_filled_points_by_trace[s_parameter_name] = zero_filled_count
s_parameters[s_parameter_name.lower()] = s_parameter_values
zero_filled_points_by_trace[combo] = zero_filled_count
s_parameters[combo] = s_parameter_values
if zero_filled_points_by_trace:
# One concise line per affected cycle (a rare dropped-frame event), so the
@@ -62,10 +62,6 @@ class OutputChannel:
stimulus_port: int
stage_index: int
@property
def number(self) -> int:
"""Return the 1-based output number used in S-parameter names."""
return self.index + 1
def build_output_channels(
@@ -129,7 +125,9 @@ class SweepConfiguration:
@dataclass(slots=True)
class SweepMeasurementResult:
"""Measured synchronized sweep for one active master output port."""
"""S21 traces keyed by zero-based (input, output) for one synchronized sweep."""
frequencies_hz: np.ndarray
s_parameters: dict[str, np.ndarray]
s_parameters: dict[tuple[int, int], np.ndarray]
capture_start_ns: int
capture_end_ns: int