some fixes
This commit is contained in:
+105
-41
@@ -15,9 +15,10 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||
|
||||
@@ -58,33 +59,60 @@ def _panel_extra(details: str | None, once_key: str | None) -> dict[str, object]
|
||||
return {"panel_details": details, "panel_once_key": once_key}
|
||||
|
||||
|
||||
class _PanelLogBridge(QObject):
|
||||
"""Marshals log records from any thread onto the GUI thread for panel rendering.
|
||||
class _PanelLogBuffer:
|
||||
"""Thread-safe bounded buffer between logging handlers and the GUI flush timer.
|
||||
|
||||
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster),
|
||||
but the log widget may only be touched on the GUI thread; emitting this queued
|
||||
signal hands the record across safely (the GPIO-button pattern).
|
||||
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster)
|
||||
at a very high rate — e.g. the USB RX threads while the free-running sweep
|
||||
streams. Posting one queued Qt event per record used to flood the GUI event
|
||||
queue and keep the interface frozen long after a blocking operation finished
|
||||
while the backlog rendered. Instead, records land in this bounded buffer and
|
||||
a periodic GUI-side timer drains them in one batch; overflow drops the oldest
|
||||
records and reports how many were lost.
|
||||
"""
|
||||
|
||||
record = pyqtSignal(str, str, object, object) # display level, message, details, once_key
|
||||
_CAPACITY = 2000
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._entries: deque[tuple[str, str, str | None, str | None]] = deque(maxlen=self._CAPACITY)
|
||||
self._dropped_count = 0
|
||||
|
||||
def append(self, level: str, text: str, details: str | None, once_key: str | None) -> None:
|
||||
"""Store one record, evicting the oldest when full (any thread)."""
|
||||
with self._lock:
|
||||
if len(self._entries) == self._CAPACITY:
|
||||
self._dropped_count += 1
|
||||
self._entries.append((level, text, details, once_key))
|
||||
|
||||
def drain(self) -> tuple[list[tuple[str, str, str | None, str | None]], int]:
|
||||
"""Return and clear all buffered records plus the overflow-drop count."""
|
||||
with self._lock:
|
||||
entries = list(self._entries)
|
||||
self._entries.clear()
|
||||
dropped_count = self._dropped_count
|
||||
self._dropped_count = 0
|
||||
return entries, dropped_count
|
||||
|
||||
|
||||
class _QtLogPanelHandler(logging.Handler):
|
||||
"""Logging handler that forwards application log records to the GUI log panel."""
|
||||
|
||||
def __init__(self, bridge: _PanelLogBridge) -> None:
|
||||
def __init__(self, buffer: _PanelLogBuffer) -> None:
|
||||
super().__init__()
|
||||
self._bridge = bridge
|
||||
self._buffer = buffer
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Forward one record to the panel bridge, mapping WARNING to the short 'WARN'."""
|
||||
"""Buffer one record for the panel, mapping WARNING to the short 'WARN'."""
|
||||
try:
|
||||
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
|
||||
self._bridge.record.emit(
|
||||
details = getattr(record, "panel_details", None)
|
||||
once_key = getattr(record, "panel_once_key", None)
|
||||
self._buffer.append(
|
||||
display_level,
|
||||
record.getMessage(),
|
||||
getattr(record, "panel_details", None),
|
||||
getattr(record, "panel_once_key", None),
|
||||
details if isinstance(details, str) else None,
|
||||
once_key if isinstance(once_key, str) else None,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - logging must never raise into the caller
|
||||
self.handleError(record)
|
||||
@@ -145,23 +173,53 @@ class AppWindow(
|
||||
log_dir = self._project_root / "python_app/runtime/logs"
|
||||
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
|
||||
self._gui_logger = get_logger("gui")
|
||||
self._log_panel_bridge = _PanelLogBridge()
|
||||
self._log_panel_bridge.record.connect(self._on_log_record)
|
||||
self._log_panel_buffer = _PanelLogBuffer()
|
||||
|
||||
def _attach_log_panel(self) -> None:
|
||||
"""Route application log records into the on-screen panel (widget now exists)."""
|
||||
add_handler(_QtLogPanelHandler(self._log_panel_bridge))
|
||||
add_handler(_QtLogPanelHandler(self._log_panel_buffer))
|
||||
# One bounded flush per tick instead of one queued event per record: the
|
||||
# panel can never flood the GUI event queue, no matter how chatty a
|
||||
# DEBUG-level driver gets.
|
||||
self._log_flush_timer = QTimer(self)
|
||||
self._log_flush_timer.setInterval(100)
|
||||
self._log_flush_timer.timeout.connect(self._flush_log_panel_buffer)
|
||||
self._log_flush_timer.start()
|
||||
|
||||
def _on_log_record(self, level: str, text: str, details: object, once_key: object) -> None:
|
||||
"""Render one forwarded log record in the panel (always on the GUI thread)."""
|
||||
if not hasattr(self, "_log_box"):
|
||||
def _flush_log_panel_buffer(self) -> None:
|
||||
"""Render every buffered log record into the panel as one batched insert."""
|
||||
entries, dropped_count = self._log_panel_buffer.drain()
|
||||
if (not entries and not dropped_count) or not hasattr(self, "_log_box"):
|
||||
return
|
||||
self._append_log_entry(
|
||||
level,
|
||||
text,
|
||||
details=details if isinstance(details, str) else None,
|
||||
once_key=once_key if isinstance(once_key, str) else None,
|
||||
|
||||
entry_htmls: list[str] = []
|
||||
if dropped_count:
|
||||
entry_htmls.append(
|
||||
self._render_log_entry_html(
|
||||
"WARN",
|
||||
f"Log panel overflow: {dropped_count} record(s) dropped "
|
||||
"(they are still in the log file).",
|
||||
)
|
||||
)
|
||||
error_seen = False
|
||||
for level, text, details, once_key in entries:
|
||||
if once_key is not None:
|
||||
if once_key in self._logged_once_keys:
|
||||
continue
|
||||
self._logged_once_keys.add(once_key)
|
||||
entry_htmls.append(self._render_log_entry_html(level, text, details))
|
||||
error_seen = error_seen or level.upper() == "ERROR"
|
||||
|
||||
if not entry_htmls:
|
||||
return
|
||||
cursor = self._log_box.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self._log_box.setTextCursor(cursor)
|
||||
self._log_box.insertHtml("".join(entry_htmls))
|
||||
self._log_box.insertPlainText("\n")
|
||||
self._log_box.ensureCursorVisible()
|
||||
if error_seen and hasattr(self, "_status_label"):
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
def _init_runtime_services(self) -> None:
|
||||
"""Initialize long-lived service objects used by mixins."""
|
||||
@@ -266,6 +324,10 @@ class AppWindow(
|
||||
def _init_capture_state(self) -> None:
|
||||
"""Initialize one-shot capture and sequence-control flags."""
|
||||
self._capture_session: SequentialCaptureSession | MultiRadarSequentialCaptureSession | None = None
|
||||
# Guards the blocking per-combo capture against duplicate requests, and keeps
|
||||
# the dialog's action buttons disabled until the post-capture input backlog
|
||||
# is dropped (see AppWindowPreprocessMixin._begin/_end_preprocess_capture).
|
||||
self._preprocess_capture_busy = False
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns: int | None = None
|
||||
@@ -551,20 +613,8 @@ class AppWindow(
|
||||
"""Return full chained traceback for error dialogs and log details."""
|
||||
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
|
||||
|
||||
def _append_log_entry(
|
||||
self,
|
||||
level: str,
|
||||
text: str,
|
||||
*,
|
||||
details: str | None = None,
|
||||
once_key: str | None = None,
|
||||
) -> None:
|
||||
"""Append formatted log entry with timestamp and optional details."""
|
||||
if once_key is not None:
|
||||
if once_key in self._logged_once_keys:
|
||||
return
|
||||
self._logged_once_keys.add(once_key)
|
||||
|
||||
def _render_log_entry_html(self, level: str, text: str, details: str | None = None) -> str:
|
||||
"""Render one log entry as the panel's HTML block."""
|
||||
level_upper = level.upper()
|
||||
palette = {
|
||||
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
|
||||
@@ -586,16 +636,30 @@ class AppWindow(
|
||||
"<pre style='margin:3px 0 0 16px; color:"
|
||||
f"{detail_color};'>{html.escape(details)}</pre>"
|
||||
)
|
||||
return "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
||||
|
||||
def _append_log_entry(
|
||||
self,
|
||||
level: str,
|
||||
text: str,
|
||||
*,
|
||||
details: str | None = None,
|
||||
once_key: str | None = None,
|
||||
) -> None:
|
||||
"""Append formatted log entry with timestamp and optional details."""
|
||||
if once_key is not None:
|
||||
if once_key in self._logged_once_keys:
|
||||
return
|
||||
self._logged_once_keys.add(once_key)
|
||||
|
||||
entry_html = "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
|
||||
cursor = self._log_box.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self._log_box.setTextCursor(cursor)
|
||||
self._log_box.insertHtml(entry_html)
|
||||
self._log_box.insertHtml(self._render_log_entry_html(level, text, details))
|
||||
self._log_box.insertPlainText("\n")
|
||||
self._log_box.ensureCursorVisible()
|
||||
|
||||
if level_upper == "ERROR" and hasattr(self, "_status_label"):
|
||||
if level.upper() == "ERROR" and hasattr(self, "_status_label"):
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
def _on_log_level_selected(self, level_text: str) -> None:
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.gui.trace_png_export import export_trace_png
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
@@ -519,13 +521,23 @@ class AppWindowPreprocessMixin:
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
# The capture blocks the event loop, so clicks made during it are delivered
|
||||
# only after it finishes. `_begin_preprocess_capture` disables the action
|
||||
# buttons for that whole window (re-enabled via a posted event), so a queued
|
||||
# click lands on a disabled button instead of silently starting — and
|
||||
# advancing the combo cursor of — another capture.
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
try:
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _capture_all_remaining(self) -> None:
|
||||
"""Capture all remaining combos for the active preprocess session."""
|
||||
@@ -539,6 +551,8 @@ class AppWindowPreprocessMixin:
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
@@ -547,6 +561,7 @@ class AppWindowPreprocessMixin:
|
||||
f"{display_name} batch capture started: remaining="
|
||||
f"{session.state().total_count - session.state().captured_count}"
|
||||
)
|
||||
try:
|
||||
while not session.is_complete():
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
@@ -554,6 +569,32 @@ class AppWindowPreprocessMixin:
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _begin_preprocess_capture(self) -> bool:
|
||||
"""Mark a blocking combo capture as running; refuse when one already is.
|
||||
|
||||
Returns False for a duplicate request (e.g. a click delivered while an
|
||||
error dialog inside a capture pumps the event loop).
|
||||
"""
|
||||
if self._preprocess_capture_busy:
|
||||
self._log("Preprocess combo capture already in progress; ignoring duplicate request.")
|
||||
return False
|
||||
self._preprocess_capture_busy = True
|
||||
# Disable the sequence action buttons for the whole blocked window.
|
||||
self._update_capture_dialog_state()
|
||||
return True
|
||||
|
||||
def _end_preprocess_capture(self) -> None:
|
||||
"""Re-enable capture actions after the pending input backlog is discarded.
|
||||
|
||||
The zero-delay timer fires only after Qt has dispatched the window-system
|
||||
events queued while the capture blocked the loop; those clicks hit the
|
||||
still-disabled buttons and are dropped, then the buttons come back.
|
||||
"""
|
||||
self._preprocess_capture_busy = False
|
||||
QTimer.singleShot(0, self._update_capture_dialog_state)
|
||||
|
||||
def _on_capture_combo_failed(
|
||||
self,
|
||||
@@ -817,6 +858,10 @@ class AppWindowPreprocessMixin:
|
||||
and state.current_combo is not None
|
||||
),
|
||||
variant_count=state.variant_count,
|
||||
# While a blocking capture is executing, every action stays disabled no
|
||||
# matter what the session state allows: clicks queued during the freeze
|
||||
# must land on disabled buttons (see `_end_preprocess_capture`).
|
||||
actions_enabled=not self._preprocess_capture_busy,
|
||||
)
|
||||
|
||||
def _cleanup_capture_session(self) -> None:
|
||||
|
||||
@@ -354,8 +354,14 @@ class PreprocessDialog(QDialog):
|
||||
can_finalize: bool,
|
||||
can_capture_all: bool,
|
||||
variant_count: int = 1,
|
||||
actions_enabled: bool = True,
|
||||
) -> None:
|
||||
"""Update sequence progress/status widgets."""
|
||||
"""Update sequence progress/status widgets.
|
||||
|
||||
With ``actions_enabled=False`` the progress labels still update but every
|
||||
sequence action button is kept disabled — used while a blocking capture
|
||||
runs, so input queued during the freeze cannot trigger another action.
|
||||
"""
|
||||
if kind is None:
|
||||
self._active_kind_label.setText("<none>")
|
||||
self._progress_label.setText("0 / 0")
|
||||
@@ -368,13 +374,14 @@ class PreprocessDialog(QDialog):
|
||||
self._capture_all_button.setText("Capture All Remaining")
|
||||
return
|
||||
|
||||
actions_enabled = bool(actions_enabled)
|
||||
active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind
|
||||
self._active_kind_label.setText(active_label)
|
||||
self._progress_label.setText(f"{captured_count} / {total_count}")
|
||||
self._undo_last_button.setEnabled(bool(can_undo))
|
||||
self._save_sequence_button.setEnabled(bool(can_finalize))
|
||||
self._capture_all_button.setEnabled(bool(can_capture_all))
|
||||
self._abort_button.setEnabled(True)
|
||||
self._undo_last_button.setEnabled(bool(can_undo) and actions_enabled)
|
||||
self._save_sequence_button.setEnabled(bool(can_finalize) and actions_enabled)
|
||||
self._capture_all_button.setEnabled(bool(can_capture_all) and actions_enabled)
|
||||
self._abort_button.setEnabled(actions_enabled)
|
||||
self._capture_all_button.setText("Capture All Remaining")
|
||||
if next_input is None or next_output is None:
|
||||
self._combo_label.setText("<complete>")
|
||||
@@ -384,7 +391,7 @@ class PreprocessDialog(QDialog):
|
||||
if int(variant_count) > 1:
|
||||
combo_text += f" | radar configs={int(variant_count)}"
|
||||
self._combo_label.setText(combo_text)
|
||||
self._capture_next_button.setEnabled(True)
|
||||
self._capture_next_button.setEnabled(actions_enabled)
|
||||
|
||||
def set_available_sets(self, available_sets: dict[str, list[str]]) -> None:
|
||||
"""Replace combo-box choices for all preprocess assets."""
|
||||
|
||||
@@ -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,6 +83,64 @@ class SwitchedMatrixRadarService:
|
||||
|
||||
for out_k in range(out_steps):
|
||||
for in_k in range(in_steps):
|
||||
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)
|
||||
raise RuntimeError(
|
||||
f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing"
|
||||
)
|
||||
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=[trace for trace in slots if trace is not None],
|
||||
capture_start_ns=capture_start_ns,
|
||||
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)
|
||||
@@ -113,26 +171,17 @@ class SwitchedMatrixRadarService:
|
||||
(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)
|
||||
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
|
||||
]
|
||||
|
||||
if any(trace is None for trace in slots):
|
||||
missing = sum(1 for trace in slots if trace is None)
|
||||
raise RuntimeError(
|
||||
f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing"
|
||||
)
|
||||
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=[trace for trace in slots if trace is not None],
|
||||
capture_start_ns=capture_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
def build_physical_switch(
|
||||
model: SwitchModel,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Unit tests for the bounded GUI log-panel buffer.
|
||||
|
||||
The buffer decouples logging handlers (any thread, potentially very chatty at
|
||||
DEBUG) from the GUI: records are batched by a flush timer instead of posting one
|
||||
queued Qt event per record, and overflow drops the oldest records with a count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from python_app.gui.app_window import _PanelLogBuffer
|
||||
|
||||
|
||||
class PanelLogBufferTest(unittest.TestCase):
|
||||
"""Bounded capacity, oldest-first eviction, and accurate drop accounting."""
|
||||
|
||||
def test_drain_returns_entries_in_order_and_clears(self) -> None:
|
||||
buffer = _PanelLogBuffer()
|
||||
buffer.append("INFO", "first", None, None)
|
||||
buffer.append("WARN", "second", "details", "key")
|
||||
|
||||
entries, dropped_count = buffer.drain()
|
||||
|
||||
self.assertEqual(dropped_count, 0)
|
||||
self.assertEqual(
|
||||
entries,
|
||||
[("INFO", "first", None, None), ("WARN", "second", "details", "key")],
|
||||
)
|
||||
self.assertEqual(buffer.drain(), ([], 0))
|
||||
|
||||
def test_overflow_drops_oldest_and_counts(self) -> None:
|
||||
buffer = _PanelLogBuffer()
|
||||
overflow = 100
|
||||
total = _PanelLogBuffer._CAPACITY + overflow
|
||||
for index in range(total):
|
||||
buffer.append("DEBUG", f"m{index}", None, None)
|
||||
|
||||
entries, dropped_count = buffer.drain()
|
||||
|
||||
self.assertEqual(dropped_count, overflow)
|
||||
self.assertEqual(len(entries), _PanelLogBuffer._CAPACITY)
|
||||
self.assertEqual(entries[0][1], f"m{overflow}")
|
||||
self.assertEqual(entries[-1][1], f"m{total - 1}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit tests for switch-widened matrix capture.
|
||||
|
||||
Cover the targeted single-step acquisition on ``SwitchedMatrixRadarService`` and
|
||||
verify the manual per-combo capture workflow uses it instead of sweeping the full
|
||||
widened matrix (the regression that froze the GUI for the whole matrix per click).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
_INNER_INPUTS = 4
|
||||
_INNER_OUTPUTS = 2
|
||||
_POINTS = 8
|
||||
|
||||
|
||||
class _FakeInnerMatrixRadar:
|
||||
"""Matrix radar stub emitting the canonical 2x4 combo set per acquisition."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.acquire_count = 0
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def configure(self, sweep) -> None:
|
||||
pass
|
||||
|
||||
def recover(self) -> None:
|
||||
pass
|
||||
|
||||
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
|
||||
self.acquire_count += 1
|
||||
frequency_hz = np.linspace(1e6, 2e6, _POINTS, dtype=np.float32)
|
||||
traces = [
|
||||
TraceData(
|
||||
combo=ComboKey(input=input_pos, output=output_pos),
|
||||
frequency_hz=frequency_hz,
|
||||
s11=np.full(_POINTS, complex(self.acquire_count, 0), dtype=np.complex64),
|
||||
s21=np.full(_POINTS, complex(input_pos, output_pos), dtype=np.complex64),
|
||||
)
|
||||
for output_pos in range(_INNER_OUTPUTS)
|
||||
for input_pos in range(_INNER_INPUTS)
|
||||
]
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=traces,
|
||||
)
|
||||
|
||||
|
||||
class _FakeSwitch:
|
||||
"""Switch stub recording every position it is driven to."""
|
||||
|
||||
def __init__(self, positions: int) -> None:
|
||||
self.positions = positions
|
||||
self.switched_to: list[int] = []
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def position_count(self) -> int:
|
||||
return self.positions
|
||||
|
||||
def switch_to(self, position: int) -> None:
|
||||
self.switched_to.append(int(position))
|
||||
|
||||
|
||||
def _switched_service(input_steps: int = 3) -> tuple[SwitchedMatrixRadarService, _FakeInnerMatrixRadar, _FakeSwitch]:
|
||||
inner = _FakeInnerMatrixRadar()
|
||||
input_switch = _FakeSwitch(input_steps)
|
||||
service = SwitchedMatrixRadarService(
|
||||
inner=inner,
|
||||
output_switch=None,
|
||||
input_switch=input_switch,
|
||||
inner_output_positions=_INNER_OUTPUTS,
|
||||
inner_input_positions=_INNER_INPUTS,
|
||||
settling_ms=0,
|
||||
)
|
||||
return service, inner, input_switch
|
||||
|
||||
|
||||
class SwitchedMatrixComboAcquisitionTest(unittest.TestCase):
|
||||
"""acquire_combo_collection must acquire exactly one physical switch step."""
|
||||
|
||||
def test_acquires_only_the_step_containing_the_combo(self) -> None:
|
||||
service, inner, input_switch = _switched_service(input_steps=3)
|
||||
|
||||
# Widened input 9 lives in physical step 9 // 4 = 2.
|
||||
collection = service.acquire_combo_collection(input_pos=9, output_pos=1)
|
||||
|
||||
self.assertEqual(inner.acquire_count, 1)
|
||||
self.assertEqual(input_switch.switched_to, [2])
|
||||
self.assertEqual(len(collection.traces), _INNER_INPUTS * _INNER_OUTPUTS)
|
||||
combos = {(trace.combo.input, trace.combo.output) for trace in collection.traces}
|
||||
self.assertIn((9, 1), combos)
|
||||
# Every trace of the step is remapped into the widened axis of that step.
|
||||
self.assertEqual(
|
||||
combos,
|
||||
{(2 * _INNER_INPUTS + i, o) for i in range(_INNER_INPUTS) for o in range(_INNER_OUTPUTS)},
|
||||
)
|
||||
|
||||
def test_rejects_out_of_range_combo(self) -> None:
|
||||
service, _inner, _input_switch = _switched_service(input_steps=3)
|
||||
with self.assertRaises(ValueError):
|
||||
service.acquire_combo_collection(input_pos=12, output_pos=0)
|
||||
with self.assertRaises(ValueError):
|
||||
service.acquire_combo_collection(input_pos=0, output_pos=2)
|
||||
|
||||
def test_full_collection_still_covers_widened_matrix_in_canonical_order(self) -> None:
|
||||
service, inner, input_switch = _switched_service(input_steps=3)
|
||||
|
||||
collection = service.acquire_collection(collection_id=7)
|
||||
|
||||
self.assertEqual(inner.acquire_count, 3)
|
||||
self.assertEqual(input_switch.switched_to, [0, 1, 2])
|
||||
expected_combos = [
|
||||
(input_pos, output_pos)
|
||||
for output_pos in range(_INNER_OUTPUTS)
|
||||
for input_pos in range(3 * _INNER_INPUTS)
|
||||
]
|
||||
self.assertEqual(
|
||||
[(trace.combo.input, trace.combo.output) for trace in collection.traces],
|
||||
expected_combos,
|
||||
)
|
||||
|
||||
|
||||
class ManualComboCaptureUsesTargetedAcquisitionTest(unittest.TestCase):
|
||||
"""The per-combo capture session must not sweep the full widened matrix."""
|
||||
|
||||
@staticmethod
|
||||
def _switched_mock_config() -> RunConfigModel:
|
||||
config = RunConfigModel()
|
||||
config.radar.model = RunConfigModel.LIBREVNA_MULTI_MODEL
|
||||
config.radar.driver_mode = "mock"
|
||||
config.radar.multi_device.slave_serials = ["SLAVE_A", "SLAVE_B"]
|
||||
config.radar.multi_device.input_switch_positions = 3
|
||||
config.apply_device_model_constraints()
|
||||
return config
|
||||
|
||||
def test_manual_capture_runs_one_inner_collection_per_median_sweep(self) -> None:
|
||||
config = self._switched_mock_config()
|
||||
session = SequentialCaptureSession(
|
||||
config=config,
|
||||
kind="s21_calibration",
|
||||
set_name="targeted_test",
|
||||
median_sweep_count=2,
|
||||
)
|
||||
with mock.patch.object(
|
||||
MultiDeviceLibreVnaService,
|
||||
"acquire_collection",
|
||||
autospec=True,
|
||||
side_effect=MultiDeviceLibreVnaService.acquire_collection,
|
||||
) as inner_acquire:
|
||||
session.open()
|
||||
try:
|
||||
trace = session.capture_current_combo()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
first_combo = config.combos[0]
|
||||
self.assertEqual(
|
||||
(trace.combo.input, trace.combo.output),
|
||||
(first_combo.input, first_combo.output),
|
||||
)
|
||||
# 2 median sweeps of ONE physical step — not 2 x 3 full-matrix steps.
|
||||
self.assertEqual(inner_acquire.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,6 +19,7 @@ from python_app.workflows.radar_config_variants import RadarConfigVariant
|
||||
from python_app.workflows.sequential_capture_workflow import (
|
||||
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
|
||||
SequentialCaptureState,
|
||||
acquire_matrix_combo_collection,
|
||||
combine_collections_via_median,
|
||||
combine_traces_via_median,
|
||||
select_trace_for_combo,
|
||||
@@ -192,6 +193,19 @@ class MultiRadarSequentialCaptureSession:
|
||||
self._radar.configure(variant.config.radar.sweep)
|
||||
if self._base_config.runtime.settling_ms > 0:
|
||||
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
||||
if self._manual_matrix_radar_capture:
|
||||
# Only this combo is kept, so acquire the smallest collection
|
||||
# that contains it instead of the full (switch-widened) matrix.
|
||||
per_sweep_traces = [
|
||||
select_trace_for_combo(
|
||||
acquire_matrix_combo_collection(self._radar, combo), combo
|
||||
)
|
||||
for _ in range(self._median_sweep_count)
|
||||
]
|
||||
trace = combine_traces_via_median(per_sweep_traces)
|
||||
pending_traces_by_radar_key[variant.radar_key] = [trace]
|
||||
display_traces.append(trace)
|
||||
else:
|
||||
collections: list[SweepCollection] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
collection = self._radar.acquire_collection(collection_id=1)
|
||||
@@ -200,12 +214,6 @@ class MultiRadarSequentialCaptureSession:
|
||||
f"Matrix radar variant {variant.display_name} returned no traces"
|
||||
)
|
||||
collections.append(collection)
|
||||
if self._manual_matrix_radar_capture:
|
||||
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
|
||||
trace = combine_traces_via_median(per_sweep_traces)
|
||||
pending_traces_by_radar_key[variant.radar_key] = [trace]
|
||||
display_traces.append(trace)
|
||||
else:
|
||||
combined_collection = combine_collections_via_median(collections)
|
||||
pending_traces_by_radar_key[variant.radar_key] = list(combined_collection.traces)
|
||||
display_traces.append(combined_collection.traces[-1])
|
||||
|
||||
@@ -156,20 +156,27 @@ class SequentialCaptureSession:
|
||||
raise RuntimeError("Capture session is already complete")
|
||||
|
||||
if self._is_matrix_radar:
|
||||
collections: list[SweepCollection] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
collection = self._radar.acquire_collection(collection_id=1)
|
||||
if not collection.traces:
|
||||
raise RuntimeError("Matrix radar capture returned no traces")
|
||||
collections.append(collection)
|
||||
if self._manual_matrix_radar_capture:
|
||||
per_sweep_traces = [select_trace_for_combo(collection, combo) for collection in collections]
|
||||
# Only this combo is kept, so acquire the smallest collection that
|
||||
# contains it instead of the full (switch-widened) matrix.
|
||||
per_sweep_traces = [
|
||||
select_trace_for_combo(
|
||||
acquire_matrix_combo_collection(self._radar, combo), combo
|
||||
)
|
||||
for _ in range(self._median_sweep_count)
|
||||
]
|
||||
trace = combine_traces_via_median(per_sweep_traces)
|
||||
self._traces.append(trace)
|
||||
self._next_index += 1
|
||||
logger.debug("Captured matrix combo input=%d output=%d", combo.input, combo.output)
|
||||
return trace
|
||||
|
||||
collections: list[SweepCollection] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
collection = self._radar.acquire_collection(collection_id=1)
|
||||
if not collection.traces:
|
||||
raise RuntimeError("Matrix radar capture returned no traces")
|
||||
collections.append(collection)
|
||||
combined_collection = combine_collections_via_median(collections)
|
||||
self._traces.extend(combined_collection.traces)
|
||||
self._next_index = len(self._combos)
|
||||
@@ -301,6 +308,32 @@ class SequentialCaptureSession:
|
||||
return self._combos[self._next_index]
|
||||
|
||||
|
||||
def acquire_matrix_combo_collection(
|
||||
radar: MatrixRadarService,
|
||||
combo: ComboModel,
|
||||
collection_id: int = 1,
|
||||
) -> SweepCollection:
|
||||
"""Acquire the smallest matrix collection that contains one combo.
|
||||
|
||||
A switch-widened matrix radar (``SwitchedMatrixRadarService``) can acquire just
|
||||
the physical switch step carrying the combo, which is several times faster than
|
||||
the full matrix and keeps the per-combo capture UI responsive. Plain matrix
|
||||
radars expose only full-matrix acquisition, so they fall back to it.
|
||||
"""
|
||||
acquire_combo = getattr(radar, "acquire_combo_collection", None)
|
||||
if callable(acquire_combo):
|
||||
collection = acquire_combo(
|
||||
input_pos=int(combo.input),
|
||||
output_pos=int(combo.output),
|
||||
collection_id=collection_id,
|
||||
)
|
||||
else:
|
||||
collection = radar.acquire_collection(collection_id=collection_id)
|
||||
if not collection.traces:
|
||||
raise RuntimeError("Matrix radar capture returned no traces")
|
||||
return collection
|
||||
|
||||
|
||||
def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> TraceData:
|
||||
"""Return the trace matching a virtual combo from a full multi-device capture."""
|
||||
for trace in collection.traces:
|
||||
|
||||
Reference in New Issue
Block a user