improved logging

This commit is contained in:
Ayzen
2026-06-06 00:52:52 +03:00
parent af6005d68f
commit aea49f6128
65 changed files with 1206 additions and 240 deletions
+30 -2
View File
@@ -110,14 +110,18 @@ class KamilAdcTtyReader:
daemon=True,
)
self._thread.start()
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
def close(self) -> None:
"""Stop the reader thread and close the TTY descriptor."""
logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path)
self._stop_event.set()
with self._mailbox_cv:
self._mailbox_cv.notify_all()
if self._thread is not None:
self._thread.join(timeout=1.0)
if self._thread.is_alive():
logger.warning("Kamil ADC reader thread did not stop within 1.0s")
self._thread = None
if self._fd is not None:
try:
@@ -180,7 +184,11 @@ class KamilAdcTtyReader:
# ------------------------------------------------------------------
def _reader_loop(self) -> None:
"""Drain TTY parse frames publish completed sweeps until stop."""
"""Drain the TTY, parse frames, and publish completed sweeps until stop.
Runs on the background reader thread. Any exception is logged and stored
so the next :meth:`read_sweep` re-raises it on the consumer thread.
"""
buffer = bytearray()
try:
if not self._skip_to_first_start_marker(buffer):
@@ -191,6 +199,7 @@ class KamilAdcTtyReader:
return
self._publish_sweep(sweep)
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
logger.exception("Kamil ADC reader thread failed on %s", self.tty_path)
self._publish_error(exc)
def _skip_to_first_start_marker(self, buffer: bytearray) -> bool:
@@ -335,12 +344,15 @@ class KamilAdcService:
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
reader.open()
self._reader = reader
logger.info("Kamil ADC service opened")
except Exception:
logger.exception("Kamil ADC service failed to open; cleaning up")
self.close()
raise
def close(self) -> None:
"""Stop the TTY reader and the external collector process."""
logger.debug("Closing Kamil ADC service")
if self._reader is not None:
with suppress(Exception):
self._reader.close()
@@ -352,6 +364,9 @@ class KamilAdcService:
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
logger.debug(
"Kamil ADC configured: frequency axis %s-%s Hz", sweep.start_hz, sweep.stop_hz
)
def read_device_limits(self) -> dict[str, float | int]:
"""Kamil ADC has no runtime-readable sweep limit API."""
@@ -374,6 +389,7 @@ class KamilAdcService:
)
points = int(s21.size)
if self._frequency_hz is None or self._frequency_hz.size != points:
logger.debug("Building Kamil ADC frequency axis for %d points", points)
self._frequency_hz = self._build_frequency_axis(points)
return SweepResult(
x=self._frequency_hz.copy(),
@@ -409,6 +425,7 @@ class KamilAdcService:
self._process = None
if process is None or process.poll() is not None:
return
logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
@@ -416,6 +433,9 @@ class KamilAdcService:
return
except subprocess.TimeoutExpired:
pass
logger.warning(
"Kamil ADC collector (pid=%d) ignored SIGTERM; sending SIGKILL", process.pid
)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=1.0)
@@ -533,9 +553,16 @@ def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"""Apply Kamil ADC laser settings exactly through the legacy device_main command sequence."""
"""Apply the configured laser settings through the legacy device_main command sequence.
Connects to the laser controller, resets it, and applies either manual or
variation mode per ``radar.laser_control``. Returns `True` when settings were
applied, `False` when laser control is disabled. The controller is always
disconnected before returning.
"""
laser = config.radar.laser_control
if not laser.enabled:
logger.debug("Kamil ADC laser control disabled; skipping")
return False
_validate_laser_control_config(config)
@@ -554,6 +581,7 @@ def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
controller.connect()
controller.reset()
mode = laser.mode.strip().lower()
logger.info("Applying Kamil ADC laser control in %s mode", mode)
if mode == "manual":
manual = laser.manual
controller.set_manual_mode(
@@ -1,8 +1,7 @@
"""
Constants for laser control module.
"""Constants for the laser control module.
Physical constraints, protocol parameters, and operational limits
extracted from original device_commands.py and device_conversion.py.
Physical constraints, protocol parameters, and operational limits for the
laser control board.
"""
# ---- Protocol constants
@@ -362,8 +362,8 @@ class LaserController:
if raw and len(raw) == 2:
state = Protocol.decode_state(raw)
if state != 0:
# Surface a device-reported non-OK STATE instead of silently treating
# a board-rejected command as success. (Returned to the caller too.)
# Surface a device-reported non-OK STATE instead of silently
# treating a board-rejected command as success.
logger.warning(
"Device returned non-OK STATE 0x%04x after command: %s",
state,
@@ -388,6 +388,6 @@ class LaserController:
try:
self.stop_task()
except Exception:
pass
logger.warning("Failed to stop laser task on exit; closing port anyway", exc_info=True)
self.disconnect()
return False
@@ -1,10 +1,7 @@
"""
Physical unit conversions for laser control module.
"""Physical unit conversions for the laser control module.
Converts between physical quantities (°C, mA, V) and
raw ADC/DAC integer values used by the device firmware.
All formulas are taken directly from the original device_conversion.py.
Converts between physical quantities (°C, mA, V) and the raw ADC/DAC integer
values used by the device firmware, using the hardware's bridge/divider formulas.
"""
import math
@@ -1,11 +1,10 @@
"""
Communication protocol for laser control module.
"""Communication protocol for the laser control module.
Encodes commands to bytes and decodes device responses.
Faithful re-implementation of the logic in device_commands.py,
refactored into a clean, testable class-based API.
Encodes commands to wire bytes, decodes device responses, and manages the
serial port connection to the laser control board.
"""
import logging
import struct
from typing import Optional
from enum import IntEnum
@@ -38,6 +37,8 @@ from .exceptions import (
ProtocolError,
)
logger = logging.getLogger(__name__)
# Re-export enums so tests can import from protocol module
class CommandCode(IntEnum):
@@ -77,11 +78,11 @@ def _int_to_hex4(value: int) -> str:
return f"{value:04x}"
def _flipfour(s: str) -> str:
"""Swap two byte-pairs: 'aabb' 'bbaa' (little-endian word)."""
if len(s) != 4:
raise ValueError(f"Expected 4-char hex string, got '{s}'")
return s[2:4] + s[0:2]
def _flipfour(hex_word: str) -> str:
"""Swap the two byte-pairs of a 4-char hex word: 'aabb' -> 'bbaa' (little-endian)."""
if len(hex_word) != 4:
raise ValueError(f"Expected 4-char hex string, got '{hex_word}'")
return hex_word[2:4] + hex_word[0:2]
def _xor_crc(words: list) -> str:
@@ -183,7 +184,7 @@ class Protocol:
# ---- Connection management
def connect(self) -> None:
"""Open the serial port. Auto-detects if port is None."""
"""Open the serial port. Auto-detects the device path when port is None."""
port = self._port_name or self._detect_port()
try:
self._serial = serial.Serial(
@@ -192,13 +193,16 @@ class Protocol:
timeout=SERIAL_TIMEOUT_SEC,
)
except Exception as exc:
logger.error("Cannot open laser serial port '%s': %s", port, exc)
raise CommunicationError(
f"Cannot connect to port '{port}': {exc}"
) from exc
logger.debug("Laser serial port opened: %s @ %d baud", port, BAUDRATE)
def disconnect(self) -> None:
"""Close the serial port if open."""
if self._serial and self._serial.is_open:
logger.debug("Closing laser serial port")
self._serial.close()
@property
@@ -241,13 +245,14 @@ class Protocol:
@staticmethod
def calculate_crc(data: bytes) -> int:
"""
XOR CRC over all 16-bit words except the last two bytes (CRC field).
Mirrors the original CalculateCRC logic.
"""Return the XOR CRC over all 16-bit words except word 0 and the CRC field.
The command-code word (word 0) is excluded, matching the firmware's CRC
expectation.
"""
hex_str = data.hex()
words = [hex_str[i:i+4] for i in range(0, len(hex_str), 4)]
# Skip word 0 (command code) per original firmware expectation
# Word 0 (command code) is excluded from the CRC.
crc_words = words[1:]
result = int(crc_words[0], 16)
for w in crc_words[1:]:
@@ -342,9 +347,8 @@ class Protocol:
case TaskType.CHANGE_CURRENT_LD2:
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
# Word 5: current step encoded like LD1 and like min/max (current_ma_to_n),
# NOT int(step*100) — the latter was a copy/paste from temperature scaling
# and produced a different wire value than LD1 for the same physical step.
# Word 5: current step uses the same current_ma_to_n scaling as
# min/max (and as LD1) so equal physical steps map to equal wire values.
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7
@@ -10,7 +10,6 @@ from .enums import (
SweepScale,
SyncMode,
)
from .logging_utils import DEFAULT_LOG_LEVEL, configure_logging
from .exceptions import (
CRCError,
DeviceDisconnectedError,
@@ -39,7 +38,6 @@ from .models import (
__all__ = [
"CRCError",
"DEFAULT_LOG_LEVEL",
"DeviceConfigVariant",
"DeviceDisconnectedError",
"DeviceInfo",
@@ -55,7 +53,6 @@ __all__ = [
"PacketType",
"ParseError",
"ProtocolVersionMismatch",
"configure_logging",
"SParameter",
"StreamHandle",
"SweepKind",
@@ -44,7 +44,18 @@ class GeneratorController:
timeout_s: float,
poll_interval_s: float,
) -> DeviceStatus:
"""Wait until available lock flags report the generator is ready."""
"""Poll device status until the source/LO lock flags report the generator
is ready, then return that status.
Polls every ``poll_interval_s`` seconds up to ``timeout_s`` total. Raises
``TimeoutError`` if the locks do not assert in time and ``RuntimeError`` if
the connected hardware family exposes no lock telemetry.
"""
logger.info(
"Waiting for generator lock (timeout=%.2fs, poll_interval=%.2fs)",
timeout_s,
poll_interval_s,
)
deadline = time.monotonic() + timeout_s
@@ -53,13 +64,19 @@ class GeneratorController:
lock_values = [value for value in (status.source_locked, status.lo_locked) if value is not None]
if lock_values:
if all(lock_values):
logger.info("Generator locked (family=%s)", status.family.name)
return status
else:
logger.error(
"Generator lock telemetry unavailable for hardware family %s",
status.family.name,
)
raise RuntimeError(
f"Generator lock telemetry is unavailable for hardware family {status.family.name}"
)
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning("Timed out waiting for generator lock after %.2fs", timeout_s)
raise TimeoutError("Timed out waiting for LibreVNA generator lock")
time.sleep(min(poll_interval_s, remaining))
@@ -1,52 +0,0 @@
"""Logging helpers for applications embedding ``librevna_driver``.
The library uses standard ``logging`` module loggers under the
``librevna_driver`` namespace and never configures global logging implicitly.
Use :func:`configure_logging` in scripts/services when you want a convenient
default console setup.
"""
from __future__ import annotations
import logging
_LOGGER_NAMESPACE = "librevna_driver"
_DEFAULT_FORMAT = (
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
)
DEFAULT_LOG_LEVEL = "INFO"
def configure_logging(
level: int | str | None = None,
*,
fmt: str = _DEFAULT_FORMAT,
datefmt: str | None = "%Y-%m-%d %H:%M:%S",
) -> None:
"""Configure package logger with one stream handler.
This helper affects only the ``librevna_driver`` logger tree and is safe to
call repeatedly (previous handlers attached by this function are replaced).
When ``level`` is ``None``, :data:`DEFAULT_LOG_LEVEL` is used.
"""
effective_level = level if level is not None else DEFAULT_LOG_LEVEL
logger = logging.getLogger(_LOGGER_NAMESPACE)
logger.handlers.clear()
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
logger.addHandler(handler)
logger.setLevel(_parse_level(effective_level))
logger.propagate = False
def _parse_level(level: int | str) -> int:
"""Parse numeric or textual log level into logging constant."""
if isinstance(level, int):
return level
normalized = level.strip().upper()
if normalized in logging.getLevelNamesMapping():
return logging.getLevelNamesMapping()[normalized]
raise ValueError(f"Unknown logging level: {level!r}")
@@ -251,13 +251,14 @@ class SweepResult:
return self.trace(parameter).imag
def to_npz(self, path: str) -> None:
"""Save result as NumPy `.npz` archive."""
"""Save the axis and all traces to a NumPy `.npz` archive at ``path``."""
data: dict[str, np.ndarray] = {self.x_label: self.x}
data.update(self.traces)
np.savez(path, **data)
logger.debug("Saved SweepResult to NPZ: %s (traces=%d)", path, len(self.traces))
def to_csv(self, path: str) -> None:
"""Save result as CSV with `<trace>_real`/`<trace>_imag` columns."""
"""Save the axis and traces to CSV at ``path``, with `<trace>_real`/`<trace>_imag` columns."""
columns: list[np.ndarray] = [self.x]
headers: list[str] = [self.x_label]
for name, values in sorted(self.traces.items()):
@@ -267,6 +268,7 @@ class SweepResult:
headers.append(f"{name}_imag")
matrix = np.column_stack(columns)
np.savetxt(path, matrix, delimiter=",", header=",".join(headers), comments="")
logger.debug("Saved SweepResult to CSV: %s (traces=%d)", path, len(self.traces))
PacketPayload = Any
@@ -6,6 +6,7 @@ from collections.abc import Iterator, Sequence
from contextlib import suppress
from dataclasses import replace
from typing import Optional
import logging
import threading
import time
@@ -23,6 +24,8 @@ from python_app.hardware_full.librevna_multi_device_driver.protocol import (
)
from python_app.hardware_full.librevna_multi_device_driver.transport import LibreVnaUsbBulkConnection
logger = logging.getLogger(__name__)
class MultiDeviceVnaController:
"""Coordinate one master LibreVNA and receiver slave LibreVNAs."""
@@ -46,6 +49,13 @@ class MultiDeviceVnaController:
self._sweep_is_running = False
self._is_closed = False
logger.info(
"Opening multi-device controller (master=%s, slaves=%s, sync=%s, external_ref=%s)",
master_serial_number,
list(slave_serial_numbers),
self._synchronization_enabled,
self._force_external_reference,
)
try:
# Register each device the moment it opens so a partial open (e.g. a
# slave that fails after the master is up) is fully released by close().
@@ -57,9 +67,12 @@ class MultiDeviceVnaController:
self._slave_devices.append(connection)
self._all_devices.append(connection)
except Exception:
logger.exception("Failed to open multi-device controller; releasing devices")
self.close()
raise
logger.info("Multi-device controller ready (%d device(s) open)", len(self._all_devices))
def __enter__(self) -> MultiDeviceVnaController:
"""Return this controller as a context manager resource."""
return self
@@ -77,17 +90,20 @@ class MultiDeviceVnaController:
if self._is_closed:
return
logger.info("Closing multi-device controller (%d device(s))", len(self._all_devices))
self._is_closed = True
with suppress(Exception):
self._send_idle_to_all_devices()
for device_connection in self._all_devices:
with suppress(Exception):
device_connection.close()
logger.debug("Multi-device controller closed")
def stop_continuous_sweep(self) -> None:
"""Stop the currently running sweep without closing device transports."""
if self._is_closed:
return
logger.info("Stopping continuous sweep")
self._send_idle_to_all_devices()
def configure_continuous_sweep(
@@ -121,6 +137,7 @@ class MultiDeviceVnaController:
and self._last_applied_sweep_configuration == sweep_configuration
and self._last_master_stimulus_ports == stimulus_ports
):
logger.debug("Sweep configuration unchanged; keeping running sweep")
return
if self._sweep_is_running:
@@ -131,6 +148,15 @@ class MultiDeviceVnaController:
# so the new sweep starts on an empty queue.
self._drain_all_received_packets()
logger.info(
"Configuring continuous sweep: %d points %d-%d Hz, ifbw=%d Hz, power=%.2f dBm, ports=%s",
sweep_configuration.points,
sweep_configuration.start_hz,
sweep_configuration.stop_hz,
sweep_configuration.if_bandwidth,
sweep_configuration.power_dbm,
stimulus_ports,
)
self._configure_sweep_on_all_devices(
sweep_configuration,
master_stimulus_ports=stimulus_ports,
@@ -163,6 +189,7 @@ class MultiDeviceVnaController:
datapoint_timeout_seconds=datapoint_timeout_seconds,
)
except Exception:
logger.warning("Sweep cycle collection failed; idling all devices", exc_info=True)
self._send_idle_to_all_devices()
raise
@@ -179,6 +206,10 @@ class MultiDeviceVnaController:
timeout_seconds: float = 3.0,
retry_count: int = 1,
) -> None:
"""Send a packet and wait for its ACK, retrying up to ``retry_count`` times.
Re-raises the last error if every attempt fails to acknowledge in time.
"""
last_error: Exception | None = None
for _attempt_index in range(retry_count + 1):
device_connection.send_packet(packet_type, payload)
@@ -187,6 +218,14 @@ class MultiDeviceVnaController:
return
except Exception as exc: # noqa: BLE001
last_error = exc
logger.debug(
"No ACK for packet type %s from %s (attempt %d/%d): %s",
packet_type,
device_connection.serial_number,
_attempt_index + 1,
retry_count + 1,
exc,
)
assert last_error is not None
raise last_error
@@ -199,6 +238,11 @@ class MultiDeviceVnaController:
timeout_seconds: float = 3.0,
retry_count: int = 1,
) -> None:
"""Send a command and wait for its ACK, swallowing any failure.
Used on best-effort paths (e.g. idling devices during shutdown) where a
non-responsive device must not abort the operation.
"""
try:
self._send_command_and_wait_for_acknowledgement(
device_connection,
@@ -208,9 +252,15 @@ class MultiDeviceVnaController:
retry_count=retry_count,
)
except Exception:
pass
logger.debug(
"Best-effort command type %s to %s failed; ignoring",
packet_type,
device_connection.serial_number,
)
def _send_idle_to_all_devices(self) -> None:
"""Best-effort SET_IDLE to every device and mark the sweep as stopped."""
logger.debug("Sending SET_IDLE to %d device(s)", len(self._all_devices))
# 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
@@ -225,6 +275,12 @@ class MultiDeviceVnaController:
self._sweep_is_running = False
def _configure_reference_clocks(self) -> None:
"""Apply ReferenceSettings to every device and mark the reference configured."""
logger.debug(
"Configuring reference clocks on %d device(s) (external_ref=%s)",
len(self._all_devices),
self._force_external_reference,
)
for device_connection in self._all_devices:
# 1 s ACK timeout plus one retry caps worst-case at ~2 s per device
# so a stuck reference apply cannot stall recovery for minutes.
@@ -245,6 +301,11 @@ class MultiDeviceVnaController:
*,
master_stimulus_ports: tuple[int, ...],
) -> None:
"""Send SweepSettings to slaves then the master and mark the sweep running.
The master is configured last so receivers are armed before the master
begins driving the synchronized trigger.
"""
if self._master_device is None:
raise RuntimeError("Master device is not open")
@@ -270,8 +331,15 @@ class MultiDeviceVnaController:
self._last_applied_sweep_configuration = replace(sweep_configuration)
self._last_master_stimulus_ports = master_stimulus_ports
self._sweep_is_running = True
logger.debug("Sweep settings applied to all devices; sweep running")
def _drain_all_received_packets(self) -> None:
"""Empty every device's received-packet queue, in parallel for 2+ devices.
Concurrent draining keeps cross-device timing skew small so a hardware
cycle wrap cannot slip between per-device drains and desynchronize the
cycle counters.
"""
# 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
@@ -299,6 +367,7 @@ 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")
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
import logging
import queue
import threading
import time
@@ -21,6 +22,8 @@ from python_app.hardware_full.librevna_multi_device_driver.protocol import (
)
from python_app.hardware_full.librevna_multi_device_driver.transport import LibreVnaUsbBulkConnection
logger = logging.getLogger(__name__)
LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS = 1.5
# Hard upper bound on how long one full sweep cycle is allowed to take from
@@ -93,6 +96,14 @@ def collect_complete_running_sweep_cycles(
device_connection: LibreVnaUsbBulkConnection,
handle_datapoint: Callable[[ParsedVnaDatapoint], bool],
) -> None:
"""Read datapoints from one device until enough are consumed or a timeout fires.
Runs on a worker thread. Each datapoint is offered to ``handle_datapoint``,
which returns whether it was consumed; only consumed datapoints count toward
progress and refresh the no-progress timeout. On any timeout or transport
error the error is recorded and ``stop_collection_requested`` is set so the
other collector threads also stop.
"""
datapoints_received = 0
expected_datapoint_count = cycle_count * point_count
loop_start_timestamp = time.monotonic()
@@ -111,6 +122,13 @@ def collect_complete_running_sweep_cycles(
now = time.monotonic()
remaining_timeout_seconds = (last_consumed_timestamp + datapoint_timeout_seconds) - now
if remaining_timeout_seconds <= 0:
logger.warning(
"No usable datapoints from %s for %.1fs (received %d/%d); aborting collection",
device_connection.serial_number,
datapoint_timeout_seconds,
datapoints_received,
expected_datapoint_count,
)
collection_errors.append(
TimeoutError(
f"No usable datapoints from {device_connection.serial_number} for "
@@ -122,6 +140,12 @@ def collect_complete_running_sweep_cycles(
return
if not has_consumed_any_datapoint and (now - loop_start_timestamp) > cycle_start_guard_seconds:
logger.warning(
"Device %s streamed datapoints but never reached point_index=0 within %.1fs; "
"aborting collection",
device_connection.serial_number,
cycle_start_guard_seconds,
)
collection_errors.append(
TimeoutError(
f"Device {device_connection.serial_number} streamed datapoints but never "
@@ -138,6 +162,14 @@ def collect_complete_running_sweep_cycles(
# on for too long — this is the safety net the per-device timeout
# cannot provide by itself.
if (now - loop_start_timestamp) > _MAX_FULL_CYCLE_SECONDS:
logger.warning(
"Device %s did not finish a sweep cycle within %.1fs (received %d/%d); "
"aborting collection",
device_connection.serial_number,
_MAX_FULL_CYCLE_SECONDS,
datapoints_received,
expected_datapoint_count,
)
collection_errors.append(
TimeoutError(
f"Device {device_connection.serial_number} did not finish a sweep cycle "
@@ -157,10 +189,20 @@ def collect_complete_running_sweep_cycles(
return
if isinstance(exc, queue.Empty):
continue
logger.warning(
"Timed out receiving datapoint from %s; aborting collection: %s",
device_connection.serial_number,
exc,
)
collection_errors.append(exc)
stop_collection_requested.set()
return
except Exception as exc: # noqa: BLE001
logger.error(
"Error receiving datapoint from %s; aborting collection",
device_connection.serial_number,
exc_info=exc,
)
collection_errors.append(exc)
stop_collection_requested.set()
return
@@ -182,6 +224,13 @@ def collect_complete_running_sweep_cycles(
def build_cycle_tracking_handler(
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
) -> Callable[[ParsedVnaDatapoint], bool]:
"""Wrap a cycle-aware handler with cross-device cycle tracking.
Returns a per-datapoint handler that anchors cycle 0 on the first
``point_index == 0`` seen, advances the cycle counter on each point-index
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
@@ -198,6 +247,12 @@ def collect_complete_running_sweep_cycles(
}
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
"""Track the cycle index for one datapoint and dispatch it to the handler.
Returns ``True`` when the datapoint was consumed (within ``cycle_count``)
and ``False`` when it was ignored (pre-sync straggler or past the last
requested cycle).
"""
current_point_index = parsed_datapoint.point_index
if not cycle_tracking_state["synchronized"]:
@@ -221,6 +276,12 @@ def collect_complete_running_sweep_cycles(
return handle_datapoint
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Store the master device's frequency, reference, and reflection values.
Records the sweep-point frequency and, per active master stimulus port, the
reference receiver value and the matching reflection (S11/S22) into the
cycle/point measurement buffers.
"""
point_index = parsed_datapoint.point_index
frequencies_hz[point_index] = parsed_datapoint.frequency_hz
@@ -260,9 +321,16 @@ def collect_complete_running_sweep_cycles(
] = port_receiver_value
def build_slave_datapoint_handler(slave_index: int) -> Callable[[ParsedVnaDatapoint], bool]:
"""Build a cycle-tracking datapoint handler for the given slave device.
The slave's two receivers map to ports ``2*slave_index + 3`` and ``+ 4``,
producing forward S-parameters (e.g. S3x/S4x) for each active master
stimulus port.
"""
receiver_base_port = 2 * slave_index + 3
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Store this slave's forward receiver values into the measurement buffers."""
point_index = parsed_datapoint.point_index
for master_stimulus_port, stage_index in stage_by_master_port.items():
first_s_parameter_name = f"S{receiver_base_port}{master_stimulus_port}"
@@ -303,6 +371,13 @@ def collect_complete_running_sweep_cycles(
)
)
logger.debug(
"Collecting %d sweep cycle(s) of %d points from %d device(s) (datapoint_timeout=%.1fs)",
cycle_count,
point_count,
len(all_device_connections),
datapoint_timeout_seconds,
)
for collection_thread in collection_threads:
collection_thread.start()
@@ -321,6 +396,10 @@ def collect_complete_running_sweep_cycles(
collection_thread for collection_thread in collection_threads if collection_thread.is_alive()
]
if stalled_threads:
logger.warning(
"Collector thread(s) still alive after join; requesting stop again: %s",
", ".join(stalled_thread.name for stalled_thread in stalled_threads),
)
stop_collection_requested.set()
# Give them one more short window in case they were just slow to react.
secondary_deadline = time.monotonic() + 0.5
@@ -328,6 +407,10 @@ def collect_complete_running_sweep_cycles(
stalled_thread.join(timeout=max(0.0, secondary_deadline - time.monotonic()))
still_stalled = [stalled_thread for stalled_thread in stalled_threads if stalled_thread.is_alive()]
if still_stalled:
logger.error(
"Collector thread(s) failed to stop within the join deadline: %s",
", ".join(stalled_thread.name for stalled_thread in still_stalled),
)
collection_errors.append(
RuntimeError(
"Sweep collector thread(s) failed to stop within the join deadline: "
@@ -339,11 +422,17 @@ def collect_complete_running_sweep_cycles(
raise RuntimeError(f"Sweep collection failed: {collection_errors[0]}") from collection_errors[0]
if slave_device_connections and min(datapoint_counts_by_device_serial.values(), default=0) == 0:
logger.error(
"No datapoints from at least one device; hardware trigger sync did not start "
"(per-device counts: %s)",
datapoint_counts_by_device_serial,
)
raise RuntimeError(
"No datapoints received from at least one device; hardware trigger sync did not start. "
"Check Trigger Out/In loop and 10 MHz reference wiring."
)
logger.debug("Sweep cycle collection complete (per-device counts: %s)", datapoint_counts_by_device_serial)
return SweepMeasurementResult(
frequencies_hz=frequencies_hz,
s_parameters=calculate_last_cycle_s_parameters(
@@ -20,6 +20,11 @@ class LibreVnaUsbBulkConnection:
"""Minimal packet transport for one LibreVNA device."""
def __init__(self, serial_number: str) -> None:
"""Open the USB transport for ``serial_number`` and start receiving packets.
Raises ``ValueError`` when no serial number is supplied and propagates any
transport error raised while opening the device.
"""
if not serial_number:
raise ValueError("serial_number is required for multi-device acquisition")
self.serial_number = serial_number
@@ -32,10 +37,13 @@ class LibreVnaUsbBulkConnection:
on_disconnect=self._on_disconnect,
read_chunk_size=4096,
)
logger.debug("Opening LibreVNA USB connection (serial=%s)", serial_number)
self._transport.connect(serial=serial_number, timeout_s=2.0)
logger.info("LibreVNA USB connection ready (serial=%s)", serial_number)
def close(self) -> None:
"""Close USB resources."""
"""Disconnect the underlying USB transport and release its resources."""
logger.debug("Closing LibreVNA USB connection (serial=%s)", self.serial_number)
self._transport.disconnect()
def drain_received_packets(self) -> list[tuple[int, bytes]]:
@@ -83,6 +91,11 @@ class LibreVnaUsbBulkConnection:
raise RuntimeError(f"Device {self.serial_number} returned NACK")
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.
"""
try:
packets = self._scanner.feed(chunk)
except Exception as exc: # noqa: BLE001
@@ -92,15 +105,18 @@ class LibreVnaUsbBulkConnection:
self._received_packets.put((int(packet.type), bytes(packet.payload)))
def _on_disconnect(self, exc: Exception) -> None:
"""Record an asynchronous transport disconnect as the fatal error."""
self._set_fatal_error(exc)
def _set_fatal_error(self, exc: Exception) -> None:
"""Store the first fatal transport error and log it; later errors are ignored."""
with self._fatal_lock:
if self._fatal_error is None:
logger.error("LibreVNA USB transport failed for %s: %s", self.serial_number, exc)
self._fatal_error = exc
def _raise_if_failed(self) -> None:
"""Re-raise the stored fatal transport error as ``RuntimeError`` if one exists."""
with self._fatal_lock:
if self._fatal_error is None:
return
@@ -34,6 +34,7 @@ class LibreVnaService:
raise ValueError(f"Unsupported LibreVnaService backend mode: {self.backend_mode}")
if mode == "mock":
logger.info("LibreVNA service using mock backend (mode=mock)")
self._backend = MockLibreVnaBackend()
self._using_mock_backend = True
return
@@ -44,6 +45,7 @@ class LibreVnaService:
strict_protocol_version=self.strict_protocol_version,
)
self._driver_available = True
logger.info("LibreVNA native backend initialized (serial=%s)", self.serial or "auto")
except Exception as exc:
# 'native' demands real hardware — never substitute synthetic data.
if mode == "native":
@@ -68,18 +70,24 @@ class LibreVnaService:
return
if self._backend is None:
return
logger.debug("Opening LibreVNA backend (mock=%s)", self._using_mock_backend)
self._backend.open()
def close(self) -> None:
"""Close backend resources."""
if self._backend is None:
return
logger.debug("Closing LibreVNA backend")
self._backend.close()
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings to active backend."""
if self._backend is None:
raise RuntimeError("LibreVNA backend is not initialized")
logger.debug(
"Configuring LibreVNA sweep: %s-%s Hz, %s points",
sweep.start_hz, sweep.stop_hz, sweep.points,
)
self._backend.configure(sweep)
def read_device_limits(self) -> dict[str, float | int]:
@@ -83,6 +83,10 @@ class MultiDeviceLibreVnaService:
slave_serial_numbers=self.slave_serials,
force_external_reference=self.force_external_reference,
)
logger.info(
"Multi-device controller opened (master=%s, slaves=%s)",
self.master_serial, self.slave_serials,
)
except Exception as exc:
# Never silently latch to synthetic data: a deployed appliance must wait
# for the real device, not record fakes. Synthetic data requires an
@@ -180,6 +184,14 @@ class MultiDeviceLibreVnaService:
if_bandwidth=int(round(float(sweep.if_bandwidth_hz))),
power_dbm=float(sweep.power_dbm),
)
logger.debug(
"Multi-device configured: %s-%s Hz, %s points, IFBW=%s Hz, %s dBm",
self._sweep_configuration.start_hz,
self._sweep_configuration.stop_hz,
self._sweep_configuration.points,
self._sweep_configuration.if_bandwidth,
self._sweep_configuration.power_dbm,
)
def acquire_collection(
self,
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Protocol
from python_app.hardware_full.kamil_adc_service import KamilAdcService
@@ -10,6 +11,8 @@ from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
logger = logging.getLogger(__name__)
class SingleRadarService(Protocol):
"""Common API used by single-radar workflows."""
@@ -39,6 +42,7 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
)
model = config.radar.model or RunConfigModel.LIBREVNA_MODEL
logger.debug("Creating single-radar service for model=%s (driver_mode=%s)", model, config.radar.driver_mode)
if model == RunConfigModel.LIBREVNA_MODEL:
# Forward driver_mode (mirrors the matrix path): 'native' must require real
# hardware and 'mock' must use the synthetic backend — never silently the wrong one.
@@ -79,6 +79,7 @@ class Sn9000Service:
if self._instrument is not None:
return
logger.info("Opening SN9000 VISA session: %s", self.resource)
try:
self._resource_manager = pyvisa.ResourceManager(self.visa_library)
self._instrument = self._resource_manager.open_resource(self.resource)
@@ -91,12 +92,14 @@ class Sn9000Service:
if self._settings is not None:
self._apply_configuration(self._settings)
except Exception:
logger.exception("Failed to open SN9000 VISA session: %s", self.resource)
self.close()
raise
def close(self) -> None:
"""Close VISA sessions."""
if self._instrument is not None:
logger.debug("Closing SN9000 VISA session")
self._instrument.close()
self._instrument = None
if self._resource_manager is not None:
@@ -114,6 +117,7 @@ class Sn9000Service:
def recover(self) -> None:
"""Reopen the VISA session after a transient acquisition failure."""
logger.warning("Recovering SN9000 VISA session (close, wait, reopen)")
self.close()
time.sleep(0.25)
self.open()
@@ -133,6 +137,10 @@ class Sn9000Service:
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
logger.debug(
"Configuring SN9000 sweep: %s-%s Hz, %s points, IFBW=%s Hz, %s dBm",
sweep.start_hz, sweep.stop_hz, sweep.points, sweep.if_bandwidth_hz, sweep.power_dbm,
)
if self._instrument is None:
return
self._apply_configuration(sweep)
@@ -210,6 +218,10 @@ class Sn9000Service:
instrument.write("TRIG:SOUR BUS")
self._expect_opc("*OPC?", context="SN9000 setup")
self._frequency_hz = self._query_float32_array("SENS:FREQ:DATA?", int(sweep.points))
logger.info(
"SN9000 configured: %d traces, %d points (%s-%s Hz)",
len(_S_PARAMETER_QUERY_ORDER), int(sweep.points), sweep.start_hz, sweep.stop_hz,
)
def _query_sweep_s_parameters(self, points: int) -> dict[str, np.ndarray]:
instrument = self._require_instrument()