added median sweep and fixed multi device issue
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user