added remote k209 setup

This commit is contained in:
Ayzen
2026-04-29 16:32:39 +03:00
parent e05c06bcbe
commit 5a70235ef3
30 changed files with 2373 additions and 95 deletions
@@ -110,16 +110,29 @@ class CompactMK209Service:
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?")),
}
opened_here = self._instrument is None
try:
if opened_here:
self.open()
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?")),
}
finally:
if opened_here:
self.close()
def frequency_axis(self) -> np.ndarray:
"""Return the configured frequency axis."""
if self._frequency_hz is None:
raise RuntimeError("K209 frequency axis is not configured")
return self._frequency_hz
def acquire_interleaved(self) -> CompactMK209InterleavedSweep:
"""Acquire one corrected sweep without converting interleaved arrays."""
@@ -0,0 +1,90 @@
"""Binary TCP protocol shared by the K209 remote server and Python client."""
from __future__ import annotations
import socket
import struct
from typing import BinaryIO
import numpy as np
COMMAND_IDENTITY = b"I"
COMMAND_LIMITS = b"L"
COMMAND_CONFIGURE = b"C"
COMMAND_ACQUIRE = b"A"
STATUS_OK = b"O"
STATUS_ERROR = b"E"
DEFAULT_REMOTE_HOST = "127.0.0.1"
DEFAULT_REMOTE_PORT = 50209
CONFIG_STRUCT = struct.Struct("!ddIdd")
LIMITS_STRUCT = struct.Struct("!ddddIdd")
U32_STRUCT = struct.Struct("!I")
def recv_exact(stream: socket.socket | BinaryIO, size: int) -> bytes:
"""Read exactly `size` bytes from a socket-like object."""
chunks: list[bytes] = []
remaining = int(size)
while remaining > 0:
chunk = stream.recv(remaining) if isinstance(stream, socket.socket) else stream.read(remaining)
if not chunk:
raise ConnectionError(f"K209 remote connection closed with {remaining} bytes pending")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def send_all(stream: socket.socket | BinaryIO, payload: bytes) -> None:
"""Write all payload bytes to a socket-like object."""
if isinstance(stream, socket.socket):
stream.sendall(payload)
return
stream.write(payload)
def send_u32(stream: socket.socket | BinaryIO, value: int) -> None:
"""Send one network-order uint32."""
send_all(stream, U32_STRUCT.pack(int(value)))
def recv_u32(stream: socket.socket | BinaryIO) -> int:
"""Read one network-order uint32."""
return int(U32_STRUCT.unpack(recv_exact(stream, U32_STRUCT.size))[0])
def send_error(stream: socket.socket | BinaryIO, message: str) -> None:
"""Send protocol error response."""
payload = str(message).encode("utf-8", errors="replace")
send_all(stream, STATUS_ERROR)
send_u32(stream, len(payload))
send_all(stream, payload)
def read_status(stream: socket.socket | BinaryIO) -> None:
"""Read response status and raise remote error when needed."""
status = recv_exact(stream, 1)
if status == STATUS_OK:
return
if status == STATUS_ERROR:
message = recv_exact(stream, recv_u32(stream)).decode("utf-8", errors="replace")
raise RuntimeError(f"K209 remote server error: {message}")
raise RuntimeError(f"K209 remote server returned invalid status byte: {status!r}")
def send_float32_array(stream: socket.socket | BinaryIO, values: np.ndarray) -> None:
"""Send a float32 array as a length-prefixed little-endian payload."""
payload = np.asarray(values, dtype="<f4").tobytes(order="C")
send_u32(stream, len(payload))
send_all(stream, payload)
def recv_float32_array(stream: socket.socket | BinaryIO, expected_values: int) -> np.ndarray:
"""Read a length-prefixed little-endian float32 array."""
payload_size = recv_u32(stream)
expected_size = int(expected_values) * np.dtype(np.float32).itemsize
if payload_size != expected_size:
raise RuntimeError(f"K209 remote payload has {payload_size} bytes, expected {expected_size}")
return np.frombuffer(recv_exact(stream, payload_size), dtype="<f4").copy()
@@ -0,0 +1,172 @@
"""TCP client for a Compact-M K209 connected to a remote S2VNA host."""
from __future__ import annotations
from dataclasses import dataclass, field
import socket
import numpy as np
from python_app.hardware_full.k209_remote_protocol import (
COMMAND_ACQUIRE,
COMMAND_CONFIGURE,
COMMAND_IDENTITY,
COMMAND_LIMITS,
CONFIG_STRUCT,
DEFAULT_REMOTE_HOST,
DEFAULT_REMOTE_PORT,
LIMITS_STRUCT,
read_status,
recv_exact,
recv_float32_array,
recv_u32,
)
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
@dataclass(slots=True)
class RemoteCompactMK209Service:
"""Acquire K209 sweeps through a persistent TCP connection."""
host: str = DEFAULT_REMOTE_HOST
port: int = DEFAULT_REMOTE_PORT
timeout_s: float = 20.0
_socket: socket.socket | 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:
self.host = str(self.host).strip()
if not self.host:
raise ValueError("K209 remote host must not be empty")
self.port = int(self.port)
if self.port <= 0 or self.port > 65535:
raise ValueError("K209 remote port must be in 1..65535")
self.timeout_s = float(self.timeout_s)
if self.timeout_s <= 0.0:
raise ValueError("K209 remote timeout_s must be > 0")
def open(self) -> None:
"""Open TCP connection and apply stored settings when present."""
if self._socket is not None:
return
sock = socket.create_connection((self.host, self.port), timeout=self.timeout_s)
sock.settimeout(self.timeout_s)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self._socket = sock
try:
if self._settings is not None:
self._apply_configuration(self._settings)
except Exception:
self.close()
raise
def close(self) -> None:
"""Close TCP connection."""
if self._socket is None:
return
try:
self._socket.close()
finally:
self._socket = None
def query_identity(self) -> str:
"""Read analyzer identity string through the remote server."""
sock = self._require_socket()
sock.sendall(COMMAND_IDENTITY)
read_status(sock)
return recv_exact(sock, recv_u32(sock)).decode("utf-8", errors="replace").strip()
def read_device_limits(self) -> dict[str, float | int]:
"""Read analyzer limits through the remote server."""
opened_here = self._socket is None
try:
if opened_here:
self.open()
sock = self._require_socket()
sock.sendall(COMMAND_LIMITS)
read_status(sock)
min_freq, max_freq, min_ifbw, max_ifbw, max_points, min_power, max_power = LIMITS_STRUCT.unpack(
recv_exact(sock, LIMITS_STRUCT.size)
)
return {
"min_frequency_hz": float(min_freq),
"max_frequency_hz": float(max_freq),
"min_ifbw_hz": float(min_ifbw),
"max_ifbw_hz": float(max_ifbw),
"max_points": int(max_points),
"min_power_dbm": float(min_power),
"max_power_dbm": float(max_power),
}
finally:
if opened_here:
self.close()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings."""
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
if self._socket is not None:
self._apply_configuration(sweep)
def acquire(self) -> SweepResult:
"""Acquire one corrected S11/S21 sweep."""
if self._settings is None or self._frequency_hz is None:
raise RuntimeError("K209 remote service is not configured")
sock = self._require_socket()
points = int(self._settings.points)
sock.sendall(COMMAND_ACQUIRE)
read_status(sock)
returned_points = recv_u32(sock)
if returned_points != points:
raise RuntimeError(f"K209 remote sweep returned {returned_points} points, expected {points}")
s11_values = recv_float32_array(sock, points * 2)
s21_values = recv_float32_array(sock, points * 2)
return SweepResult(
x=self._frequency_hz.copy(),
traces={
"s11": self._complex_from_interleaved(s11_values),
"s21": self._complex_from_interleaved(s21_values),
},
)
def _apply_configuration(self, sweep: RadarSweepModel) -> None:
sock = self._require_socket()
sock.sendall(COMMAND_CONFIGURE)
sock.sendall(
CONFIG_STRUCT.pack(
float(sweep.start_hz),
float(sweep.stop_hz),
int(sweep.points),
float(sweep.if_bandwidth_hz),
float(sweep.power_dbm),
)
)
read_status(sock)
returned_points = recv_u32(sock)
if returned_points != int(sweep.points):
raise RuntimeError(f"K209 remote config returned {returned_points} points, expected {sweep.points}")
self._frequency_hz = recv_float32_array(sock, int(sweep.points))
def _require_socket(self) -> socket.socket:
if self._socket is None:
raise RuntimeError("K209 remote socket is not open")
return self._socket
@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 remote 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)
@@ -0,0 +1,49 @@
"""Factory for single-radar Python acquisition services."""
from __future__ import annotations
from typing import Protocol
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
class SingleRadarService(Protocol):
"""Common API used by single-radar workflows."""
def open(self) -> None:
"""Open radar connection."""
def close(self) -> None:
"""Close radar connection."""
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings."""
def read_device_limits(self) -> dict[str, float | int]:
"""Read device capability limits."""
def acquire(self) -> SweepResult:
"""Acquire one sweep."""
def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
"""Create the Python service for a non-multi-device radar config."""
if config.is_multi_device:
raise RuntimeError("single-radar service factory does not support librevna_multi")
model = config.radar.model or RunConfigModel.LIBREVNA_MODEL
if model == RunConfigModel.LIBREVNA_MODEL:
return LibreVnaService(serial=config.radar.serial or None)
if model == RunConfigModel.COMPACT_M_K209_MODEL:
if config.radar.driver_mode != "native":
raise RuntimeError("Compact-M K209 requires radar.driver_mode='native'")
return RemoteCompactMK209Service(
host=config.radar.remote_host,
port=config.radar.remote_port,
)
raise RuntimeError(f"Unsupported single-radar model: {model}")