256 lines
7.9 KiB
Python
256 lines
7.9 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 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 result as NumPy `.npz` archive."""
|
|
data: dict[str, np.ndarray] = {self.x_label: self.x}
|
|
data.update(self.traces)
|
|
np.savez(path, **data)
|
|
|
|
def to_csv(self, path: str) -> None:
|
|
"""Save result as CSV 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="")
|
|
|
|
|
|
PacketPayload = Any
|