Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
@@ -1,274 +0,0 @@
"""VISA HiSLIP driver for Compact-M K209 / S2VNA analyzers."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
import pyvisa
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
@dataclass(slots=True)
class CompactMK209InterleavedSweep:
"""Raw corrected K209 traces as interleaved REAL32 arrays."""
frequency_hz: np.ndarray
s11_values: np.ndarray
s21_values: np.ndarray
@dataclass(slots=True)
class CompactMK209Service:
"""Acquire corrected S11/S21 sweeps from a K209 analyzer through VISA HiSLIP."""
resource: str
timeout_ms: int = 20_000
preset_on_open: bool = True
visa_library: str = "@ivi"
_resource_manager: pyvisa.ResourceManager | None = field(init=False, default=None, repr=False)
_instrument: Any | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
"""Normalize constructor values."""
self.resource = str(self.resource).strip()
if not self.resource:
raise ValueError("K209 VISA resource must not be empty")
self.timeout_ms = int(self.timeout_ms)
if self.timeout_ms <= 0:
raise ValueError("K209 timeout_ms must be > 0")
self.visa_library = str(self.visa_library).strip() or "@ivi"
if self.visa_library == "@py" or self.visa_library.endswith("@py"):
raise ValueError("K209 requires an IVI/Vendor VISA backend, not pyvisa-py")
@property
def is_open(self) -> bool:
"""Return whether the VISA session is open."""
return self._instrument is not None
def open(self) -> None:
"""Open VISA resource and apply stored sweep settings when available."""
if self._instrument is not None:
return
try:
self._resource_manager = pyvisa.ResourceManager(self.visa_library)
self._instrument = self._resource_manager.open_resource(self.resource)
self._instrument.timeout = self.timeout_ms
self._instrument.write_termination = "\n"
self._instrument.read_termination = None
self._instrument.chunk_size = max(int(getattr(self._instrument, "chunk_size", 20_480)), 8 * 1024 * 1024)
self._instrument.write("*CLS")
if self._settings is not None:
self._apply_configuration(self._settings)
except Exception:
self.close()
raise
def close(self) -> None:
"""Close VISA sessions."""
if self._instrument is not None:
self._instrument.close()
self._instrument = None
if self._resource_manager is not None:
self._resource_manager.close()
self._resource_manager = None
def __enter__(self) -> CompactMK209Service:
"""Open and return this service."""
self.open()
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
"""Close VISA resources."""
self.close()
def query_identity(self) -> str:
"""Read analyzer identity string."""
instrument = self._require_instrument()
return str(instrument.query("*IDN?")).strip()
def query_system_error(self) -> str:
"""Read one analyzer SCPI error queue entry."""
instrument = self._require_instrument()
return str(instrument.query("SYST:ERR?")).strip()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings."""
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
if self._instrument is None:
return
self._apply_configuration(sweep)
def read_device_limits(self) -> dict[str, float | int]:
"""Read analyzer limits through SCPI capability/service queries."""
opened_here = self._instrument is None
try:
if opened_here:
self.open()
instrument = self._require_instrument()
return {
"min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")),
"max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")),
"min_ifbw_hz": float(instrument.query("SYST:CAP:IFBW:MIN?")),
"max_ifbw_hz": float(instrument.query("SYST:CAP:IFBW:MAX?")),
"max_points": int(float(instrument.query("SERV:SWE:POIN?"))),
"min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")),
"max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")),
}
finally:
if opened_here:
self.close()
def frequency_axis(self) -> np.ndarray:
"""Return the configured frequency axis."""
if self._frequency_hz is None:
raise RuntimeError("K209 frequency axis is not configured")
return self._frequency_hz
def acquire_interleaved(self) -> CompactMK209InterleavedSweep:
"""Acquire one corrected sweep without converting interleaved arrays."""
if self._settings is None:
raise RuntimeError("K209 service is not configured")
if self._frequency_hz is None:
raise RuntimeError("K209 frequency axis is not configured")
points = int(self._settings.points)
s11_values, s21_values = self._query_sweep_trace_pair(points)
return CompactMK209InterleavedSweep(
frequency_hz=self._frequency_hz,
s11_values=s11_values,
s21_values=s21_values,
)
def acquire(self) -> SweepResult:
"""Acquire one corrected S11/S21 sweep."""
raw = self.acquire_interleaved()
return SweepResult(
x=raw.frequency_hz.copy(),
traces={
"s11": self._complex_from_interleaved(raw.s11_values),
"s21": self._complex_from_interleaved(raw.s21_values),
},
)
def _apply_configuration(self, sweep: RadarSweepModel) -> None:
instrument = self._require_instrument()
if self.preset_on_open:
instrument.write("SYST:PRES")
self._expect_opc("*OPC?", context="K209 preset")
instrument.write(f"SENS:FREQ:STAR {float(sweep.start_hz):.9f}")
instrument.write(f"SENS:FREQ:STOP {float(sweep.stop_hz):.9f}")
instrument.write(f"SENS:SWE:POIN {int(sweep.points)}")
instrument.write("SENS:SWE:POIN:TIME 0")
instrument.write(f"SENS:BAND {float(sweep.if_bandwidth_hz):.9f}")
instrument.write(f"SOUR:POW {float(sweep.power_dbm):.3f}")
instrument.write("SENS:AVER OFF")
instrument.write("CALC:PAR1:DEF S21")
instrument.write("CALC:PAR1:SEL")
instrument.write("FORM:DATA REAL32")
instrument.write("FORM:BORD SWAP")
instrument.write("INIT:CONT ON")
instrument.write("TRIG:SOUR BUS")
self._expect_opc("*OPC?", context="K209 setup")
self._frequency_hz = self._query_float32_array("SENS:FREQ:DATA?", int(sweep.points))
def _expect_opc(self, command: str, *, context: str) -> None:
instrument = self._require_instrument()
response = str(instrument.query(command)).strip()
if response != "1":
raise RuntimeError(f"{context} returned unexpected *OPC? response: {response!r}")
def _query_float32_array(self, command: str, expected_values: int) -> np.ndarray:
instrument = self._require_instrument()
instrument.write(command)
return self._read_float32_block(command, expected_values)
def _query_sweep_trace_pair(self, points: int) -> tuple[np.ndarray, np.ndarray]:
instrument = self._require_instrument()
instrument.write("TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21")
response = self._read_ascii_token()
if response != "1":
raise RuntimeError(f"K209 sweep returned unexpected *OPC? response: {response!r}")
return (
self._read_float32_block("SENS:DATA:CORR? S11", points * 2),
self._read_float32_block("SENS:DATA:CORR? S21", points * 2),
)
def _read_ascii_token(self) -> str:
token = bytearray()
while True:
byte = self._read_response_bytes(1)
if byte in (b";", b"\n", b"\r"):
if token:
return token.decode("ascii").strip()
continue
token.extend(byte)
if len(token) > 64 * 1024:
raise RuntimeError("K209 ASCII response token is too long")
def _read_float32_block(self, context: str, expected_values: int) -> np.ndarray:
marker = self._read_response_bytes(1)
while marker in (b";", b"\n", b"\r"):
marker = self._read_response_bytes(1)
if marker != b"#":
raise RuntimeError(f"K209 response for {context!r} does not start with IEEE block marker")
width = self._read_response_bytes(1)
if width != b"8":
raise RuntimeError(f"K209 response for {context!r} uses unsupported IEEE block header width")
payload_size = int(self._read_response_bytes(8).decode("ascii"))
expected_size = expected_values * np.dtype(np.float32).itemsize
if payload_size != expected_size:
raise RuntimeError(
f"K209 response for {context!r} returned {payload_size} payload bytes, "
f"expected {expected_size}"
)
payload = self._read_response_bytes(payload_size)
array = np.frombuffer(payload, dtype="<f4")
if array.size != expected_values:
raise RuntimeError(
f"K209 response for {context!r} returned {array.size} float32 values, "
f"expected {expected_values}"
)
return array
def _read_response_bytes(self, count: int) -> bytes:
instrument = self._require_instrument()
data = instrument.read_bytes(count, break_on_termchar=False)
if len(data) != count:
raise RuntimeError(f"K209 response ended after {len(data)} bytes, expected {count}")
return data
def _require_instrument(self) -> Any:
if self._instrument is None:
raise RuntimeError("K209 VISA instrument is not open")
return self._instrument
@staticmethod
def _validate_sweep(sweep: RadarSweepModel) -> None:
if int(sweep.points) < 2:
raise ValueError("K209 sweep points must be >= 2")
if float(sweep.stop_hz) < float(sweep.start_hz):
raise ValueError("K209 sweep stop_hz must be >= start_hz")
if float(sweep.if_bandwidth_hz) <= 0.0:
raise ValueError("K209 IF bandwidth must be > 0")
@staticmethod
def _complex_from_interleaved(values: np.ndarray) -> np.ndarray:
if values.size % 2 != 0:
raise RuntimeError("K209 complex trace payload has odd scalar count")
reshaped = np.asarray(values, dtype=np.float32).reshape((-1, 2))
return (reshaped[:, 0] + 1j * reshaped[:, 1]).astype(np.complex64)
@@ -1,90 +0,0 @@
"""Binary TCP protocol shared by the K209 remote server and Python client."""
from __future__ import annotations
import socket
import struct
from typing import BinaryIO
import numpy as np
COMMAND_IDENTITY = b"I"
COMMAND_LIMITS = b"L"
COMMAND_CONFIGURE = b"C"
COMMAND_ACQUIRE = b"A"
STATUS_OK = b"O"
STATUS_ERROR = b"E"
DEFAULT_REMOTE_HOST = "127.0.0.1"
DEFAULT_REMOTE_PORT = 50209
CONFIG_STRUCT = struct.Struct("!ddIdd")
LIMITS_STRUCT = struct.Struct("!ddddIdd")
U32_STRUCT = struct.Struct("!I")
def recv_exact(stream: socket.socket | BinaryIO, size: int) -> bytes:
"""Read exactly `size` bytes from a socket-like object."""
chunks: list[bytes] = []
remaining = int(size)
while remaining > 0:
chunk = stream.recv(remaining) if isinstance(stream, socket.socket) else stream.read(remaining)
if not chunk:
raise ConnectionError(f"K209 remote connection closed with {remaining} bytes pending")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def send_all(stream: socket.socket | BinaryIO, payload: bytes) -> None:
"""Write all payload bytes to a socket-like object."""
if isinstance(stream, socket.socket):
stream.sendall(payload)
return
stream.write(payload)
def send_u32(stream: socket.socket | BinaryIO, value: int) -> None:
"""Send one network-order uint32."""
send_all(stream, U32_STRUCT.pack(int(value)))
def recv_u32(stream: socket.socket | BinaryIO) -> int:
"""Read one network-order uint32."""
return int(U32_STRUCT.unpack(recv_exact(stream, U32_STRUCT.size))[0])
def send_error(stream: socket.socket | BinaryIO, message: str) -> None:
"""Send protocol error response."""
payload = str(message).encode("utf-8", errors="replace")
send_all(stream, STATUS_ERROR)
send_u32(stream, len(payload))
send_all(stream, payload)
def read_status(stream: socket.socket | BinaryIO) -> None:
"""Read response status and raise remote error when needed."""
status = recv_exact(stream, 1)
if status == STATUS_OK:
return
if status == STATUS_ERROR:
message = recv_exact(stream, recv_u32(stream)).decode("utf-8", errors="replace")
raise RuntimeError(f"K209 remote server error: {message}")
raise RuntimeError(f"K209 remote server returned invalid status byte: {status!r}")
def send_float32_array(stream: socket.socket | BinaryIO, values: np.ndarray) -> None:
"""Send a float32 array as a length-prefixed little-endian payload."""
payload = np.asarray(values, dtype="<f4").tobytes(order="C")
send_u32(stream, len(payload))
send_all(stream, payload)
def recv_float32_array(stream: socket.socket | BinaryIO, expected_values: int) -> np.ndarray:
"""Read a length-prefixed little-endian float32 array."""
payload_size = recv_u32(stream)
expected_size = int(expected_values) * np.dtype(np.float32).itemsize
if payload_size != expected_size:
raise RuntimeError(f"K209 remote payload has {payload_size} bytes, expected {expected_size}")
return np.frombuffer(recv_exact(stream, payload_size), dtype="<f4").copy()
@@ -74,7 +74,13 @@ class KamilAdcTtyReader:
name=f"kamil-adc-tty-reader[{self.tty_path}]",
daemon=True,
)
self._thread.start()
try:
self._thread.start()
except Exception:
self._thread = None
fd, self._fd = self._fd, None
os.close(fd)
raise
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
def close(self) -> None:
@@ -120,6 +126,8 @@ class KamilAdcTtyReader:
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
if self._stop_event.is_set():
raise RuntimeError("Kamil ADC TTY reader is closed")
# Deliver a pending sweep first: if the reader both published a
# sweep and then died, the consumer still sees the good data and
# only meets the error on the next call.
@@ -156,6 +164,8 @@ class KamilAdcTtyReader:
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
if self._stop_event.is_set():
raise RuntimeError("Kamil ADC TTY reader is closed")
sweep = self._combo_slots.pop(combo, None)
if sweep is not None:
return sweep
@@ -20,6 +20,8 @@ from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Union
from python_app.file_io import atomic_write_json
_PathLike = Union[str, os.PathLike[str]]
# Default IPC locations. Both are overridable via CLI/API so several runs can use
@@ -46,12 +48,7 @@ class LaserVariationSession:
Writes to a temp file in the same directory then renames, so a concurrent
checker never observes a half-written file.
"""
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(f"{dest.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
os.replace(tmp, dest)
return dest
return atomic_write_json(Path(path), asdict(self))
@classmethod
def load(cls, path: _PathLike = DEFAULT_SESSION_PATH) -> "LaserVariationSession":
@@ -196,6 +196,8 @@ class Protocol:
the bus. Note the lock is advisory (``flock``): every program that opens
this port must also use ``exclusive=True`` for the guarantee to hold.
"""
if self.is_connected:
return
port = self._port_name or self._detect_port()
try:
self._serial = serial.Serial(
@@ -219,9 +221,10 @@ class Protocol:
def disconnect(self) -> None:
"""Close the serial port if open."""
if self._serial and self._serial.is_open:
connection, self._serial = self._serial, None
if connection is not None and connection.is_open:
logger.debug("Closing laser serial port")
self._serial.close()
connection.close()
@property
def is_connected(self) -> bool:
@@ -246,37 +249,6 @@ class Protocol:
raise CommunicationError("Serial port is not connected")
return self._serial.read(length)
# ---- Static encoding helpers (no connection required) ---------------
@staticmethod
def flipfour(value: int) -> int:
"""Byte-swap a 16-bit integer (little-endian word swap)."""
return ((value & 0xFF) << 8) | ((value >> 8) & 0xFF)
@staticmethod
def pack_float(value: float) -> bytes:
return struct.pack('<f', value)
@staticmethod
def pack_uint16(value: int) -> bytes:
return struct.pack('<H', value)
@staticmethod
def calculate_crc(data: bytes) -> int:
"""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)]
# 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:]:
result ^= int(w, 16)
return result
# ---- Command encoders -----------------------------------------------
@staticmethod
@@ -404,11 +376,6 @@ class Protocol:
"""Build DEFAULT_ENABLE command (0x2222) — reset device."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
@staticmethod
def encode_remove_file() -> bytes:
"""Build REMOVE_FILE command (0x5555) — delete saved data."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_REMOVE_FILE)))
# ---- Response decoders -----------------------------------------------
@staticmethod
@@ -477,4 +444,4 @@ class Protocol:
@staticmethod
def state_to_description(state_hex_str: str) -> str:
"""Return human-readable description for a state hex string."""
return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.")
return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.")
+13 -88
View File
@@ -3,8 +3,8 @@
from __future__ import annotations
from dataclasses import dataclass, field
import math
from typing import Any, Protocol
from contextlib import suppress
from typing import Any
import numpy as np
@@ -12,29 +12,6 @@ from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
class LibreVnaBackend(Protocol):
"""Minimal backend contract required by `LibreVnaService`."""
def open(self) -> None:
"""Open backend resources."""
def close(self) -> None:
"""Close backend resources."""
@property
def is_open(self) -> bool:
"""Return whether backend currently holds open runtime resources."""
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings."""
def read_device_limits(self) -> dict[str, float | int]:
"""Query runtime device limits."""
def acquire(self) -> SweepResult:
"""Acquire one sweep with all available traces."""
@dataclass(slots=True)
class NativeLibreVnaBackend:
"""Native backend using direct USB LibreVNA Python driver."""
@@ -43,15 +20,13 @@ class NativeLibreVnaBackend:
strict_protocol_version: int
_device: Any | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_libre_vna_device: type[Any] | None = field(init=False, default=None, repr=False)
_vna_sweep_settings: type[Any] | None = field(init=False, default=None, repr=False)
_libre_vna_device: type[Any] = field(init=False, repr=False)
_vna_sweep_settings: type[Any] = field(init=False, repr=False)
def __post_init__(self) -> None:
"""Import native driver classes lazily and initialize backend state."""
from python_app.hardware_full.librevna_driver import LibreVNADevice, VNASweepSettings
self._device: LibreVNADevice | None = None
self._settings: RadarSweepModel | None = None
self._libre_vna_device = LibreVNADevice
self._vna_sweep_settings = VNASweepSettings
@@ -60,8 +35,13 @@ class NativeLibreVnaBackend:
if self._device is not None:
return
self._device = self._libre_vna_device()
self._device.connect(serial=self.serial, strict_protocol_version=self.strict_protocol_version, timeout_s=2.0)
device = self._device = self._libre_vna_device()
try:
device.connect(serial=self.serial, strict_protocol_version=self.strict_protocol_version, timeout_s=2.0)
except Exception:
with suppress(Exception):
self.close()
raise
@property
def is_open(self) -> bool:
@@ -72,8 +52,8 @@ class NativeLibreVnaBackend:
"""Close device connection when open."""
if self._device is None:
return
self._device.disconnect()
self._device = None
device, self._device = self._device, None
device.disconnect()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings to connected hardware."""
@@ -124,58 +104,3 @@ class NativeLibreVnaBackend:
"s21": np.asarray(result.trace("s21"), dtype=np.complex64),
},
)
@dataclass(slots=True)
class MockLibreVnaBackend:
"""Synthetic backend used for local development and tests."""
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_mock_phase: float = field(init=False, default=0.0, repr=False)
def __post_init__(self) -> None:
"""Initialize mock backend state."""
self._settings = None
self._mock_phase = 0.0
def open(self) -> None:
"""No-op for mock backend."""
def close(self) -> None:
"""No-op for mock backend."""
@property
def is_open(self) -> bool:
"""Mock backend does not hold external resources."""
return False
def configure(self, sweep: RadarSweepModel) -> None:
"""Store sweep settings used by synthetic acquisition."""
self._settings = sweep
def read_device_limits(self) -> dict[str, float | int]:
"""Mock backend does not support native device limits queries."""
raise RuntimeError("LibreVNA Python driver is not available")
def acquire(self) -> SweepResult:
"""Generate synthetic S11 and S21 values using deterministic envelopes."""
if self._settings is None:
raise RuntimeError("Radar service is not configured")
points = self._settings.points
freq = np.linspace(self._settings.start_hz, self._settings.stop_hz, points, dtype=np.float32)
phase = (2.0 * math.pi * np.linspace(0.0, 1.0, points, dtype=np.float32)) + self._mock_phase
s21_envelope = 0.6 + 0.4 * np.sin(phase * 0.5)
s11_envelope = 0.25 + 0.15 * np.cos(phase * 0.75)
s21 = (s21_envelope * np.cos(phase) + 1j * s21_envelope * np.sin(phase)).astype(np.complex64)
reflected_phase = phase * 0.6 + 0.8
s11 = (
s11_envelope * np.cos(reflected_phase) + 1j * s11_envelope * np.sin(reflected_phase)
).astype(np.complex64)
self._mock_phase += 0.05
return SweepResult(
x=freq,
traces={
"s11": s11,
"s21": s21,
},
)
@@ -201,6 +201,8 @@ class SweepResult:
x: np.ndarray
traces: dict[str, np.ndarray] = field(default_factory=dict)
x_label: str = "frequency_hz"
capture_start_ns: int = 0
capture_end_ns: int = 0
def __post_init__(self) -> None:
"""Normalize dtypes and validate shape compatibility."""
@@ -28,6 +28,7 @@ from .protocol import (
from .transport import USBTransport
logger = logging.getLogger(__name__)
_PACKET_QUEUE_MAX = 32768
class _IndexedPayload(Protocol):
@@ -50,7 +51,9 @@ class LibreVNASession:
on_disconnect=self._on_transport_disconnect,
)
self._incoming: dict[PacketType, deque[Packet]] = defaultdict(deque)
# The sweep free-runs between acquisitions, and subscriptions do not
# consume request queues. Retain a bounded tail of stale datapoints.
self._incoming: dict[PacketType, deque[Packet]] = defaultdict(lambda: deque(maxlen=_PACKET_QUEUE_MAX))
self._subscribers: dict[PacketType, set[Callable[[Packet], None]]] = defaultdict(set)
self._incoming_cv = threading.Condition()
@@ -159,8 +162,8 @@ class LibreVNASession:
def disconnect(self) -> None:
"""Disconnect USB transport and clear session state."""
logger.debug("Closing USB transport and clearing session queues")
self._transport.disconnect()
with self._incoming_cv:
self._fatal_error = DeviceDisconnectedError("LibreVNA session is disconnected")
self._incoming.clear()
self._subscribers.clear()
self._incoming_cv.notify_all()
@@ -168,6 +171,7 @@ class LibreVNASession:
self._awaiting_ack = False
self._ack_result = None
self._ack_cv.notify_all()
self._transport.disconnect()
def send(self, packet: Packet, *, require_ack: bool = True, timeout_s: float = 0.5) -> None:
"""Send one protocol packet and optionally wait for ACK/NACK."""
@@ -188,26 +192,26 @@ class LibreVNASession:
self._awaiting_ack = True
self._ack_result = None
self._transport.write(frame, timeout_s=timeout_s)
if require_ack:
deadline = time.monotonic() + timeout_s
with self._ack_cv:
while self._ack_result is None:
self._raise_if_fatal_locked()
remaining = deadline - time.monotonic()
if remaining <= 0:
self._awaiting_ack = False
raise TimeoutError(f"Timeout waiting for ACK for packet {packet.type.name}")
self._ack_cv.wait(timeout=remaining)
result = self._ack_result
self._awaiting_ack = False
self._ack_result = None
if result == PacketType.NACK:
raise NackError(f"Received NACK for packet {packet.type.name}")
logger.debug("ACK received for packet %s", packet.type.name)
try:
self._transport.write(frame, timeout_s=timeout_s)
if require_ack:
deadline = time.monotonic() + timeout_s
with self._ack_cv:
while self._ack_result is None:
self._raise_if_fatal_locked()
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Timeout waiting for ACK for packet {packet.type.name}")
self._ack_cv.wait(timeout=remaining)
result = self._ack_result
if result == PacketType.NACK:
raise NackError(f"Received NACK for packet {packet.type.name}")
logger.debug("ACK received for packet %s", packet.type.name)
finally:
if require_ack:
with self._ack_cv:
self._awaiting_ack = False
self._ack_result = None
def request(
self,
@@ -238,9 +242,10 @@ class LibreVNASession:
payload_type: type[TPacketPayload],
payload_error: str,
) -> list[TPacketPayload]:
"""Collect indexed payloads by `point_number` into ascending order.
"""Collect one sweep from point zero, returning points in ascending order.
Missing points are omitted; callers decide whether this is acceptable.
Ignore the tail before the first point zero. A new point zero discards
an incomplete earlier cycle instead of filling its gaps from a later one.
"""
if expected_points <= 0:
raise ValueError("expected_points must be > 0")
@@ -265,6 +270,11 @@ class LibreVNASession:
f"Received out-of-range point index {point_number}, expected 0..{expected_points - 1}"
)
if point_number == 0:
collected = [None] * expected_points
received = 0
elif collected[0] is None:
continue
if collected[point_number] is None:
received += 1
collected[point_number] = payload
@@ -391,8 +401,12 @@ class LibreVNASession:
callbacks: list[Callable[[Packet], None]] = []
with self._incoming_cv:
self._incoming[packet.type].append(packet)
callbacks = list(self._subscribers.get(packet.type, set()))
queue = self._incoming[packet.type]
if len(queue) == queue.maxlen and not callbacks and packet.type != PacketType.VNA_DATAPOINT:
self._set_fatal_error(ParseError(f"Packet queue overflow for {packet.type.name}"))
return
queue.append(packet)
self._incoming_cv.notify_all()
for callback in callbacks:
@@ -94,68 +94,60 @@ class USBTransport:
logger.debug("Opening libusb context for connect(serial=%s)", serial)
self._ctx = usb1.USBContext()
selected_handle: usb1.USBDeviceHandle | None = None
selected_serial = ""
try:
selected_handle: usb1.USBDeviceHandle | None = None
selected_serial = ""
for device, _, _ in self._iter_matching_devices(self._ctx):
handle: usb1.USBDeviceHandle | None = None
for device, _, _ in self._iter_matching_devices(self._ctx):
handle: usb1.USBDeviceHandle | None = None
try:
handle = device.open()
found_serial = handle.getSerialNumber() or ""
if serial is not None and found_serial != serial:
continue
selected_handle = handle
selected_serial = found_serial
break
except usb1.USBError as exc:
logger.debug("Skipping USB candidate during connect due to error: %s", exc)
finally:
if handle is not None and handle is not selected_handle:
with suppress(usb1.USBError):
handle.close()
if selected_handle is None:
serial_msg = f" with serial '{serial}'" if serial else ""
raise DeviceDisconnectedError(f"No compatible LibreVNA USB device found{serial_msg}")
self._handle = selected_handle
try:
handle = device.open()
found_serial = handle.getSerialNumber() or ""
if serial is not None and found_serial != serial:
handle.close()
continue
selected_handle = handle
selected_serial = found_serial
break
selected_handle.setAutoDetachKernelDriver(True)
if selected_handle.kernelDriverActive(self.INTERFACE):
selected_handle.detachKernelDriver(self.INTERFACE)
except usb1.USBError as exc:
if handle is not None:
with suppress(usb1.USBError):
handle.close()
logger.debug("Skipping USB candidate during connect due to error: %s", exc)
continue
raise DeviceDisconnectedError(f"Failed to prepare USB kernel driver state: {exc}") from exc
if selected_handle is None:
if self._ctx is not None:
self._ctx.close()
self._ctx = None
serial_msg = f" with serial '{serial}'" if serial else ""
raise DeviceDisconnectedError(f"No compatible LibreVNA USB device found{serial_msg}")
try:
self._claim_interface_with_busy_recovery(selected_handle)
except usb1.USBError as exc:
raise DeviceDisconnectedError(f"Failed to claim USB interface {self.INTERFACE}: {exc}") from exc
try:
selected_handle.setAutoDetachKernelDriver(True)
if selected_handle.kernelDriverActive(self.INTERFACE):
selected_handle.detachKernelDriver(self.INTERFACE)
except usb1.USBError as exc:
selected_handle.close()
if self._ctx is not None:
self._ctx.close()
self._ctx = None
raise DeviceDisconnectedError(f"Failed to prepare USB kernel driver state: {exc}") from exc
self.connected_serial = selected_serial
self._stop_event.clear()
logger.info(
"USB connected (serial=%s, timeout=%.2fs, endpoint_out=0x%02x, endpoint_in=0x%02x)",
self.connected_serial,
timeout_s,
self.DATA_EP_OUT,
self.DATA_EP_IN,
)
try:
self._claim_interface_with_busy_recovery(selected_handle)
except usb1.USBError as exc:
selected_handle.close()
if self._ctx is not None:
self._ctx.close()
self._ctx = None
raise DeviceDisconnectedError(f"Failed to claim USB interface {self.INTERFACE}: {exc}") from exc
self._handle = selected_handle
self.connected_serial = selected_serial
self._stop_event.clear()
logger.info(
"USB connected (serial=%s, timeout=%.2fs, endpoint_out=0x%02x, endpoint_in=0x%02x)",
self.connected_serial,
timeout_s,
self.DATA_EP_OUT,
self.DATA_EP_IN,
)
self._rx_thread = threading.Thread(target=self._rx_loop, name="librevna-usb-rx", daemon=True)
self._rx_thread.start()
self._rx_thread = threading.Thread(target=self._rx_loop, name="librevna-usb-rx", daemon=True)
self._rx_thread.start()
except Exception:
self.disconnect()
raise
def _claim_interface_with_busy_recovery(self, handle: "usb1.USBDeviceHandle") -> None: # type: ignore[name-defined]
"""Claim the data interface, clearing a stale BUSY claim once if needed.
+7 -76
View File
@@ -1,102 +1,38 @@
"""High-level orchestration service for configuring and querying LibreVNA."""
"""Native single-device LibreVNA service used by acquisition and device-limit queries."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend
from python_app.hardware_full.librevna_backends import NativeLibreVnaBackend
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class LibreVnaService:
"""Provide stable API for GUI/workflows while hiding backend details."""
"""Provide the native single-device API; factories select mock acquisition."""
serial: str | None = None
strict_protocol_version: int = 14
backend_mode: str = "auto"
_driver_available: bool = field(init=False, default=False, repr=False)
_backend: LibreVnaBackend | None = field(init=False, default=None, repr=False)
_using_mock_backend: bool = field(init=False, default=False, repr=False)
_backend: NativeLibreVnaBackend = field(init=False, repr=False)
def __post_init__(self) -> None:
"""Initialize selected backend and detect driver availability."""
self._driver_available = False
self._backend = None
self._using_mock_backend = False
mode = self.backend_mode.strip().lower()
if mode not in {"auto", "native", "mock"}:
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
try:
self._backend = NativeLibreVnaBackend(
serial=self.serial,
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":
raise
# 'auto' falls back to the mock backend ONLY when the native driver
# library itself is unavailable (a dev host without the USB stack) —
# this is not device-absence (that surfaces later from open()). Log it
# loudly so it is never a silent surprise; a deployed appliance should
# set driver_mode='native' to forbid the fallback entirely.
logger.warning("LibreVNA native backend unavailable; falling back to mock (mode=auto): %s", exc)
self._backend = MockLibreVnaBackend()
self._using_mock_backend = True
@property
def driver_available(self) -> bool:
"""Return `True` when native Python LibreVNA driver is available."""
return self._driver_available
if self.backend_mode.strip().lower() not in {"auto", "native"}:
raise ValueError(f"Unsupported native LibreVNA backend mode: {self.backend_mode}")
self._backend = NativeLibreVnaBackend(self.serial, self.strict_protocol_version)
def open(self) -> None:
"""Open backend resources."""
if not self._driver_available and not self._using_mock_backend:
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]:
"""Read native device limits from connected LibreVNA."""
if not self._driver_available:
raise RuntimeError("LibreVNA Python driver is not available")
if self._backend is None:
raise RuntimeError("LibreVNA backend is not initialized")
opened_here = not self._backend.is_open
try:
if opened_here:
@@ -107,9 +43,4 @@ class LibreVnaService:
self.close()
def acquire(self) -> SweepResult:
"""Acquire one sweep with all available traces from active backend."""
if self._backend is None:
raise RuntimeError("LibreVNA backend is not initialized")
if self._using_mock_backend and not self._driver_available and self.backend_mode != "mock":
raise RuntimeError("Device not found")
return self._backend.acquire()
@@ -1,14 +1,4 @@
"""Factory for matrix-mode (multi-port) radar acquisition services.
Matrix radars acquire the full virtual switch matrix per call via
``acquire_collection`` instead of one combo per ``acquire`` like single-radar
services. The supported models are LibreVNA in synchronized multi-device mode
and the PLANAR SN9000 multi-port analyzer.
Service implementations are imported lazily so a config that targets one
model does not pull in transport dependencies (libusb, pyvisa, etc.) needed
only by the other model.
"""
"""Factory for synchronized LibreVNA matrices and the shared physical mock."""
from __future__ import annotations
@@ -32,57 +22,51 @@ class MatrixRadarService(Protocol):
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire one complete virtual switch matrix in canonical combo order."""
...
def recover(self) -> None:
"""Reconnect to the radar after a transient acquisition failure."""
def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
"""Create the matrix radar service implementation for the active config."""
if not config.is_matrix_radar:
def create_acquisition_service(config: RunConfigModel) -> MatrixRadarService:
"""Create a native matrix service or the shared single/multi mock scene."""
if config.radar.driver_mode == "mock" and config.radar.model in {
RunConfigModel.LIBREVNA_MODEL, RunConfigModel.LIBREVNA_MULTI_MODEL,
}:
from python_app.hardware_full.point_target_mock_service import PointTargetMockService
return PointTargetMockService(config)
if config.radar.model != RunConfigModel.LIBREVNA_MULTI_MODEL:
raise RuntimeError("matrix radar service factory requires a matrix-mode radar.model")
model = config.radar.model
if model == RunConfigModel.LIBREVNA_MULTI_MODEL:
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
inner = MultiDeviceLibreVnaService(
master_serial=config.radar.serial,
slave_serials=list(config.radar.multi_device.slave_serials),
force_external_reference=config.radar.multi_device.force_external_reference,
recovery_attempts=config.radar.multi_device.recovery_attempts,
backend_mode=config.radar.driver_mode,
switch_substage_count=config.matrix_stimulus_switch_positions,
)
out_physical = config.matrix_output_switch_positions
in_physical = config.matrix_input_switch_positions
if out_physical <= 1 and in_physical <= 1:
return inner
inner = MultiDeviceLibreVnaService(
master_serial=config.radar.serial,
slave_serials=list(config.radar.multi_device.slave_serials),
force_external_reference=config.radar.multi_device.force_external_reference,
recovery_attempts=config.radar.multi_device.recovery_attempts,
backend_mode=config.radar.driver_mode,
switch_substage_count=config.matrix_stimulus_switch_positions,
)
out_physical = config.matrix_output_switch_positions
in_physical = config.matrix_input_switch_positions
if out_physical <= 1 and in_physical <= 1:
return inner
from python_app.hardware_full.switched_matrix_radar_service import (
SwitchedMatrixRadarService,
build_physical_switch,
)
from python_app.hardware_full.switched_matrix_radar_service import (
SwitchedMatrixRadarService,
build_physical_switch,
)
return SwitchedMatrixRadarService(
inner=inner,
output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode),
input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode),
inner_output_positions=config.matrix_virtual_output_positions,
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
settling_ms=config.runtime.settling_ms,
)
return SwitchedMatrixRadarService(
inner=inner,
output_switch=build_physical_switch(config.output_switch, out_physical, config.radar.driver_mode),
input_switch=build_physical_switch(config.input_switch, in_physical, config.radar.driver_mode),
inner_output_positions=config.matrix_virtual_output_positions,
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
settling_ms=config.runtime.settling_ms,
)
if model == RunConfigModel.SN9000_MODEL:
if config.radar.driver_mode != "native":
raise RuntimeError("SN9000 requires radar.driver_mode='native'")
from python_app.hardware_full.sn9000_service import Sn9000Service
visa_library = config.radar.visa_library or "@ivi"
return Sn9000Service(
host=config.radar.remote_host,
port=config.radar.remote_port,
visa_library=visa_library,
)
raise RuntimeError(f"Unsupported matrix radar model: {model}")
create_matrix_radar_service = create_acquisition_service
@@ -4,7 +4,6 @@ from __future__ import annotations
from dataclasses import dataclass, field
import logging
import math
import threading
import time
from typing import TYPE_CHECKING
@@ -89,8 +88,6 @@ class MultiDeviceLibreVnaService:
switch_substage_count: int = 1
_controller: "MultiDeviceVnaController | None" = field(init=False, default=None, repr=False)
_sweep_configuration: SweepConfiguration | None = field(init=False, default=None, repr=False)
_using_mock_backend: bool = field(init=False, default=False, repr=False)
_mock_phase: float = field(init=False, default=0.0, repr=False)
def __post_init__(self) -> None:
"""Validate static topology and backend selection."""
@@ -101,10 +98,9 @@ class MultiDeviceLibreVnaService:
self.recovery_attempts = max(0, int(self.recovery_attempts))
mode = self.backend_mode.strip().lower()
if mode not in {"auto", "native", "mock"}:
if mode not in {"auto", "native"}:
raise ValueError(f"Unsupported multi-device backend mode: {self.backend_mode}")
self.backend_mode = mode
self._using_mock_backend = mode == "mock"
self.switch_substage_count = normalize_switch_substage_count(self.switch_substage_count)
@property
@@ -112,14 +108,9 @@ class MultiDeviceLibreVnaService:
"""Return how many stimulus outputs one acquisition produces."""
return RunConfigModel.MULTI_DEVICE_MASTER_PORTS * self.switch_substage_count
@property
def using_mock_backend(self) -> bool:
"""Return whether this service is generating synthetic data."""
return self._using_mock_backend
def open(self) -> None:
"""Open native device transports when not in mock mode."""
if self._using_mock_backend or self._controller is not None:
"""Open native device transports."""
if self._controller is not None:
return
try:
from python_app.hardware_full.librevna_multi_device_driver.controller import MultiDeviceVnaController
@@ -135,11 +126,6 @@ class MultiDeviceLibreVnaService:
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
# explicit backend_mode='mock' (selected in __post_init__); both 'auto'
# and 'native' re-raise so the producer's wait-for-device retry keeps
# trying until the hardware appears.
logger.warning("Multi-device open failed (backend_mode=%s): %s", self.backend_mode, exc)
raise
@@ -176,8 +162,6 @@ class MultiDeviceLibreVnaService:
sleeping, so a shutdown request aborts the loop immediately; an optional
`deadline_monotonic` caps the total wall-time spent here.
"""
if self._using_mock_backend:
return
self.close()
# Try to reopen immediately first, then back off only after a failure: a
@@ -261,12 +245,9 @@ class MultiDeviceLibreVnaService:
if self._sweep_configuration is None:
raise RuntimeError("Multi-device service is not configured")
capture_start_ns = time.monotonic_ns()
if self._using_mock_backend:
collection = self._acquire_mock_collection(collection_id, capture_start_ns)
else:
collection = self._acquire_native_collection_with_recovery(
collection_id, capture_start_ns, stop_event=stop_event
)
collection = self._acquire_native_collection_with_recovery(
collection_id, capture_start_ns, stop_event=stop_event
)
collection.capture_end_ns = time.monotonic_ns()
return collection
@@ -417,52 +398,6 @@ class MultiDeviceLibreVnaService:
capture_start_ns=capture_start_ns,
)
def _acquire_mock_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
assert self._sweep_configuration is not None
mock_sweep_start_ns = time.monotonic_ns()
points = int(self._sweep_configuration.points)
frequencies = np.linspace(
self._sweep_configuration.start_hz,
self._sweep_configuration.stop_hz,
points,
dtype=np.float32,
)
base_phase = 2.0 * math.pi * np.linspace(0.0, 1.0, points, dtype=np.float32) + self._mock_phase
traces: list[TraceData] = []
for output_pos in range(self.output_position_count):
reflected_phase = base_phase * (0.55 + 0.05 * output_pos) + 0.7 * (output_pos + 1)
s11 = (
(0.22 + 0.04 * output_pos) * np.cos(reflected_phase)
+ 1j * (0.22 + 0.04 * output_pos) * np.sin(reflected_phase)
).astype(np.complex64)
for input_pos in range(RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS):
gain = 0.45 + 0.08 * input_pos + 0.03 * output_pos
phase = base_phase * (1.0 + 0.03 * input_pos) + (0.4 * input_pos + 0.9 * output_pos)
s21 = (gain * np.cos(phase) + 1j * gain * np.sin(phase)).astype(np.complex64)
traces.append(
TraceData(
combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequencies,
s11=s11,
s21=s21,
capture_start_ns=mock_sweep_start_ns,
)
)
# One shared window, as on real hardware: every combo of a collection comes
# out of the same synchronized cycle. Stamping each trace as it is built
# would instead spread the ends over the generation loop, which never
# happens natively and would mislead anyone reading mock captures.
mock_sweep_end_ns = time.monotonic_ns()
for trace in traces:
trace.capture_end_ns = mock_sweep_end_ns
self._mock_phase += 0.05
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
)
@staticmethod
def _required_s_parameter(s_parameters: dict[str, np.ndarray], name: str) -> np.ndarray:
values = s_parameters.get(name)
@@ -0,0 +1,121 @@
"""One moving point reflector shared by single and multi-radar mock acquisition."""
from __future__ import annotations
from dataclasses import dataclass, field
import time
import numpy as np
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
_WAVE_SPEED_M_S = 3e8
_FRAME_PERIOD_S = 0.05
_MOTION_PERIOD_S = 8.0
@dataclass(slots=True)
class PointTargetMockService:
"""Simulate bistatic propagation from configured antennas to one moving target.
The target moves back and forth along the array and between depths 3.25–4.25 m.
One monotonic sample time determines every channel of a collection; channel
numbers never contribute an arbitrary gain, range, or phase offset.
"""
config: RunConfigModel
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
_phase_per_m: np.ndarray | None = field(init=False, default=None, repr=False)
_last_capture_ns: int = field(init=False, default=0, repr=False)
_epoch_ns: int = field(init=False, repr=False)
_combos: tuple[ComboKey, ...] = field(init=False, repr=False)
_active_combo: ComboKey = field(init=False, repr=False)
def __post_init__(self) -> None:
self._epoch_ns = time.monotonic_ns()
self._combos = tuple(ComboKey(c.input, c.output) for c in (
self.config.combos or self.config.build_runtime_combos()
))
self._active_combo = self._combos[0]
def open(self) -> None:
"""Mock acquisition owns no hardware resources."""
def close(self) -> None:
"""Mock acquisition owns no hardware resources."""
def recover(self) -> None:
"""Mock acquisition has no hardware connection to recover."""
def read_device_limits(self) -> dict[str, float | int]:
raise RuntimeError("Mock radar has no hardware device limits")
def configure(self, sweep: RadarSweepModel) -> None:
self._frequency_hz = np.linspace(sweep.start_hz, sweep.stop_hz, sweep.points, dtype=np.float32)
self._phase_per_m = -2j * np.pi * self._frequency_hz.astype(np.float64) / _WAVE_SPEED_M_S
def set_active_combo(self, combo: ComboKey) -> None:
if not (0 <= combo.input < self.config.input_switch.positions
and 0 <= combo.output < self.config.output_switch.positions):
raise ValueError(f"Mock combo is outside the antenna matrix: {combo}")
self._active_combo = ComboKey(combo.input, combo.output)
def acquire(self) -> SweepResult:
collection = self._capture((self._active_combo,), 1)
trace = collection.traces[0]
return SweepResult(
x=trace.frequency_hz, traces={"s11": trace.s11, "s21": trace.s21},
capture_start_ns=trace.capture_start_ns, capture_end_ns=trace.capture_end_ns,
)
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
return self._capture(self._combos, collection_id)
def acquire_combo_collection(
self, *, input_pos: int, output_pos: int, collection_id: int = 1,
) -> SweepCollection:
self.set_active_combo(ComboKey(input_pos, output_pos))
return self._capture((self._active_combo,), collection_id)
def _target_position(
self, sample_ns: int,
tx: dict[int, tuple[float, float, float]], rx: dict[int, tuple[float, float, float]],
) -> np.ndarray:
coordinates = [*tx.values(), *rx.values()] or [(0.0, 0.0, 0.0)]
xs = [position[0] for position in coordinates]
displacement = np.sin(2 * np.pi * (sample_ns - self._epoch_ns) / 1e9 / _MOTION_PERIOD_S)
return np.array([
(min(xs) + max(xs)) / 2 + max(0.75, (max(xs) - min(xs)) / 2 + 0.25) * displacement,
0.25, 3.75 + 0.5 * displacement,
])
def _capture(self, combos: tuple[ComboKey, ...], collection_id: int) -> SweepCollection:
if self._frequency_hz is None or self._phase_per_m is None:
raise RuntimeError("Mock radar is not configured")
delay_s = _FRAME_PERIOD_S - (time.monotonic_ns() - self._last_capture_ns) / 1e9
if delay_s > 0:
time.sleep(delay_s)
capture_start_ns = time.monotonic_ns()
tx = {p.output_pos: (p.x_m, p.y_m, p.z_m) for p in self.config.gpr.tx_geometry}
rx = {p.input_pos: (p.x_m, p.y_m, p.z_m) for p in self.config.gpr.rx_geometry}
target = self._target_position(capture_start_ns, tx, rx)
traces = []
for combo in combos:
r_tx = float(np.linalg.norm(target - tx.get(combo.output, (0.0, 0.0, 0.0))))
r_rx = float(np.linalg.norm(target - rx.get(combo.input, (0.0, 0.0, 0.0))))
traces.append(TraceData(
combo=combo, frequency_hz=self._frequency_hz,
s11=np.asarray(0.5 / max(1.0, r_tx * r_tx) * np.exp(self._phase_per_m * 2 * r_tx), dtype=np.complex64),
s21=np.asarray(0.5 / max(1.0, r_tx * r_rx) * np.exp(self._phase_per_m * (r_tx + r_rx)), dtype=np.complex64),
capture_start_ns=capture_start_ns,
))
capture_end_ns = time.monotonic_ns()
for trace in traces:
trace.capture_end_ns = capture_end_ns
self._last_capture_ns = capture_start_ns
return SweepCollection(
collection_id=collection_id, monotonic_ns=capture_end_ns, traces=traces,
capture_start_ns=capture_start_ns, capture_end_ns=capture_end_ns,
)
@@ -1,172 +0,0 @@
"""TCP client for a Compact-M K209 connected to a remote S2VNA host."""
from __future__ import annotations
from dataclasses import dataclass, field
import socket
import numpy as np
from python_app.hardware_full.k209_remote_protocol import (
COMMAND_ACQUIRE,
COMMAND_CONFIGURE,
COMMAND_IDENTITY,
COMMAND_LIMITS,
CONFIG_STRUCT,
DEFAULT_REMOTE_HOST,
DEFAULT_REMOTE_PORT,
LIMITS_STRUCT,
read_status,
recv_exact,
recv_float32_array,
recv_u32,
)
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
@dataclass(slots=True)
class RemoteCompactMK209Service:
"""Acquire K209 sweeps through a persistent TCP connection."""
host: str = DEFAULT_REMOTE_HOST
port: int = DEFAULT_REMOTE_PORT
timeout_s: float = 20.0
_socket: socket.socket | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
self.host = str(self.host).strip()
if not self.host:
raise ValueError("K209 remote host must not be empty")
self.port = int(self.port)
if self.port <= 0 or self.port > 65535:
raise ValueError("K209 remote port must be in 1..65535")
self.timeout_s = float(self.timeout_s)
if self.timeout_s <= 0.0:
raise ValueError("K209 remote timeout_s must be > 0")
def open(self) -> None:
"""Open TCP connection and apply stored settings when present."""
if self._socket is not None:
return
sock = socket.create_connection((self.host, self.port), timeout=self.timeout_s)
sock.settimeout(self.timeout_s)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self._socket = sock
try:
if self._settings is not None:
self._apply_configuration(self._settings)
except Exception:
self.close()
raise
def close(self) -> None:
"""Close TCP connection."""
if self._socket is None:
return
try:
self._socket.close()
finally:
self._socket = None
def query_identity(self) -> str:
"""Read analyzer identity string through the remote server."""
sock = self._require_socket()
sock.sendall(COMMAND_IDENTITY)
read_status(sock)
return recv_exact(sock, recv_u32(sock)).decode("utf-8", errors="replace").strip()
def read_device_limits(self) -> dict[str, float | int]:
"""Read analyzer limits through the remote server."""
opened_here = self._socket is None
try:
if opened_here:
self.open()
sock = self._require_socket()
sock.sendall(COMMAND_LIMITS)
read_status(sock)
min_freq, max_freq, min_ifbw, max_ifbw, max_points, min_power, max_power = LIMITS_STRUCT.unpack(
recv_exact(sock, LIMITS_STRUCT.size)
)
return {
"min_frequency_hz": float(min_freq),
"max_frequency_hz": float(max_freq),
"min_ifbw_hz": float(min_ifbw),
"max_ifbw_hz": float(max_ifbw),
"max_points": int(max_points),
"min_power_dbm": float(min_power),
"max_power_dbm": float(max_power),
}
finally:
if opened_here:
self.close()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings."""
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
if self._socket is not None:
self._apply_configuration(sweep)
def acquire(self) -> SweepResult:
"""Acquire one corrected S11/S21 sweep."""
if self._settings is None or self._frequency_hz is None:
raise RuntimeError("K209 remote service is not configured")
sock = self._require_socket()
points = int(self._settings.points)
sock.sendall(COMMAND_ACQUIRE)
read_status(sock)
returned_points = recv_u32(sock)
if returned_points != points:
raise RuntimeError(f"K209 remote sweep returned {returned_points} points, expected {points}")
s11_values = recv_float32_array(sock, points * 2)
s21_values = recv_float32_array(sock, points * 2)
return SweepResult(
x=self._frequency_hz.copy(),
traces={
"s11": self._complex_from_interleaved(s11_values),
"s21": self._complex_from_interleaved(s21_values),
},
)
def _apply_configuration(self, sweep: RadarSweepModel) -> None:
sock = self._require_socket()
sock.sendall(COMMAND_CONFIGURE)
sock.sendall(
CONFIG_STRUCT.pack(
float(sweep.start_hz),
float(sweep.stop_hz),
int(sweep.points),
float(sweep.if_bandwidth_hz),
float(sweep.power_dbm),
)
)
read_status(sock)
returned_points = recv_u32(sock)
if returned_points != int(sweep.points):
raise RuntimeError(f"K209 remote config returned {returned_points} points, expected {sweep.points}")
self._frequency_hz = recv_float32_array(sock, int(sweep.points))
def _require_socket(self) -> socket.socket:
if self._socket is None:
raise RuntimeError("K209 remote socket is not open")
return self._socket
@staticmethod
def _validate_sweep(sweep: RadarSweepModel) -> None:
if int(sweep.points) < 2:
raise ValueError("K209 sweep points must be >= 2")
if float(sweep.stop_hz) < float(sweep.start_hz):
raise ValueError("K209 sweep stop_hz must be >= start_hz")
if float(sweep.if_bandwidth_hz) <= 0.0:
raise ValueError("K209 IF bandwidth must be > 0")
@staticmethod
def _complex_from_interleaved(values: np.ndarray) -> np.ndarray:
if values.size % 2 != 0:
raise RuntimeError("K209 remote complex trace payload has odd scalar count")
reshaped = np.asarray(values, dtype=np.float32).reshape((-1, 2))
return (reshaped[:, 0] + 1j * reshaped[:, 1]).astype(np.complex64)
@@ -8,7 +8,6 @@ from typing import Protocol
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.librevna_driver.models import SweepResult
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__)
@@ -28,9 +27,11 @@ class SingleRadarService(Protocol):
def read_device_limits(self) -> dict[str, float | int]:
"""Read device capability limits."""
...
def acquire(self) -> SweepResult:
"""Acquire one sweep."""
...
def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
@@ -44,17 +45,11 @@ 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.
return LibreVnaService(serial=config.radar.serial or None, backend_mode=config.radar.driver_mode)
if config.radar.driver_mode == "mock":
from python_app.hardware_full.point_target_mock_service import PointTargetMockService
if model == RunConfigModel.COMPACT_M_K209_MODEL:
if config.radar.driver_mode != "native":
raise RuntimeError("Compact-M K209 requires radar.driver_mode='native'")
return RemoteCompactMK209Service(
host=config.radar.remote_host,
port=config.radar.remote_port,
)
return PointTargetMockService(config)
return LibreVnaService(serial=config.radar.serial or None, backend_mode=config.radar.driver_mode)
if model == RunConfigModel.KAMIL_ADC_MODEL:
if config.radar.driver_mode != "native":
-395
View File
@@ -1,395 +0,0 @@
"""VISA HiSLIP driver for the PLANAR SN9000 (Иридиум series) multi-port VNA.
The SN9000 hardware connects to a host PC over USB 2.0; the PC runs the SNVNA
application, which exposes the SCPI HiSLIP server. This service connects to
that server, configures a 2x4 virtual switch matrix (ports 1 and 2 stimulate,
ports 3..6 receive), and acquires the full matrix per call in a single
synchronized SCPI round trip.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import time
from typing import Any
import numpy as np
import pyvisa
logger = logging.getLogger(__name__)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel
_OUTPUT_PORT_BY_INDEX: tuple[int, ...] = (1, 2)
_INPUT_PORT_BY_INDEX: tuple[int, ...] = (3, 4, 5, 6)
_REFLECTION_BY_OUTPUT: tuple[str, ...] = tuple(
f"S{port}{port}" for port in _OUTPUT_PORT_BY_INDEX
)
_S_PARAMETER_QUERY_ORDER: tuple[str, ...] = tuple(
name
for output_port in _OUTPUT_PORT_BY_INDEX
for name in (
f"S{output_port}{output_port}",
*(f"S{receiver_port}{output_port}" for receiver_port in _INPUT_PORT_BY_INDEX),
)
)
@dataclass(slots=True)
class Sn9000Service:
"""Acquire the full 2x4 corrected sweep matrix from an SN9000 through VISA HiSLIP."""
host: str = "127.0.0.1"
port: int = 4880
timeout_ms: int = 20_000
preset_on_open: bool = True
visa_library: str = "@ivi"
_resource_manager: pyvisa.ResourceManager | None = field(init=False, default=None, repr=False)
_instrument: Any | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
"""Normalize and validate constructor values."""
self.host = str(self.host).strip()
if not self.host:
raise ValueError("SN9000 host must not be empty")
self.port = int(self.port)
if self.port <= 0 or self.port > 65535:
raise ValueError("SN9000 port must be in 1..65535")
self.timeout_ms = int(self.timeout_ms)
if self.timeout_ms <= 0:
raise ValueError("SN9000 timeout_ms must be > 0")
self.visa_library = str(self.visa_library).strip() or "@ivi"
@property
def resource(self) -> str:
"""Return the assembled VISA HiSLIP resource string."""
return f"TCPIP0::{self.host}::hislip0,{self.port}::INSTR"
@property
def is_open(self) -> bool:
"""Return whether the VISA session is open."""
return self._instrument is not None
def open(self) -> None:
"""Open the VISA session and apply stored sweep settings when available."""
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)
self._instrument.timeout = self.timeout_ms
self._instrument.write_termination = "\n"
self._instrument.read_termination = None
self._instrument.chunk_size = max(int(getattr(self._instrument, "chunk_size", 20_480)), 8 * 1024 * 1024)
self._instrument.write("*CLS")
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:
self._resource_manager.close()
self._resource_manager = None
def __enter__(self) -> Sn9000Service:
"""Open the service and return it."""
self.open()
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
"""Close VISA resources on context exit."""
self.close()
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()
def query_identity(self) -> str:
"""Read the analyzer identity string."""
instrument = self._require_instrument()
return str(instrument.query("*IDN?")).strip()
def query_system_error(self) -> str:
"""Read one entry from the analyzer SCPI error queue."""
instrument = self._require_instrument()
return str(instrument.query("SYST:ERR?")).strip()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings."""
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)
def read_device_limits(self) -> dict[str, float | int]:
"""Read analyzer limits through SCPI capability/service queries."""
opened_here = self._instrument is None
try:
if opened_here:
self.open()
instrument = self._require_instrument()
return {
"min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")),
"max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")),
# SN9000 SCPI does not expose IFBW capability queries; use the
# documented hardware sequence (1 Hz .. 300 kHz, manual p. 58, 1261).
"min_ifbw_hz": 1.0,
"max_ifbw_hz": 300_000.0,
"max_points": int(float(instrument.query("SERV:SWE:POIN?"))),
"min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")),
"max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")),
}
finally:
if opened_here:
self.close()
def frequency_axis(self) -> np.ndarray:
"""Return the cached frequency axis from the most recent configuration."""
if self._frequency_hz is None:
raise RuntimeError("SN9000 frequency axis is not configured")
return self._frequency_hz
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire one full virtual 2x4 matrix in canonical combo order."""
if self._settings is None:
raise RuntimeError("SN9000 service is not configured")
if self._frequency_hz is None:
raise RuntimeError("SN9000 frequency axis is not configured")
points = int(self._settings.points)
capture_start_ns = time.monotonic_ns()
s_parameters = self._query_sweep_s_parameters(points)
traces = self._assemble_traces(
s_parameters,
sweep_start_ns=capture_start_ns,
sweep_end_ns=time.monotonic_ns(),
)
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 _apply_configuration(self, sweep: RadarSweepModel) -> None:
instrument = self._require_instrument()
if self.preset_on_open:
instrument.write("SYST:PRES")
self._expect_opc("*OPC?", context="SN9000 preset")
instrument.write(f"CALC:PAR:COUN {len(_S_PARAMETER_QUERY_ORDER)}")
for trace_index, parameter_name in enumerate(_S_PARAMETER_QUERY_ORDER, start=1):
instrument.write(f"CALC:PAR{trace_index}:DEF {parameter_name}")
instrument.write("SENS:SWE:TYPE LIN")
instrument.write(f"SENS:FREQ:STAR {float(sweep.start_hz):.9f}")
instrument.write(f"SENS:FREQ:STOP {float(sweep.stop_hz):.9f}")
instrument.write(f"SENS:SWE:POIN {int(sweep.points)}")
instrument.write("SENS:SWE:POIN:TIME 0")
instrument.write(f"SENS:BAND {float(sweep.if_bandwidth_hz):.9f}")
instrument.write("SOUR:POW:PORT:COUP ON")
instrument.write(f"SOUR:POW {float(sweep.power_dbm):.3f}")
instrument.write("SENS:AVER OFF")
instrument.write("FORM:DATA REAL32")
instrument.write("FORM:BORD SWAP")
instrument.write("INIT:CONT ON")
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()
if self._uses_pyvisa_py_backend():
# pyvisa-py HiSLIP loses synchronization when a single packet aggregates
# *OPC? plus multiple binary blocks, so issue trigger and data queries
# one at a time. The corrected-data buffer holds the last completed
# sweep, so reading each S-parameter sequentially is safe.
instrument.write("TRIG:SING")
self._expect_opc("*OPC?", context="SN9000 sweep")
complex_values: dict[str, np.ndarray] = {}
for parameter_name in _S_PARAMETER_QUERY_ORDER:
instrument.write(f"SENS:DATA:CORR? {parameter_name}")
interleaved = self._read_float32_block(
f"SENS:DATA:CORR? {parameter_name}", points * 2
)
complex_values[parameter_name] = self._complex_from_interleaved(interleaved)
return complex_values
data_queries = ";".join(f":SENS:DATA:CORR? {name}" for name in _S_PARAMETER_QUERY_ORDER)
instrument.write(f"TRIG:SING;*OPC?;{data_queries}")
opc_token = self._read_ascii_token()
if opc_token != "1":
raise RuntimeError(f"SN9000 sweep returned unexpected *OPC? response: {opc_token!r}")
complex_values = {}
for parameter_name in _S_PARAMETER_QUERY_ORDER:
interleaved = self._read_float32_block(f"SENS:DATA:CORR? {parameter_name}", points * 2)
complex_values[parameter_name] = self._complex_from_interleaved(interleaved)
return complex_values
def _uses_pyvisa_py_backend(self) -> bool:
return self.visa_library == "@py" or self.visa_library.endswith("@py")
def _assemble_traces(
self,
s_parameters: dict[str, np.ndarray],
*,
sweep_start_ns: int,
sweep_end_ns: int,
) -> list[TraceData]:
frequency_hz = self._require_frequency_axis()
traces: list[TraceData] = []
for output_position, output_port in enumerate(_OUTPUT_PORT_BY_INDEX):
reflection = s_parameters[_REFLECTION_BY_OUTPUT[output_position]]
for input_position, receiver_port in enumerate(_INPUT_PORT_BY_INDEX):
transmission = s_parameters[f"S{receiver_port}{output_port}"]
traces.append(
TraceData(
combo=ComboKey(input=input_position, output=output_position),
frequency_hz=frequency_hz,
s11=reflection,
s21=transmission,
# One triggered sweep produces every port pair at once, so
# all combos share the sweep's window.
capture_start_ns=int(sweep_start_ns),
capture_end_ns=int(sweep_end_ns),
)
)
return traces
def _expect_opc(self, command: str, *, context: str) -> None:
instrument = self._require_instrument()
response = str(instrument.query(command)).strip()
if response != "1":
raise RuntimeError(f"{context} returned unexpected *OPC? response: {response!r}")
def _query_float32_array(self, command: str, expected_values: int) -> np.ndarray:
instrument = self._require_instrument()
instrument.write(command)
return self._read_float32_block(command, expected_values)
def _read_ascii_token(self) -> str:
token = bytearray()
while True:
byte = self._read_response_bytes(1)
if byte in (b";", b"\n", b"\r"):
if token:
return token.decode("ascii").strip()
continue
token.extend(byte)
if len(token) > 64 * 1024:
raise RuntimeError("SN9000 ASCII response token is too long")
def _read_float32_block(self, context: str, expected_values: int) -> np.ndarray:
marker = self._read_response_bytes(1)
while marker in (b";", b"\n", b"\r"):
marker = self._read_response_bytes(1)
if marker != b"#":
raise RuntimeError(f"SN9000 response for {context!r} does not start with IEEE block marker")
width = self._read_response_bytes(1)
if width != b"8":
raise RuntimeError(f"SN9000 response for {context!r} uses unsupported IEEE block header width")
payload_size = int(self._read_response_bytes(8).decode("ascii"))
expected_size = expected_values * np.dtype(np.float32).itemsize
if payload_size != expected_size:
raise RuntimeError(
f"SN9000 response for {context!r} returned {payload_size} payload bytes, "
f"expected {expected_size}"
)
payload = self._read_response_bytes(payload_size)
array = np.frombuffer(payload, dtype="<f4")
if array.size != expected_values:
raise RuntimeError(
f"SN9000 response for {context!r} returned {array.size} float32 values, "
f"expected {expected_values}"
)
self._drain_trailing_terminators()
return array
def _drain_trailing_terminators(self) -> None:
"""Consume the SCPI terminator that follows IEEE binary blocks.
SCPI responses end with `\\n`, which over HiSLIP closes the DataEnd
message group. pyvisa-py's HiSLIP layer needs the terminator drained
before the next request, otherwise it loses message-frame
synchronization on subsequent reads.
"""
instrument = self._require_instrument()
deadline = time.monotonic() + 0.2
while time.monotonic() < deadline:
try:
instrument.read_bytes(1, break_on_termchar=True)
return
except pyvisa.errors.VisaIOError as exc:
# Timeouts on a trailing newline are routine; anything else
# likely means HiSLIP framing is out of sync and the next
# request will hang — surface it in the logs.
if exc.error_code != pyvisa.constants.StatusCode.error_timeout:
logger.warning("SN9000 terminator drain failed: %s", exc)
return
def _read_response_bytes(self, count: int) -> bytes:
instrument = self._require_instrument()
data = instrument.read_bytes(count, break_on_termchar=False)
if len(data) != count:
raise RuntimeError(f"SN9000 response ended after {len(data)} bytes, expected {count}")
return data
def _require_instrument(self) -> Any:
if self._instrument is None:
raise RuntimeError("SN9000 VISA instrument is not open")
return self._instrument
def _require_frequency_axis(self) -> np.ndarray:
if self._frequency_hz is None:
raise RuntimeError("SN9000 frequency axis is not configured")
return self._frequency_hz
@staticmethod
def _validate_sweep(sweep: RadarSweepModel) -> None:
if int(sweep.points) < 2:
raise ValueError("SN9000 sweep points must be >= 2")
if float(sweep.stop_hz) < float(sweep.start_hz):
raise ValueError("SN9000 sweep stop_hz must be >= start_hz")
if float(sweep.if_bandwidth_hz) <= 0.0:
raise ValueError("SN9000 IF bandwidth must be > 0")
@staticmethod
def _complex_from_interleaved(values: np.ndarray) -> np.ndarray:
if values.size % 2 != 0:
raise RuntimeError("SN9000 complex trace payload has odd scalar count")
reshaped = np.asarray(values, dtype=np.float32).reshape((-1, 2))
return (reshaped[:, 0] + 1j * reshaped[:, 1]).astype(np.complex64)
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from contextlib import suppress
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
@@ -29,12 +30,6 @@ class H7992Driver:
_is_open: bool = field(init=False, default=False, repr=False)
_current_position: int = field(init=False, default=0, repr=False)
def __post_init__(self) -> None:
"""Initialize runtime state."""
self._lines: GpioOutputLines | None = None
self._is_open = False
self._current_position = 0
def open(self) -> None:
"""Open GPIO lines and switch to default position."""
if self._is_open:
@@ -46,16 +41,21 @@ class H7992Driver:
offsets=(self.pin_a, self.pin_b),
consumer=f"radar_{self.name}",
)
self._lines.open()
self._is_open = True
self.switch_to(self.default_position)
try:
self._lines.open()
self._is_open = True
self.switch_to(self.default_position)
except Exception:
with suppress(Exception):
self.close()
raise
def close(self) -> None:
"""Close GPIO lines."""
if self._lines is not None:
self._lines.close()
self._lines = None
lines, self._lines = self._lines, None
self._is_open = False
if lines is not None:
lines.close()
def position_count(self) -> int:
"""Return number of supported positions."""
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from contextlib import suppress
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
@@ -21,12 +22,6 @@ class HMC349ADriver:
_is_open: bool = field(init=False, default=False, repr=False)
_current_position: int = field(init=False, default=0, repr=False)
def __post_init__(self) -> None:
"""Initialize runtime state."""
self._lines: GpioOutputLines | None = None
self._is_open = False
self._current_position = 0
def open(self) -> None:
"""Open GPIO lines and switch to default position."""
if self._is_open:
@@ -40,16 +35,21 @@ class HMC349ADriver:
offsets=offsets,
consumer=f"radar_{self.name}",
)
self._lines.open()
self._is_open = True
self.switch_to(self.default_position)
try:
self._lines.open()
self._is_open = True
self.switch_to(self.default_position)
except Exception:
with suppress(Exception):
self.close()
raise
def close(self) -> None:
"""Close GPIO lines."""
if self._lines is not None:
self._lines.close()
self._lines = None
lines, self._lines = self._lines, None
self._is_open = False
if lines is not None:
lines.close()
def position_count(self) -> int:
"""Return number of supported positions."""
@@ -38,21 +38,24 @@ class SwitchedMatrixRadarService:
def open(self) -> None:
"""Open the inner radar and both switches."""
self.inner.open()
if self.output_switch is not None:
self.output_switch.open()
if self.input_switch is not None:
self.input_switch.open()
try:
self.inner.open()
if self.output_switch is not None:
self.output_switch.open()
if self.input_switch is not None:
self.input_switch.open()
except Exception:
self.close()
raise
def close(self) -> None:
"""Close switches first, then the inner radar; never raises."""
for switch in (self.input_switch, self.output_switch):
if switch is not None:
for resource in (self.input_switch, self.output_switch, self.inner):
if resource is not None:
try:
switch.close()
resource.close()
except Exception as exc: # noqa: BLE001 — shutdown path
logger.warning("Switch close ignored error: %s", exc)
self.inner.close()
logger.warning("Switched matrix cleanup failed: %s", exc)
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings to the inner radar."""
@@ -211,4 +214,4 @@ def build_physical_switch(
driver_mode = "mock" if radar_driver_mode.strip().lower() == "mock" else "native"
return SwitchService.from_model(
replace(model, positions=physical_positions, driver_mode=driver_mode)
)
)