224 lines
6.6 KiB
Python
224 lines
6.6 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
|
|
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 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"
|
|
capture_start_ns: int = 0
|
|
capture_end_ns: int = 0
|
|
|
|
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 s21(self) -> np.ndarray | None:
|
|
"""Return `S21` trace when available."""
|
|
return self.traces.get("s21")
|
|
|
|
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]
|