This commit is contained in:
Ayzen
2026-06-13 12:07:23 +03:00
parent f0d095de80
commit e7f2d25585
20 changed files with 903 additions and 148 deletions
@@ -16,6 +16,7 @@ from python_app.hardware_full.librevna_multi_device_driver.cycle_collection impo
from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
SweepMeasurementResult,
normalize_master_stimulus_ports,
)
from python_app.hardware_full.librevna_multi_device_driver.protocol import (
PacketType,
@@ -367,12 +368,5 @@ class MultiDeviceVnaController:
@staticmethod
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
"""Validate and return master stimulus ports as a tuple of ints (ports 1/2 only)."""
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
"""Validate and return master stimulus ports (ports 1/2 only, unique)."""
return normalize_master_stimulus_ports(master_stimulus_ports)
@@ -10,9 +10,13 @@ import time
import numpy as np
from python_app.hardware_full.librevna_multi_device_driver.exceptions import (
TransientCollectionError,
)
from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
SweepMeasurementResult,
normalize_master_stimulus_ports,
)
from python_app.hardware_full.librevna_multi_device_driver.protocol import (
PacketType,
@@ -39,6 +43,102 @@ _MAX_FULL_CYCLE_SECONDS = 8.0
# and let the orphan thread die when the producer process exits.
_THREAD_JOIN_TIMEOUT_SECONDS = 2.0
# Floor for the cross-device cycle-0 alignment tolerance (see
# _CrossDeviceCycleSynchronizer). Keeps the tolerance sane for very fast sweeps
# where points/IF-bandwidth would otherwise estimate a sub-millisecond cycle.
_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.
_MIN_REFERENCE_MAGNITUDE = 1e-12
class _CrossDeviceCycleSynchronizer:
"""Confirm every device thread anchors cycle 0 on the SAME physical sweep.
The devices are hardware-trigger synchronized, so they emit ``point_index==0``
at the same physical instant. After a host-side queue drain, however, a sweep
wrap can slip between the per-device drains, leaving one collector anchored to
physical sweep N and another to N+1. Their reference and measurement would then
be divided across different sweeps, producing a whole-trace random-phase result
(the reported "noise sweep").
Each collector calls :meth:`confirm_aligned_cycle_zero` the moment it observes
its first ``point_index==0``, passing the host monotonic time it saw that wrap,
and the calls rendezvous at a :class:`threading.Barrier`. Because the wrap is
physically simultaneous, aligned threads observe it within host-latency jitter
of one another, while an off-by-one thread is ~one sweep period away. If the
spread of anchor times exceeds ``alignment_tolerance_seconds`` the alignment is
rejected, so the caller re-drains and re-collects instead of emitting noise.
A single device (no slaves) trivially aligns. If a peer never reaches the
barrier within ``rendezvous_timeout_seconds`` the barrier breaks and every
waiting thread reports "not aligned", which the caller also treats as a
transient failure to retry.
"""
def __init__(
self,
*,
device_count: int,
alignment_tolerance_seconds: float,
rendezvous_timeout_seconds: float,
) -> None:
"""Create a one-shot synchronizer for ``device_count`` collector threads."""
self._barrier = threading.Barrier(max(1, int(device_count)))
self._alignment_tolerance_seconds = float(alignment_tolerance_seconds)
self._rendezvous_timeout_seconds = float(rendezvous_timeout_seconds)
self._lock = threading.Lock()
self._anchor_times: list[float] = []
self._is_aligned = False
def confirm_aligned_cycle_zero(self, anchor_monotonic_seconds: float) -> bool:
"""Block until every device reported cycle 0; return whether all agree.
Returns ``True`` only when all devices anchored within the tolerance (same
physical sweep). Returns ``False`` on misalignment or if the rendezvous
times out / breaks.
"""
with self._lock:
self._anchor_times.append(anchor_monotonic_seconds)
try:
arrival_index = self._barrier.wait(timeout=self._rendezvous_timeout_seconds)
# Exactly one thread computes the verdict; the second rendezvous
# publishes it to every thread before any of them reads it.
if arrival_index == 0:
spread_seconds = max(self._anchor_times) - min(self._anchor_times)
self._is_aligned = spread_seconds <= self._alignment_tolerance_seconds
self._barrier.wait(timeout=self._rendezvous_timeout_seconds)
except threading.BrokenBarrierError:
return False
return self._is_aligned
def _expected_sweep_cycle_seconds(point_count: int, stage_count: int, if_bandwidth_hz: float) -> float:
"""Return a conservative LOWER bound on one sweep cycle's wall-clock duration.
Every frequency point is measured once per excitation stage (one stage per
active master port), each taking at least one IF integration period
(1 / IF-bandwidth). Real sweeps are longer because of per-point settling/LO
overhead, so this underestimates — which is exactly what the cycle-alignment
tolerance wants (a tolerance safely below a true one-sweep off-by-one).
Measured anchor: 751 points x 2 stages at 50 kHz -> 30 ms here vs a real
~150 ms sweep.
"""
return (int(point_count) * max(1, int(stage_count))) / max(1.0, float(if_bandwidth_hz))
def _is_transient_collection_error(error: BaseException) -> bool:
"""Return whether a collection error is recoverable without a USB reopen.
Timeouts (no-progress / cycle / cycle-start guards) and explicit transient
errors mean the devices are still alive and only this cycle was lost. A
genuine transport failure (surfaced as a plain RuntimeError from the USB
layer) is fatal and must fall through to the heavy recovery path.
"""
return isinstance(error, (TransientCollectionError, TimeoutError))
def collect_complete_running_sweep_cycles(
*,
@@ -53,13 +153,7 @@ def collect_complete_running_sweep_cycles(
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")
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
stage_by_master_port = {port: stage for stage, port in enumerate(stimulus_ports)}
all_device_connections = [master_device_connection, *slave_device_connections]
@@ -79,6 +173,20 @@ def collect_complete_running_sweep_cycles(
device_connection.serial_number: 0
for device_connection in all_device_connections
}
rejected_datapoint_counts_by_device_serial = {
device_connection.serial_number: 0
for device_connection in all_device_connections
}
# In-band frequency gate. A misframed CRC-less datapoint that still parses
# carries an essentially random 64-bit frequency, which almost never lands in
# the swept band, so this rejects corrupt points cheaply. One frequency step of
# margin keeps every legitimate point (which lies within [start, stop]).
band_low_hz = float(min(active_sweep_configuration.start_hz, active_sweep_configuration.stop_hz))
band_high_hz = float(max(active_sweep_configuration.start_hz, active_sweep_configuration.stop_hz))
frequency_step_hz = (band_high_hz - band_low_hz) / (point_count - 1) if point_count > 1 else band_high_hz
band_low_hz -= frequency_step_hz
band_high_hz += frequency_step_hz
stop_collection_requested = threading.Event()
if datapoint_timeout_seconds is None:
datapoint_timeout_seconds = LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS
@@ -92,6 +200,57 @@ def collect_complete_running_sweep_cycles(
# stall capture indefinitely.
cycle_start_guard_seconds = max(2.0, datapoint_timeout_seconds * 4.0)
# Cross-device cycle-0 alignment. The off-by-one race (master anchored to a
# different physical sweep than a slave) shows up as anchor-time spreads on the
# order of one sweep period, whereas aligned anchors differ only by host
# latency jitter. The tolerance is half of a conservative LOWER bound on the
# sweep period: every frequency point is measured once per excitation stage
# (one stage per active master port), and the real period is strictly larger
# still because point_count*stage_count/IF-bandwidth ignores per-point settling
# overhead. That keeps the tolerance well below a true off-by-one yet well above
# host jitter. (Measured anchor: 751 points x 2 stages at 50 kHz IF -> a real
# ~150 ms sweep; this estimates 30 ms, tolerance 15 ms, so an off-by-one of
# ~150 ms is flagged with ~10x margin and ~1 ms jitter clears it with ~7x.)
# A spread above tolerance is rejected and retried, never silently emitted.
stage_count = max(1, len(stimulus_ports))
expected_cycle_seconds = _expected_sweep_cycle_seconds(
point_count, stage_count, active_sweep_configuration.if_bandwidth
)
cycle_alignment_tolerance_seconds = max(
_MIN_CYCLE_ALIGNMENT_TOLERANCE_SECONDS, 0.5 * expected_cycle_seconds
)
# Keep the rendezvous timeout below the collector join deadline: if a device
# stops streaming, the threads waiting at the barrier then break and exit with
# a transient error (cheap re-collect) instead of being orphaned into the
# "failed to stop" path that forces a USB reopen. It still allows several sweep
# periods for the slowest device to reach its first point 0 after a drain.
cycle_rendezvous_timeout_seconds = min(
0.9 * _THREAD_JOIN_TIMEOUT_SECONDS, max(1.0, 5.0 * expected_cycle_seconds)
)
cycle_synchronizer = _CrossDeviceCycleSynchronizer(
device_count=len(all_device_connections),
alignment_tolerance_seconds=cycle_alignment_tolerance_seconds,
rendezvous_timeout_seconds=cycle_rendezvous_timeout_seconds,
)
cycle_misalignment_reported = threading.Event()
def report_cycle_misalignment() -> None:
"""Record a transient misalignment once and stop all collector threads."""
if not cycle_misalignment_reported.is_set():
cycle_misalignment_reported.set()
logger.warning(
"Cross-device cycle-0 misalignment detected (tolerance %.3f ms); "
"rejecting collection for retry",
cycle_alignment_tolerance_seconds * 1e3,
)
collection_errors.append(
TransientCollectionError(
"Cross-device cycle-0 misalignment: master reference and slave "
"measurement would span different sweeps"
)
)
stop_collection_requested.set()
def collect_datapoints_from_device(
device_connection: LibreVnaUsbBulkConnection,
handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
@@ -211,15 +370,25 @@ def collect_complete_running_sweep_cycles(
continue
parsed_datapoint = parse_vna_datapoint_payload(payload)
if parsed_datapoint and 0 <= parsed_datapoint.point_index < point_count:
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
if datapoint_was_consumed:
# Only refreshed on accepted datapoints so the no-progress
# timeout above stays honest about real cycle progress.
last_consumed_timestamp = time.monotonic()
has_consumed_any_datapoint = True
datapoint_counts_by_device_serial[device_connection.serial_number] += 1
datapoints_received += 1
if (
parsed_datapoint is None
or not (0 <= parsed_datapoint.point_index < point_count)
or not (band_low_hz <= parsed_datapoint.frequency_hz <= band_high_hz)
):
# Unparseable, out-of-range index, or out-of-band frequency: almost
# certainly a corrupt/misframed point. Drop it without refreshing the
# progress timer so a stream of garbage still trips the timeout.
rejected_datapoint_counts_by_device_serial[device_connection.serial_number] += 1
continue
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
if datapoint_was_consumed:
# Only refreshed on accepted datapoints so the no-progress
# timeout above stays honest about real cycle progress.
last_consumed_timestamp = time.monotonic()
has_consumed_any_datapoint = True
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],
@@ -231,18 +400,18 @@ def collect_complete_running_sweep_cycles(
wrap, drops datapoints past ``cycle_count``, and reports whether each
datapoint was consumed.
"""
# The controller restarts the sweep before every collection, so the
# first packet each device emits is point 0 of a brand-new cycle 0.
# Anchoring cycle 0 on the first observed point_index==0 — instead of
# synthesising it from a wrap — pins master and slave threads to the
# same physical cycle even if a stale straggler from the just-stopped
# sweep escaped the post-idle drain: such a straggler always carries
# a non-zero point_index and is discarded until the genuine cycle 0
# arrives. From that anchor, each subsequent wrap advances the cycle
# counter normally.
# In steady state the controller leaves the hardware sweep FREE-RUNNING
# between collections (the unchanged-config fast path only drains the host
# queues), so the first datapoint each device emits after a drain is some
# mid-sweep point, not a fresh point 0. Each thread therefore discards
# until the next observed point_index==0 and anchors cycle 0 there. Because
# 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.
cycle_tracking_state = {
"current_cycle_index": 0,
"previous_point_index": -1,
"synchronized": False,
}
@@ -258,14 +427,23 @@ def collect_complete_running_sweep_cycles(
if not cycle_tracking_state["synchronized"]:
if current_point_index != 0:
return False
# Candidate cycle 0. Commit it only once every device confirms it
# 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()):
report_cycle_misalignment()
return False
cycle_tracking_state["synchronized"] = True
cycle_tracking_state["previous_point_index"] = current_point_index
cycle_aware_handler(parsed_datapoint, 0)
return True
if current_point_index < cycle_tracking_state["previous_point_index"]:
# 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
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
@@ -419,7 +597,15 @@ def collect_complete_running_sweep_cycles(
)
if collection_errors:
raise RuntimeError(f"Sweep collection failed: {collection_errors[0]}") from collection_errors[0]
first_error = collection_errors[0]
# Timeouts / no-progress / misalignment leave the devices healthy: surface
# them as transient so the caller re-arms and re-collects instead of doing a
# full USB reopen. A genuine transport failure (e.g. USB death) is fatal.
if _is_transient_collection_error(first_error):
raise TransientCollectionError(
f"Sweep collection failed transiently: {first_error}"
) from first_error
raise RuntimeError(f"Sweep collection failed: {first_error}") from first_error
if slave_device_connections and min(datapoint_counts_by_device_serial.values(), default=0) == 0:
logger.error(
@@ -427,11 +613,16 @@ def collect_complete_running_sweep_cycles(
"(per-device counts: %s)",
datapoint_counts_by_device_serial,
)
raise RuntimeError(
raise TransientCollectionError(
"No datapoints received from at least one device; hardware trigger sync did not start. "
"Check Trigger Out/In loop and 10 MHz reference wiring."
)
if any(rejected_datapoint_counts_by_device_serial.values()):
logger.debug(
"Sweep cycle collection rejected corrupt datapoints (per-device: %s)",
rejected_datapoint_counts_by_device_serial,
)
logger.debug("Sweep cycle collection complete (per-device counts: %s)", datapoint_counts_by_device_serial)
return SweepMeasurementResult(
frequencies_hz=frequencies_hz,
@@ -476,13 +667,23 @@ def calculate_last_cycle_s_parameters(
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(
raise TransientCollectionError(
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(
raise TransientCollectionError(
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]
last_cycle_reference = master_reference_measurements[-1]
near_zero_reference_mask = np.abs(last_cycle_reference) <= _MIN_REFERENCE_MAGNITUDE
if near_zero_reference_mask.any():
# A ~zero incident reference is a corrupt point: dividing by it would
# explode this S-parameter into noise. Reject the cycle for retry.
raise TransientCollectionError(
f"Master port {master_stimulus_port} reference is ~zero for "
f"{int(near_zero_reference_mask.sum())} datapoints (corrupt incident signal)"
)
s_parameters[s_parameter_name.lower()] = raw_receiver_measurements[-1] / last_cycle_reference
return s_parameters
@@ -0,0 +1,15 @@
"""Exception types for multi-device LibreVNA acquisition.
The distinction matters for recovery policy: a ``TransientCollectionError`` means
the devices are still healthy and the sweep merely produced an unusable cycle
(a dropped/incomplete datapoint, a no-progress timeout, or a cross-device cycle
misalignment). Such errors are cheaply recoverable by re-arming and re-collecting
— they must NOT trigger the heavy USB close()/reopen path, which is reserved for
genuine transport death.
"""
from __future__ import annotations
class TransientCollectionError(RuntimeError):
"""A recoverable single-cycle collection failure; retry without reopening USB."""
@@ -2,11 +2,29 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import numpy as np
def normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
"""Validate master stimulus ports and return them as a tuple of ints.
Ports may only be 1 and/or 2, with no duplicates and at least one entry. This
is the single source of truth shared by the protocol builder, the controller,
and the cycle collector so the rule cannot drift between call sites.
"""
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
@dataclass(frozen=True, slots=True)
class SweepConfiguration:
"""User-facing configuration for one VNA sweep."""
@@ -6,7 +6,10 @@ import struct
from collections.abc import Sequence
from dataclasses import dataclass
from python_app.hardware_full.librevna_multi_device_driver.models import SweepConfiguration
from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
normalize_master_stimulus_ports,
)
class PacketType:
@@ -51,13 +54,7 @@ def build_sweep_settings_payload(
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")
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
excitation_power_centidecibels_milliwatt = int(round(sweep_configuration.power_dbm * 100.0))
dwell_time_microseconds = max(0, min(int(sweep_configuration.dwell_us), 0xFFFF))
@@ -109,10 +106,17 @@ def parse_vna_datapoint_payload(payload: bytes) -> ParsedVnaDatapoint | None:
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:
# A genuine VNADatapoint payload is a 12-byte header followed by exactly
# `value_count` blocks of (float32 real, float32 imag, uint8 mask) = 9 bytes.
# Since these frames carry no CRC, a misframe (resync onto a 0x5A inside a
# payload) can still pass framing — reject any payload whose body is not a
# whole number of value blocks, which a random misframe almost never is.
value_bytes = len(payload) - fixed_header_length_bytes
if value_bytes <= 0 or value_bytes % per_value_storage_length_bytes != 0:
return None
value_count = value_bytes // per_value_storage_length_bytes
frequency_hz, _power_level_centidecibels_milliwatt, point_index = struct.unpack_from("<QhH", payload, 0)
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)
@@ -35,7 +35,10 @@ class LibreVnaUsbBulkConnection:
self._transport = USBTransport(
on_data=self._on_data,
on_disconnect=self._on_disconnect,
read_chunk_size=4096,
# Larger bulk reads amortize the per-transfer overhead during the
# high-rate datapoint stream; FrameScanner reassembles frames across
# chunks, so chunk size does not affect decoding.
read_chunk_size=65536,
)
logger.debug("Opening LibreVNA USB connection (serial=%s)", serial_number)
self._transport.connect(serial=serial_number, timeout_s=2.0)
@@ -93,13 +96,16 @@ class LibreVnaUsbBulkConnection:
def _on_data(self, chunk: bytes) -> None:
"""Decode a received USB chunk into frames and queue (type, payload) tuples.
Any decode failure is recorded as the fatal transport error so the next
send/receive call surfaces it to the caller.
``FrameScanner.feed`` already resyncs past malformed/corrupted frames
without raising, so a single bad chunk no longer poisons the device. Any
residual unexpected scanner error is logged and the chunk dropped — genuine
transport death is reported separately through ``_on_disconnect`` — so a
recoverable parsing glitch can never escalate to a full device teardown.
"""
try:
packets = self._scanner.feed(chunk)
except Exception as exc: # noqa: BLE001
self._set_fatal_error(exc)
except Exception as exc: # noqa: BLE001 — never let a parse glitch kill the device
logger.warning("Dropping unparseable USB chunk from %s: %s", self.serial_number, exc)
return
for packet in packets:
self._received_packets.put((int(packet.type), bytes(packet.payload)))