"""VISA HiSLIP driver for Compact-M K209 / S2VNA analyzers.""" from __future__ import annotations from dataclasses import dataclass, field from typing import Any import numpy as np import pyvisa from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.run_config_model import RadarSweepModel @dataclass(slots=True) class CompactMK209InterleavedSweep: """Raw corrected K209 traces as interleaved REAL32 arrays.""" frequency_hz: np.ndarray s11_values: np.ndarray s21_values: np.ndarray @dataclass(slots=True) class CompactMK209Service: """Acquire corrected S11/S21 sweeps from a K209 analyzer through VISA HiSLIP.""" resource: str timeout_ms: int = 20_000 preset_on_open: bool = True visa_library: str = "@ivi" _resource_manager: pyvisa.ResourceManager | None = field(init=False, default=None, repr=False) _instrument: Any | None = field(init=False, default=None, repr=False) _settings: RadarSweepModel | None = field(init=False, default=None, repr=False) _frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False) def __post_init__(self) -> None: """Normalize constructor values.""" self.resource = str(self.resource).strip() if not self.resource: raise ValueError("K209 VISA resource must not be empty") self.timeout_ms = int(self.timeout_ms) if self.timeout_ms <= 0: raise ValueError("K209 timeout_ms must be > 0") self.visa_library = str(self.visa_library).strip() or "@ivi" if self.visa_library == "@py" or self.visa_library.endswith("@py"): raise ValueError("K209 requires an IVI/Vendor VISA backend, not pyvisa-py") @property def is_open(self) -> bool: """Return whether the VISA session is open.""" return self._instrument is not None def open(self) -> None: """Open VISA resource and apply stored sweep settings when available.""" if self._instrument is not None: return try: self._resource_manager = pyvisa.ResourceManager(self.visa_library) self._instrument = self._resource_manager.open_resource(self.resource) self._instrument.timeout = self.timeout_ms self._instrument.write_termination = "\n" self._instrument.read_termination = None self._instrument.chunk_size = max(int(getattr(self._instrument, "chunk_size", 20_480)), 8 * 1024 * 1024) self._instrument.write("*CLS") if self._settings is not None: self._apply_configuration(self._settings) except Exception: self.close() raise def close(self) -> None: """Close VISA sessions.""" if self._instrument is not None: self._instrument.close() self._instrument = None if self._resource_manager is not None: self._resource_manager.close() self._resource_manager = None def __enter__(self) -> CompactMK209Service: """Open and return this service.""" self.open() return self def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: """Close VISA resources.""" self.close() def query_identity(self) -> str: """Read analyzer identity string.""" instrument = self._require_instrument() return str(instrument.query("*IDN?")).strip() def query_system_error(self) -> str: """Read one analyzer SCPI error queue entry.""" instrument = self._require_instrument() return str(instrument.query("SYST:ERR?")).strip() def configure(self, sweep: RadarSweepModel) -> None: """Store and apply sweep settings.""" self._validate_sweep(sweep) self._settings = sweep self._frequency_hz = None if self._instrument is None: return self._apply_configuration(sweep) def read_device_limits(self) -> dict[str, float | int]: """Read analyzer limits through SCPI capability/service queries.""" instrument = self._require_instrument() return { "min_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MIN?")), "max_frequency_hz": float(instrument.query("SERV:SWE:FREQ:MAX?")), "min_ifbw_hz": float(instrument.query("SYST:CAP:IFBW:MIN?")), "max_ifbw_hz": float(instrument.query("SYST:CAP:IFBW:MAX?")), "max_points": int(float(instrument.query("SERV:SWE:POIN?"))), "min_power_dbm": float(instrument.query("SERV:SWE:POW:MIN?")), "max_power_dbm": float(instrument.query("SERV:SWE:POW:MAX?")), } def acquire_interleaved(self) -> CompactMK209InterleavedSweep: """Acquire one corrected sweep without converting interleaved arrays.""" if self._settings is None: raise RuntimeError("K209 service is not configured") if self._frequency_hz is None: raise RuntimeError("K209 frequency axis is not configured") points = int(self._settings.points) s11_values, s21_values = self._query_sweep_trace_pair(points) return CompactMK209InterleavedSweep( frequency_hz=self._frequency_hz, s11_values=s11_values, s21_values=s21_values, ) def acquire(self) -> SweepResult: """Acquire one corrected S11/S21 sweep.""" raw = self.acquire_interleaved() return SweepResult( x=raw.frequency_hz.copy(), traces={ "s11": self._complex_from_interleaved(raw.s11_values), "s21": self._complex_from_interleaved(raw.s21_values), }, ) def _apply_configuration(self, sweep: RadarSweepModel) -> None: instrument = self._require_instrument() if self.preset_on_open: instrument.write("SYST:PRES") self._expect_opc("*OPC?", context="K209 preset") instrument.write(f"SENS:FREQ:STAR {float(sweep.start_hz):.9f}") instrument.write(f"SENS:FREQ:STOP {float(sweep.stop_hz):.9f}") instrument.write(f"SENS:SWE:POIN {int(sweep.points)}") instrument.write("SENS:SWE:POIN:TIME 0") instrument.write(f"SENS:BAND {float(sweep.if_bandwidth_hz):.9f}") instrument.write(f"SOUR:POW {float(sweep.power_dbm):.3f}") instrument.write("SENS:AVER OFF") instrument.write("CALC:PAR1:DEF S21") instrument.write("CALC:PAR1:SEL") instrument.write("FORM:DATA REAL32") instrument.write("FORM:BORD SWAP") instrument.write("INIT:CONT ON") instrument.write("TRIG:SOUR BUS") self._expect_opc("*OPC?", context="K209 setup") self._frequency_hz = self._query_float32_array("SENS:FREQ:DATA?", int(sweep.points)) def _expect_opc(self, command: str, *, context: str) -> None: instrument = self._require_instrument() response = str(instrument.query(command)).strip() if response != "1": raise RuntimeError(f"{context} returned unexpected *OPC? response: {response!r}") def _query_float32_array(self, command: str, expected_values: int) -> np.ndarray: instrument = self._require_instrument() instrument.write(command) return self._read_float32_block(command, expected_values) def _query_sweep_trace_pair(self, points: int) -> tuple[np.ndarray, np.ndarray]: instrument = self._require_instrument() instrument.write("TRIG:SING;*OPC?;:SENS:DATA:CORR? S11;:SENS:DATA:CORR? S21") response = self._read_ascii_token() if response != "1": raise RuntimeError(f"K209 sweep returned unexpected *OPC? response: {response!r}") return ( self._read_float32_block("SENS:DATA:CORR? S11", points * 2), self._read_float32_block("SENS:DATA:CORR? S21", points * 2), ) def _read_ascii_token(self) -> str: token = bytearray() while True: byte = self._read_response_bytes(1) if byte in (b";", b"\n", b"\r"): if token: return token.decode("ascii").strip() continue token.extend(byte) if len(token) > 64 * 1024: raise RuntimeError("K209 ASCII response token is too long") def _read_float32_block(self, context: str, expected_values: int) -> np.ndarray: marker = self._read_response_bytes(1) while marker in (b";", b"\n", b"\r"): marker = self._read_response_bytes(1) if marker != b"#": raise RuntimeError(f"K209 response for {context!r} does not start with IEEE block marker") width = self._read_response_bytes(1) if width != b"8": raise RuntimeError(f"K209 response for {context!r} uses unsupported IEEE block header width") payload_size = int(self._read_response_bytes(8).decode("ascii")) expected_size = expected_values * np.dtype(np.float32).itemsize if payload_size != expected_size: raise RuntimeError( f"K209 response for {context!r} returned {payload_size} payload bytes, " f"expected {expected_size}" ) payload = self._read_response_bytes(payload_size) array = np.frombuffer(payload, dtype=" bytes: instrument = self._require_instrument() data = instrument.read_bytes(count, break_on_termchar=False) if len(data) != count: raise RuntimeError(f"K209 response ended after {len(data)} bytes, expected {count}") return data def _require_instrument(self) -> Any: if self._instrument is None: raise RuntimeError("K209 VISA instrument is not open") return self._instrument @staticmethod def _validate_sweep(sweep: RadarSweepModel) -> None: if int(sweep.points) < 2: raise ValueError("K209 sweep points must be >= 2") if float(sweep.stop_hz) < float(sweep.start_hz): raise ValueError("K209 sweep stop_hz must be >= start_hz") if float(sweep.if_bandwidth_hz) <= 0.0: raise ValueError("K209 IF bandwidth must be > 0") @staticmethod def _complex_from_interleaved(values: np.ndarray) -> np.ndarray: if values.size % 2 != 0: raise RuntimeError("K209 complex trace payload has odd scalar count") reshaped = np.asarray(values, dtype=np.float32).reshape((-1, 2)) return (reshaped[:, 0] + 1j * reshaped[:, 1]).astype(np.complex64)