added median sweep and fixed multi device issue

This commit is contained in:
Ayzen
2026-05-20 16:52:06 +03:00
parent 0da8b1283c
commit 1c544aa582
29 changed files with 444 additions and 126 deletions
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Iterator, Sequence
from dataclasses import replace
from typing import Optional
import threading
import time
from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import (
@@ -85,7 +86,17 @@ class MultiDeviceVnaController:
*,
master_stimulus_ports: Sequence[int] = (1, 2),
) -> None:
"""Apply reference/sweep settings and leave devices sweeping."""
"""Apply reference/sweep settings and leave devices sweeping.
When the requested configuration already matches the running sweep,
the host-side packet queues are drained and the device sweep is
left untouched, so back-to-back acquires do not pay the SET_IDLE +
SWEEP_SETTINGS round-trip cost. The drain is performed in parallel
across devices to keep the cross-device timing skew below the USB
latency variance, which is what previously let a hardware cycle
wrap slip between master and slave drains and desynchronise the
per-device cycle counters.
"""
if self._is_closed:
raise RuntimeError("Controller is already closed")
stimulus_ports = self._normalize_master_stimulus_ports(master_stimulus_ports)
@@ -93,12 +104,6 @@ class MultiDeviceVnaController:
if not self._reference_configuration_applied:
self._configure_reference_clocks()
# Even when the device-side configuration matches and we skip reconfiguration,
# the host-side packet queue has been accumulating datapoints from cycles that
# ran between calls. Draining here guarantees the next collect_running_sweep_cycles
# returns a freshly-arriving cycle (the cycle tracker waits for point_index==0).
# Without this drain, callers would receive whichever stale cycle happened to be
# at the head of the queue — e.g. data from before a manual cable swap.
self._drain_all_received_packets()
if (
@@ -111,6 +116,10 @@ class MultiDeviceVnaController:
if self._sweep_is_running:
self._send_idle_to_all_devices()
time.sleep(self._reconfigure_delay_s)
# The old sweep keeps streaming datapoints until each device
# processes SET_IDLE. Drain again after the idle settling delay
# so the new sweep starts on an empty queue.
self._drain_all_received_packets()
self._configure_sweep_on_all_devices(
sweep_configuration,
@@ -192,12 +201,16 @@ class MultiDeviceVnaController:
pass
def _send_idle_to_all_devices(self) -> None:
# SET_IDLE is a one-shot stop command. The ACK may be delayed only by the
# in-flight datapoint queue, which drains within a few hundred ms. A short,
# single-shot timeout keeps recovery snappy when one device stops responding
# so callers (e.g. multi-radar capture) do not block for minutes on retries.
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,
timeout_seconds=1.0,
retry_count=0,
)
self._sweep_is_running = False
@@ -245,8 +258,30 @@ class MultiDeviceVnaController:
self._sweep_is_running = True
def _drain_all_received_packets(self) -> None:
for device_connection in self._all_devices:
device_connection.drain_received_packets()
# Drain every device queue in parallel rather than one after another:
# serial drain leaves up to a few hundred microseconds of skew between
# the master and slave drain moments, which is enough room for a
# hardware cycle wrap to slip between drains and desynchronise the
# per-device cycle counters. Each device has its own queue and lock,
# so concurrent get_nowait calls do not contend. A single device case
# just runs inline to avoid the thread-spawn overhead.
if len(self._all_devices) < 2:
for device_connection in self._all_devices:
device_connection.drain_received_packets()
return
drain_threads = [
threading.Thread(
target=device_connection.drain_received_packets,
name=f"drain-{device_connection.serial_number}",
daemon=True,
)
for device_connection in self._all_devices
]
for drain_thread in drain_threads:
drain_thread.start()
for drain_thread in drain_threads:
drain_thread.join()
@staticmethod
def _normalize_master_stimulus_ports(master_stimulus_ports: Sequence[int]) -> tuple[int, ...]:
@@ -69,6 +69,13 @@ def collect_complete_running_sweep_cycles(
else:
datapoint_timeout_seconds = max(0.5, float(datapoint_timeout_seconds))
# Upper bound on how long we wait for the first cycle-start datapoint
# (point_index==0). Without it, a device that keeps streaming non-zero
# indices but never wraps (e.g. after a misconfigured sweep restart)
# would refresh `last_datapoint_timestamp` on every incoming packet and
# stall capture indefinitely.
cycle_start_guard_seconds = max(2.0, datapoint_timeout_seconds * 4.0)
def collect_datapoints_from_device(
device_connection: LibreVnaUsbBulkConnection,
handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
@@ -76,12 +83,15 @@ def collect_complete_running_sweep_cycles(
datapoints_received = 0
expected_datapoint_count = cycle_count * point_count
last_datapoint_timestamp = time.monotonic()
collection_loop_start = last_datapoint_timestamp
has_consumed_any_datapoint = False
while datapoints_received < expected_datapoint_count:
if stop_collection_requested.is_set():
return
remaining_timeout_seconds = (last_datapoint_timestamp + datapoint_timeout_seconds) - time.monotonic()
now = time.monotonic()
remaining_timeout_seconds = (last_datapoint_timestamp + datapoint_timeout_seconds) - now
if remaining_timeout_seconds <= 0:
collection_errors.append(
TimeoutError(
@@ -93,6 +103,17 @@ def collect_complete_running_sweep_cycles(
stop_collection_requested.set()
return
if not has_consumed_any_datapoint and (now - collection_loop_start) > cycle_start_guard_seconds:
collection_errors.append(
TimeoutError(
f"Device {device_connection.serial_number} streamed datapoints but never "
f"reached point_index=0 within {cycle_start_guard_seconds:.1f} s "
f"(sweep cycle did not restart)"
)
)
stop_collection_requested.set()
return
try:
packet_type, payload = device_connection.receive_packet(
timeout_seconds=min(1.0, remaining_timeout_seconds)
@@ -118,35 +139,41 @@ def collect_complete_running_sweep_cycles(
last_datapoint_timestamp = time.monotonic()
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
if datapoint_was_consumed:
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],
) -> Callable[[ParsedVnaDatapoint], bool]:
# 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.
cycle_tracking_state = {
"current_cycle_index": 0,
"previous_point_index": -1,
"has_seen_cycle_start": False,
"synchronized": 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 not cycle_tracking_state["synchronized"]:
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["synchronized"] = 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"]
):
if 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:
@@ -163,7 +163,7 @@ class MultiDeviceLibreVnaService:
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),
combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequencies,
s11=reflection,
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name),
@@ -200,7 +200,7 @@ class MultiDeviceLibreVnaService:
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),
combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequencies,
s11=s11,
s21=s21,