Refactor radar system
This commit is contained in:
@@ -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.")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user