Files
radar_system/python_app/hardware_full/librevna_driver/models.py
T
2026-06-06 00:52:52 +03:00

275 lines
8.7 KiB
Python

"""Public datamodels and low-level packet payload representations."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
from threading import Event, Thread
from typing import Any, Callable
import numpy as np
from .enums import HardwareFamily, PacketType, SweepKind, SweepScale, SyncMode
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class Packet:
"""Generic protocol packet container."""
type: PacketType
payload: Any = b""
@dataclass(slots=True)
class USBDeviceDescriptor:
"""USB device descriptor exposed by transport discovery."""
serial: str
vendor_id: int
product_id: int
@dataclass(slots=True)
class DeviceLimits:
"""Hardware capability limits reported by the instrument."""
min_frequency_hz: float
max_frequency_hz: float
max_frequency_harmonic_hz: float
min_ifbw_hz: float
max_ifbw_hz: float
max_points: int
min_power_dbm: float
max_power_dbm: float
min_rbw_hz: float
max_rbw_hz: float
max_amplitude_points: int
max_dwell_time_s: float
@dataclass(slots=True)
class DeviceInfo:
"""Device identity and capabilities."""
protocol_version: int
firmware_major: int
firmware_minor: int
firmware_patch: int
firmware_version: str
hardware_version: int
hardware_revision: str
hardware_family: HardwareFamily
limits: DeviceLimits
num_ports: int
@dataclass(slots=True)
class DeviceStatus:
"""Current runtime status and telemetry."""
family: HardwareFamily
source_locked: bool | None
lo_locked: bool | None
adc_overload: bool | None
unlevel: bool | None
temperatures_c: list[float] = field(default_factory=list)
raw: dict[str, int | float | bool] = field(default_factory=dict)
@dataclass(slots=True)
class VNASweepSettings:
"""Configuration for one VNA sweep setup packet."""
kind: SweepKind = SweepKind.FREQUENCY
f_start_hz: float = 1_000_000.0
f_stop_hz: float = 6_000_000_000.0
points: int = 501
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
fixed_power_setting: bool = False
standby: bool = True
sync_mode: SyncMode | None = None
sync_master: bool = False
def __post_init__(self) -> None:
"""Validate configuration before transmitting to the device."""
if self.points <= 0:
raise ValueError("points must be > 0")
if self.if_bandwidth_hz <= 0:
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:
raise ValueError("power_stop_dbm must be >= power_start_dbm for power sweep")
if self.kind == SweepKind.POWER and self.f_start_hz != self.f_stop_hz:
raise ValueError("power sweep requires f_start_hz == f_stop_hz")
@dataclass(slots=True)
class GeneratorSettings:
"""Configuration for LibreVNA signal-generator mode."""
frequency_hz: float = 1_000_000.0
power_dbm: float = -10.0
active_port: int = 1
apply_amplitude_correction: bool = False
def __post_init__(self) -> None:
"""Validate generator settings before transmission."""
if self.frequency_hz <= 0:
raise ValueError("frequency_hz must be > 0")
if self.active_port not in {1, 2}:
raise ValueError("active_port must be 1 or 2")
@dataclass(slots=True)
class DeviceConfigVariant:
"""Family-specific device configuration fields."""
family: HardwareFamily
values: dict[str, int | float | bool] = field(default_factory=dict)
@dataclass(slots=True)
class VNADatapointPacket:
"""Decoded low-level VNADatapoint payload."""
frequency_or_time: int
cdbm: int
point_number: int
real: np.ndarray
imag: np.ndarray
flags: np.ndarray
@dataclass(slots=True)
class VNARawPoint:
"""Normalized VNA datapoint used by streaming callback API."""
point_number: int
frequency_hz: float | None
time_s: float | None
power_dbm: float | None
measurements: dict[str, complex] = field(default_factory=dict)
z0: float = 50.0
class StreamHandle:
"""Handle returned from streaming subscriptions."""
def __init__(
self,
stop_event: Event,
close_callback: Callable[[], None],
reader_thread: Thread | None = None,
) -> None:
"""Create stream handle with stop event and unsubscribe callback."""
self._stop_event = stop_event
self._close_callback = close_callback
self._reader_thread = reader_thread
def close(self, *, join_timeout_s: float | None = 1.0) -> None:
"""Stop streaming and release internal resources."""
logger.debug("Closing stream handle (join_timeout_s=%s)", join_timeout_s)
if not self._stop_event.is_set():
self._stop_event.set()
self._close_callback()
if self._reader_thread is not None and self._reader_thread.is_alive():
self._reader_thread.join(timeout=join_timeout_s)
logger.debug("Stream handle closed")
@dataclass(slots=True)
class SweepResult:
"""Container for complex VNA traces sharing one X-axis."""
x: np.ndarray
traces: dict[str, np.ndarray] = field(default_factory=dict)
x_label: str = "frequency_hz"
def __post_init__(self) -> None:
"""Normalize dtypes and validate shape compatibility."""
self.x = np.asarray(self.x, dtype=np.float64)
normalized: dict[str, np.ndarray] = {}
for key, values in self.traces.items():
arr = np.asarray(values, dtype=np.complex128)
if arr.shape != self.x.shape:
raise ValueError(
f"Trace '{key}' shape {arr.shape} does not match axis shape {self.x.shape}"
)
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