init commit
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user