improved logging
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user