some fixes
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from ..exceptions import DeviceDisconnectedError, TimeoutError
|
||||
@@ -44,6 +45,10 @@ class USBTransport:
|
||||
self._rx_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._tx_lock = threading.Lock()
|
||||
# Aggregation window for the RX debug trace (see `_rx_loop`).
|
||||
self._rx_debug_bytes = 0
|
||||
self._rx_debug_chunks = 0
|
||||
self._rx_debug_window_start = 0.0
|
||||
|
||||
self.connected_serial: str | None = None
|
||||
|
||||
@@ -270,7 +275,27 @@ class USBTransport:
|
||||
|
||||
if data:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("USB RX %d bytes", len(data))
|
||||
# Aggregate: the free-running datapoint stream completes bulk
|
||||
# reads hundreds of times per second, and a log record per chunk
|
||||
# floods every handler (file, stderr, and the GUI panel, which
|
||||
# marshals each record onto the GUI thread). One summary per
|
||||
# second keeps the throughput trace without the flood.
|
||||
self._rx_debug_bytes += len(data)
|
||||
self._rx_debug_chunks += 1
|
||||
now = time.monotonic()
|
||||
if self._rx_debug_window_start == 0.0:
|
||||
self._rx_debug_window_start = now
|
||||
elif now - self._rx_debug_window_start >= 1.0:
|
||||
logger.debug(
|
||||
"USB RX %d bytes in %d chunks over %.2f s (serial=%s)",
|
||||
self._rx_debug_bytes,
|
||||
self._rx_debug_chunks,
|
||||
now - self._rx_debug_window_start,
|
||||
self.connected_serial,
|
||||
)
|
||||
self._rx_debug_bytes = 0
|
||||
self._rx_debug_chunks = 0
|
||||
self._rx_debug_window_start = now
|
||||
self._on_data(bytes(data))
|
||||
logger.debug("USB RX thread stopped")
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ from python_app.hardware_full.librevna_multi_device_driver.protocol import Packe
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The sweep free-runs by design, so devices stream datapoints continuously even
|
||||
# while no acquisition is consuming them (e.g. an operator pausing between manual
|
||||
# combo captures). An unbounded queue then grows without limit — hundreds of MB
|
||||
# over a few minutes — and the next acquisition's drain spends seconds discarding
|
||||
# the backlog on the GUI thread. Bound the queue and drop the OLDEST packet on
|
||||
# overflow: every acquisition drains stale packets before collecting anyway, and
|
||||
# whenever packets actually matter (ACK waits, cycle collection) a consumer is
|
||||
# already pulling, so the queue never approaches the bound. Sized to hold many
|
||||
# full sweeps of datapoints with a wide margin.
|
||||
_RECEIVED_PACKET_QUEUE_MAX = 32768
|
||||
|
||||
|
||||
class LibreVnaUsbBulkConnection:
|
||||
"""Minimal packet transport for one LibreVNA device."""
|
||||
@@ -29,7 +40,9 @@ class LibreVnaUsbBulkConnection:
|
||||
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._received_packets: queue.Queue[tuple[int, bytes]] = queue.Queue(
|
||||
maxsize=_RECEIVED_PACKET_QUEUE_MAX
|
||||
)
|
||||
self._fatal_error: Exception | None = None
|
||||
self._fatal_lock = threading.Lock()
|
||||
self._transport = USBTransport(
|
||||
@@ -108,7 +121,20 @@ class LibreVnaUsbBulkConnection:
|
||||
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)))
|
||||
entry = (int(packet.type), bytes(packet.payload))
|
||||
while True:
|
||||
try:
|
||||
self._received_packets.put_nowait(entry)
|
||||
break
|
||||
except queue.Full:
|
||||
# Blocking here would stall the USB read thread; discard the
|
||||
# oldest packet instead — stale data is what the pre-collect
|
||||
# drain throws away anyway. Racing a concurrent consumer just
|
||||
# means the queue already has room again.
|
||||
try:
|
||||
self._received_packets.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def _on_disconnect(self, exc: Exception) -> None:
|
||||
"""Record an asynchronous transport disconnect as the fatal error."""
|
||||
|
||||
@@ -83,42 +83,8 @@ class SwitchedMatrixRadarService:
|
||||
|
||||
for out_k in range(out_steps):
|
||||
for in_k in range(in_steps):
|
||||
step_start_ns = time.monotonic_ns()
|
||||
if self.output_switch is not None:
|
||||
self.output_switch.switch_to(out_k)
|
||||
if self.input_switch is not None:
|
||||
self.input_switch.switch_to(in_k)
|
||||
switched_ns = time.monotonic_ns()
|
||||
# Settle AFTER the last switch change and BEFORE collecting, so the
|
||||
# cycle we anchor on starts with the RF path already stable.
|
||||
if self.settling_ms > 0:
|
||||
time.sleep(self.settling_ms / 1000.0)
|
||||
settled_ns = time.monotonic_ns()
|
||||
|
||||
sub = self.inner.acquire_collection(collection_id)
|
||||
inner_end_ns = time.monotonic_ns()
|
||||
logger.debug(
|
||||
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
|
||||
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
|
||||
collection_id,
|
||||
out_k,
|
||||
in_k,
|
||||
(
|
||||
f"{(step_start_ns - self._last_inner_end_ns) / 1e6:.2f}"
|
||||
if self._last_inner_end_ns
|
||||
else "n/a"
|
||||
),
|
||||
(switched_ns - step_start_ns) / 1e6,
|
||||
(settled_ns - switched_ns) / 1e6,
|
||||
(inner_end_ns - settled_ns) / 1e6,
|
||||
)
|
||||
self._last_inner_end_ns = inner_end_ns
|
||||
for trace in sub.traces:
|
||||
input_pos = in_k * self.inner_input_positions + int(trace.combo.input)
|
||||
output_pos = out_k * self.inner_output_positions + int(trace.combo.output)
|
||||
slots[output_pos * total_inputs + input_pos] = replace(
|
||||
trace, combo=ComboKey(input=input_pos, output=output_pos)
|
||||
)
|
||||
for trace in self._acquire_step_traces(out_k, in_k, collection_id):
|
||||
slots[trace.combo.output * total_inputs + trace.combo.input] = trace
|
||||
|
||||
if any(trace is None for trace in slots):
|
||||
missing = sum(1 for trace in slots if trace is None)
|
||||
@@ -134,6 +100,89 @@ class SwitchedMatrixRadarService:
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
def acquire_combo_collection(
|
||||
self,
|
||||
*,
|
||||
input_pos: int,
|
||||
output_pos: int,
|
||||
collection_id: int = 1,
|
||||
) -> SweepCollection:
|
||||
"""Acquire only the physical switch step that carries one widened combo.
|
||||
|
||||
The per-combo capture workflows need a single trace at a time; sweeping
|
||||
every switch position for that (a full ``acquire_collection``) multiplies
|
||||
the capture time by the number of physical steps and freezes the caller
|
||||
for the whole sweep. One widened combo lives entirely inside one
|
||||
(out_k, in_k) step, so acquiring just that step is sufficient. The result
|
||||
contains that step's traces with widened combo keys, including the
|
||||
requested combo.
|
||||
"""
|
||||
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
|
||||
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
|
||||
total_inputs = in_steps * self.inner_input_positions
|
||||
total_outputs = out_steps * self.inner_output_positions
|
||||
if not (0 <= int(input_pos) < total_inputs and 0 <= int(output_pos) < total_outputs):
|
||||
raise ValueError(
|
||||
f"Widened combo out of range: input={input_pos} (of {total_inputs}), "
|
||||
f"output={output_pos} (of {total_outputs})"
|
||||
)
|
||||
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
out_k = int(output_pos) // self.inner_output_positions
|
||||
in_k = int(input_pos) // self.inner_input_positions
|
||||
traces = self._acquire_step_traces(out_k, in_k, collection_id)
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=traces,
|
||||
capture_start_ns=capture_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
def _acquire_step_traces(self, out_k: int, in_k: int, collection_id: int) -> list[TraceData]:
|
||||
"""Drive both switches to one step, settle, and collect its widened traces."""
|
||||
step_start_ns = time.monotonic_ns()
|
||||
if self.output_switch is not None:
|
||||
self.output_switch.switch_to(out_k)
|
||||
if self.input_switch is not None:
|
||||
self.input_switch.switch_to(in_k)
|
||||
switched_ns = time.monotonic_ns()
|
||||
# Settle AFTER the last switch change and BEFORE collecting, so the
|
||||
# cycle we anchor on starts with the RF path already stable.
|
||||
if self.settling_ms > 0:
|
||||
time.sleep(self.settling_ms / 1000.0)
|
||||
settled_ns = time.monotonic_ns()
|
||||
|
||||
sub = self.inner.acquire_collection(collection_id)
|
||||
inner_end_ns = time.monotonic_ns()
|
||||
logger.debug(
|
||||
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
|
||||
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
|
||||
collection_id,
|
||||
out_k,
|
||||
in_k,
|
||||
(
|
||||
f"{(step_start_ns - self._last_inner_end_ns) / 1e6:.2f}"
|
||||
if self._last_inner_end_ns
|
||||
else "n/a"
|
||||
),
|
||||
(switched_ns - step_start_ns) / 1e6,
|
||||
(settled_ns - switched_ns) / 1e6,
|
||||
(inner_end_ns - settled_ns) / 1e6,
|
||||
)
|
||||
self._last_inner_end_ns = inner_end_ns
|
||||
return [
|
||||
replace(
|
||||
trace,
|
||||
combo=ComboKey(
|
||||
input=in_k * self.inner_input_positions + int(trace.combo.input),
|
||||
output=out_k * self.inner_output_positions + int(trace.combo.output),
|
||||
),
|
||||
)
|
||||
for trace in sub.traces
|
||||
]
|
||||
|
||||
|
||||
def build_physical_switch(
|
||||
model: SwitchModel,
|
||||
physical_positions: int,
|
||||
|
||||
Reference in New Issue
Block a user