Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -5,7 +5,6 @@ import logging
from .enums import (
HardwareFamily,
PacketType,
SParameter,
SweepKind,
SweepScale,
SyncMode,
@@ -53,7 +52,6 @@ __all__ = [
"PacketType",
"ParseError",
"ProtocolVersionMismatch",
"SParameter",
"StreamHandle",
"SweepKind",
"SweepResult",
@@ -1,11 +0,0 @@
"""Public controller classes."""
from .config import ConfigController
from .generator import GeneratorController
from .vna import VNAController
__all__ = [
"ConfigController",
"GeneratorController",
"VNAController",
]
@@ -4,13 +4,12 @@ from __future__ import annotations
from dataclasses import replace
import logging
from typing import Callable
from ..enums import PacketType
from ..enums import PacketType, SyncMode
from ..exceptions import ParseError
from ..models import Packet, StreamHandle, SweepResult, VNADatapointPacket, VNARawPoint, VNASweepSettings
from ..models import Packet, SweepResult, VNADatapointPacket, VNASweepSettings
from ..session import LibreVNASession
from ..sweep.assembler import assemble_vna_sweep, datapoint_to_raw_point
from ..sweep.assembler import assemble_vna_sweep
logger = logging.getLogger(__name__)
@@ -27,22 +26,21 @@ class VNAController:
"""Send `SweepSettings` packet to configure VNA operation."""
effective = settings
if effective.sync_mode is None:
effective = replace(effective, sync_mode=self._session.default_sync_mode)
effective = replace(effective, sync_mode=SyncMode.DISABLED)
self._settings = effective
self._session.clear_queue(PacketType.VNA_DATAPOINT)
self._session.send(Packet(PacketType.SWEEP_SETTINGS, effective), require_ack=True)
logger.info(
"VNA configured: kind=%s start=%.3fHz stop=%.3fHz points=%d ifbw=%.3fHz ports=%s",
"VNA configured: kind=%s start=%.3fHz stop=%.3fHz points=%d ifbw=%.3fHz",
effective.kind.value,
effective.f_start_hz,
effective.f_stop_hz,
effective.points,
effective.if_bandwidth_hz,
effective.excited_ports,
)
def acquire(self, *, expected_points: int | None = None, timeout_s: float = 10.0) -> SweepResult:
def acquire(self, *, expected_points: int | None = None, timeout_s: float | None = None) -> SweepResult:
"""Acquire one complete sweep and return assembled complex traces."""
if self._settings is None:
raise ParseError("VNA is not configured. Call vna.configure() before acquire().")
@@ -52,6 +50,13 @@ class VNAController:
if points_target <= 0:
raise ValueError("expected_points must be > 0")
# Firmware integrates for 1/IFBW after dwell/PLL settling at every point.
# Allow a discarded free-running tail and USB/host overhead, but detect
# a stalled instrument independently of this whole-sweep budget.
point_seconds = 1.0 / settings.if_bandwidth_hz + settings.dwell_s + 0.001
if timeout_s is None:
timeout_s = max(10.0, 2.0 * points_target * point_seconds + 2.0)
logger.info(
"VNA acquire start: expected_points=%d timeout=%.2fs standby=%s",
points_target,
@@ -66,6 +71,7 @@ class VNAController:
packet_type=PacketType.VNA_DATAPOINT,
expected_points=points_target,
timeout_s=timeout_s,
progress_timeout_s=max(1.5, 3.0 * point_seconds),
payload_type=VNADatapointPacket,
payload_error="Expected decoded VNADatapointPacket payload",
)
@@ -79,21 +85,3 @@ class VNAController:
)
logger.info("VNA acquire complete: received_points=%d", len(result.x))
return result
def stream(self, callback: Callable[[VNARawPoint], None]) -> StreamHandle:
"""Subscribe callback for every incoming VNA datapoint packet."""
if self._settings is None:
raise ParseError("VNA is not configured. Call vna.configure() before stream().")
settings = self._settings
num_ports = self._session.get_device_info().num_ports
def _on_packet(packet: Packet) -> None:
"""Decode datapoint packet and forward mapped raw point to callback."""
payload = packet.payload
if not isinstance(payload, VNADatapointPacket):
raise ParseError("Expected decoded VNADatapointPacket payload")
callback(datapoint_to_raw_point(payload, settings, num_ports=num_ports))
logger.info("VNA stream subscription started")
return self._session.subscribe(PacketType.VNA_DATAPOINT, _on_packet)
@@ -76,12 +76,3 @@ class SweepScale(str, Enum):
LIN = "lin"
LOG = "log"
class SParameter(str, Enum):
"""Canonical 2-port S-parameters."""
S11 = "S11"
S12 = "S12"
S21 = "S21"
S22 = "S22"
@@ -89,7 +89,6 @@ class VNASweepSettings:
if_bandwidth_hz: float = 1_000.0
power_start_dbm: float = -10.0
power_stop_dbm: float = -10.0
excited_ports: tuple[int, ...] = (1, 2)
sweep_scale: SweepScale = SweepScale.LIN
dwell_s: float = 0.0
suppress_invalid_peaks: bool = True
@@ -106,12 +105,6 @@ class VNASweepSettings:
raise ValueError("if_bandwidth_hz must be > 0")
if self.dwell_s < 0:
raise ValueError("dwell_s must be >= 0")
if not self.excited_ports:
raise ValueError("excited_ports must not be empty")
if len(set(self.excited_ports)) != len(self.excited_ports):
raise ValueError("excited_ports must not contain duplicates")
if any(port <= 0 for port in self.excited_ports):
raise ValueError("excited_ports must use 1-based positive port numbers")
if self.f_stop_hz < self.f_start_hz:
raise ValueError("f_stop_hz must be >= f_start_hz")
if self.kind == SweepKind.POWER and self.power_stop_dbm < self.power_start_dbm:
@@ -217,60 +210,14 @@ class SweepResult:
normalized[key.strip().lower()] = arr
self.traces = normalized
@property
def s11(self) -> np.ndarray | None:
"""Return `S11` trace when available."""
return self.traces.get("s11")
@property
def s21(self) -> np.ndarray | None:
"""Return `S21` trace when available."""
return self.traces.get("s21")
@property
def s12(self) -> np.ndarray | None:
"""Return `S12` trace when available."""
return self.traces.get("s12")
@property
def s22(self) -> np.ndarray | None:
"""Return `S22` trace when available."""
return self.traces.get("s22")
def trace(self, parameter: str) -> np.ndarray:
"""Return complex trace by parameter name."""
key = parameter.strip().lower()
if key not in self.traces:
raise KeyError(f"Trace '{parameter}' is not available")
return self.traces[key]
def real(self, parameter: str) -> np.ndarray:
"""Return real part of selected trace."""
return self.trace(parameter).real
def imag(self, parameter: str) -> np.ndarray:
"""Return imaginary part of selected trace."""
return self.trace(parameter).imag
def to_npz(self, path: str) -> None:
"""Save the axis and all traces to a NumPy `.npz` archive at ``path``."""
data: dict[str, np.ndarray] = {self.x_label: self.x}
data.update(self.traces)
np.savez(path, **data)
logger.debug("Saved SweepResult to NPZ: %s (traces=%d)", path, len(self.traces))
def to_csv(self, path: str) -> None:
"""Save the axis and traces to CSV at ``path``, with `<trace>_real`/`<trace>_imag` columns."""
columns: list[np.ndarray] = [self.x]
headers: list[str] = [self.x_label]
for name, values in sorted(self.traces.items()):
columns.append(values.real)
columns.append(values.imag)
headers.append(f"{name}_real")
headers.append(f"{name}_imag")
matrix = np.column_stack(columns)
np.savetxt(path, matrix, delimiter=",", header=",".join(headers), comments="")
logger.debug("Saved SweepResult to CSV: %s (traces=%d)", path, len(self.traces))
PacketPayload = Any
@@ -239,12 +239,8 @@ def decode_packet_payload(packet: Packet) -> object:
def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
"""Encode `SweepSettings` payload."""
if len(settings.excited_ports) > 4:
raise ValueError("Protocol supports at most four excited ports")
sync_mode = settings.sync_mode if settings.sync_mode is not None else 0
stage_by_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
flags1 = 0
flags1 |= int(bool(settings.standby)) << 0
flags1 |= int(bool(settings.sync_master)) << 1
@@ -253,13 +249,9 @@ def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
flags1 |= int(settings.sweep_scale.value == "log") << 4
flags1 |= (int(sync_mode) & 0x03) << 5
flags2 = 0
stages = len(settings.excited_ports) - 1
flags2 |= stages & 0x07
flags2 |= (stage_by_port.get(1, 0) & 0x07) << 3
flags2 |= (stage_by_port.get(2, 0) & 0x07) << 6
flags2 |= (stage_by_port.get(3, 0) & 0x07) << 9
flags2 |= (stage_by_port.get(4, 0) & 0x07) << 12
# One stage, stimulus on port 1; stage 7 disables port 2.
# port3Stage must stay zero: our firmware uses it for substage width.
flags2 = 7 << 6
dwell_us = int(round(settings.dwell_s * 1_000_000.0))
dwell_us = max(0, min(0xFFFF, dwell_us))
@@ -8,7 +8,7 @@ import threading
import time
from typing import Callable, Protocol, TypeVar
from .enums import HardwareFamily, PacketType, SyncMode
from .enums import HardwareFamily, PacketType
from .exceptions import (
DeviceDisconnectedError,
NackError,
@@ -69,9 +69,6 @@ class LibreVNASession:
self._device_status: DeviceStatus | None = None
self._hardware_family: HardwareFamily = HardwareFamily.UNKNOWN
self._default_sync_mode = SyncMode.DISABLED
self._default_sync_master = False
@property
def is_connected(self) -> bool:
"""Return `True` when USB connection is active."""
@@ -87,21 +84,6 @@ class LibreVNASession:
"""Hardware family inferred from `DeviceInfo` packet."""
return self._hardware_family
@property
def default_sync_mode(self) -> SyncMode:
"""Default sync mode used by controllers when settings do not override."""
return self._default_sync_mode
@property
def default_sync_master(self) -> bool:
"""Default sync master flag used by controllers."""
return self._default_sync_master
def set_default_sync(self, mode: SyncMode, master: bool) -> None:
"""Set session-level default synchronization settings."""
self._default_sync_mode = mode
self._default_sync_master = master
@staticmethod
def list_devices() -> list[USBDeviceDescriptor]:
"""Enumerate available direct-USB LibreVNA devices."""
@@ -241,6 +223,7 @@ class LibreVNASession:
timeout_s: float,
payload_type: type[TPacketPayload],
payload_error: str,
progress_timeout_s: float | None = None,
) -> list[TPacketPayload]:
"""Collect one sweep from point zero, returning points in ascending order.
@@ -251,11 +234,14 @@ class LibreVNASession:
raise ValueError("expected_points must be > 0")
deadline = time.monotonic() + timeout_s
progress_timeout_s = timeout_s if progress_timeout_s is None else progress_timeout_s
progress_deadline = time.monotonic() + progress_timeout_s
previous_point: int | None = None
collected: list[TPacketPayload | None] = [None] * expected_points
received = 0
while received < expected_points:
remaining = deadline - time.monotonic()
remaining = min(deadline, progress_deadline) - time.monotonic()
if remaining <= 0:
break
@@ -270,6 +256,10 @@ class LibreVNASession:
f"Received out-of-range point index {point_number}, expected 0..{expected_points - 1}"
)
if point_number != previous_point:
progress_deadline = time.monotonic() + progress_timeout_s
previous_point = point_number
if point_number == 0:
collected = [None] * expected_points
received = 0
@@ -1,10 +0,0 @@
"""Sweep assembly and result helpers."""
from .assembler import assemble_vna_sweep, datapoint_to_raw_point
from ..models import SweepResult
__all__ = [
"SweepResult",
"assemble_vna_sweep",
"datapoint_to_raw_point",
]
@@ -32,12 +32,12 @@ def _find_vna_value(
) -> complex:
"""Find one complex receiver value for stage/port/ref tuple."""
source_mask = 1 << port_index
if reference:
source_mask |= _REFERENCE_FLAG
for idx, flags in enumerate(datapoint.flags):
if _extract_stage(int(flags)) != stage:
continue
if bool(int(flags) & _REFERENCE_FLAG) != reference:
continue
if (int(flags) & source_mask) != source_mask:
continue
return complex(float(datapoint.real[idx]), float(datapoint.imag[idx]))
@@ -48,6 +48,14 @@ def _find_vna_value(
)
def _s21(datapoint: VNADatapointPacket) -> complex:
reference = _find_vna_value(datapoint, stage=0, port_index=0, reference=True)
receiver = _find_vna_value(datapoint, stage=0, port_index=1, reference=False)
if not np.isfinite(reference) or abs(reference) <= 1e-12 or not np.isfinite(receiver):
raise ParseError(f"Invalid receiver/reference at point {datapoint.point_number}")
return receiver / reference
def datapoint_to_raw_point(
datapoint: VNADatapointPacket,
settings: VNASweepSettings,
@@ -55,19 +63,9 @@ def datapoint_to_raw_point(
num_ports: int,
) -> VNARawPoint:
"""Convert low-level VNADatapoint packet into callback-friendly object."""
measurements: dict[str, complex] = {}
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
for excited_port, stage in stage_by_excited_port.items():
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
for receiver_port in range(1, num_ports + 1):
measured = _find_vna_value(
datapoint,
stage=stage,
port_index=receiver_port - 1,
reference=False,
)
measurements[f"S{receiver_port}{excited_port}"] = measured / ref
if num_ports < 2:
raise ParseError("S21 acquisition requires two receiver ports")
measurements = {"S21": _s21(datapoint)}
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
settings.power_start_dbm,
@@ -108,17 +106,9 @@ def assemble_vna_sweep(
x = np.empty(expected_points, dtype=np.float64)
seen = np.zeros(expected_points, dtype=bool)
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
receiver_limit = min(num_ports, 2)
trace_names = [
f"s{receiver_port}{excited_port}"
for excited_port in settings.excited_ports
for receiver_port in range(1, receiver_limit + 1)
if receiver_port <= 2 and excited_port <= 2
]
traces: dict[str, np.ndarray] = {
name: np.empty(expected_points, dtype=np.complex128) for name in trace_names
}
if num_ports < 2:
raise ParseError("S21 acquisition requires two receiver ports")
s21 = np.empty(expected_points, dtype=np.complex128)
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
settings.power_start_dbm,
@@ -130,7 +120,7 @@ def assemble_vna_sweep(
for datapoint in datapoints:
idx = datapoint.point_number
if idx >= expected_points:
if not 0 <= idx < expected_points:
raise ParseError(f"Received out-of-range point index {idx}, expected < {expected_points}")
if zero_span:
@@ -140,22 +130,7 @@ def assemble_vna_sweep(
else:
x[idx] = float(datapoint.frequency_or_time)
for excited_port, stage in stage_by_excited_port.items():
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
for receiver_port in range(1, num_ports + 1):
if receiver_port > 2 or excited_port > 2:
# Public SweepResult intentionally exposes 2-port canonical traces.
continue
measured = _find_vna_value(
datapoint,
stage=stage,
port_index=receiver_port - 1,
reference=False,
)
trace_name = f"s{receiver_port}{excited_port}"
trace = traces.get(trace_name)
if trace is not None:
trace[idx] = measured / ref
s21[idx] = _s21(datapoint)
seen[idx] = True
@@ -171,7 +146,7 @@ def assemble_vna_sweep(
elif power_sweep:
x_label = "power_dbm"
result = SweepResult(x=x, traces=traces, x_label=x_label)
result = SweepResult(x=x, traces={"s21": s21}, x_label=x_label)
logger.debug(
"Assembled VNA sweep: points=%d x_label=%s traces=%s",
len(result.x),
@@ -6,7 +6,7 @@ from contextlib import suppress
import logging
import threading
import time
from typing import Callable
from typing import Callable, cast
from ..exceptions import DeviceDisconnectedError, TimeoutError
from ..models import USBDeviceDescriptor
@@ -40,8 +40,9 @@ class USBTransport:
self._on_disconnect = on_disconnect
self._read_chunk_size = read_chunk_size
self._ctx: usb1.USBContext | None = None # type: ignore[valid-type]
self._handle: usb1.USBDeviceHandle | None = None # type: ignore[valid-type]
self._ctx: usb1.USBContext | None = None
# libusb1 binds close() to a zero-argument finalizer on each handle.
self._handle: usb1.USBDeviceHandle | None = None
self._rx_thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._tx_lock = threading.Lock()
@@ -78,7 +79,7 @@ class USBTransport:
finally:
if handle is not None:
with suppress(usb1.USBError):
handle.close()
cast(Callable[[], None], handle.close)()
devices.append(USBDeviceDescriptor(serial=serial, vendor_id=vid, product_id=pid))
@@ -114,7 +115,7 @@ class USBTransport:
finally:
if handle is not None and handle is not selected_handle:
with suppress(usb1.USBError):
handle.close()
cast(Callable[[], None], handle.close)()
if selected_handle is None:
serial_msg = f" with serial '{serial}'" if serial else ""
@@ -149,7 +150,7 @@ class USBTransport:
self.disconnect()
raise
def _claim_interface_with_busy_recovery(self, handle: "usb1.USBDeviceHandle") -> None: # type: ignore[name-defined]
def _claim_interface_with_busy_recovery(self, handle: "usb1.USBDeviceHandle") -> None:
"""Claim the data interface, clearing a stale BUSY claim once if needed.
When a previous session's RX thread wedged inside libusb, ``disconnect()``
@@ -163,7 +164,9 @@ class USBTransport:
try:
handle.claimInterface(self.INTERFACE)
return
except usb1.USBErrorBusy as exc:
except usb1.USBError as exc:
if exc.value != -6: # LIBUSB_ERROR_BUSY
raise
logger.warning(
"USB interface %d busy on claim; resetting device to clear a stale claim: %s",
self.INTERFACE,
@@ -202,7 +205,7 @@ class USBTransport:
with suppress(usb1.USBError):
self._handle.releaseInterface(self.INTERFACE)
with suppress(usb1.USBError):
self._handle.close()
cast(Callable[[], None], self._handle.close)()
self._handle = None
if self._ctx is not None:
@@ -223,11 +226,9 @@ class USBTransport:
with self._tx_lock:
try:
written = handle.bulkWrite(self.DATA_EP_OUT, data, timeout=timeout_ms)
except usb1.USBErrorTimeout as exc:
raise TimeoutError("Timed out writing USB bulk packet") from exc
except usb1.USBErrorNoDevice as exc:
raise DeviceDisconnectedError("USB device disconnected during write") from exc
except usb1.USBError as exc:
if exc.value == -7: # LIBUSB_ERROR_TIMEOUT
raise TimeoutError("Timed out writing USB bulk packet") from exc
raise DeviceDisconnectedError(f"USB write failed: {exc}") from exc
if written != len(data):
@@ -248,16 +249,9 @@ class USBTransport:
while not self._stop_event.is_set():
try:
data = handle.bulkRead(self.DATA_EP_IN, self._read_chunk_size, timeout=timeout_ms)
except usb1.USBErrorTimeout:
continue
except usb1.USBErrorInterrupted:
continue
except usb1.USBErrorNoDevice as exc:
if self._on_disconnect is not None:
self._on_disconnect(DeviceDisconnectedError(f"USB device disconnected: {exc}"))
logger.warning("USB RX stopped: device disconnected: %s", exc)
return
except usb1.USBError as exc:
if exc.value in (-7, -10): # LIBUSB_ERROR_TIMEOUT / INTERRUPTED
continue
if self._stop_event.is_set():
return
if self._on_disconnect is not None:
@@ -294,7 +288,7 @@ class USBTransport:
@classmethod
def _iter_matching_devices(
cls,
ctx: "usb1.USBContext", # type: ignore[name-defined]
ctx: "usb1.USBContext",
):
"""Yield USB devices matching supported LibreVNA VID/PID pairs."""
for device in ctx.getDeviceList(skip_on_error=True):