added remote k209 setup
This commit is contained in:
@@ -3,10 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
|
||||
|
||||
class AppWindowRadarLimitsMixin:
|
||||
"""Handle LibreVNA capability probing and dependent UI clamping."""
|
||||
"""Handle radar capability probing and dependent UI clamping."""
|
||||
|
||||
def _on_radar_sweep_limits_changed(self) -> None:
|
||||
"""Clamp processing frequency bounds after sweep start/stop edits."""
|
||||
@@ -15,27 +16,19 @@ class AppWindowRadarLimitsMixin:
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _refresh_radar_limits_from_device(self) -> bool:
|
||||
"""Query native LibreVNA limits and apply them to GUI fields."""
|
||||
serial = self._defaults_config.radar.serial
|
||||
radar_service = LibreVnaService(serial=serial or None)
|
||||
if not radar_service.driver_available:
|
||||
self._fallback_to_mock_mode("LibreVNA Python driver is not available for device limits query")
|
||||
return False
|
||||
"""Query native radar limits and apply them to GUI fields."""
|
||||
config = self._defaults_config
|
||||
if config.is_multi_device:
|
||||
radar_service = LibreVnaService(serial=config.radar.serial or None)
|
||||
else:
|
||||
radar_service = create_single_radar_service(config)
|
||||
|
||||
try:
|
||||
limits = radar_service.read_device_limits()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to query LibreVNA limits; using UI fallback", exc, level="WARN")
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
return False
|
||||
if isinstance(radar_service, LibreVnaService) and not radar_service.driver_available:
|
||||
raise RuntimeError("LibreVNA Python driver is not available for device limits query")
|
||||
|
||||
limits = radar_service.read_device_limits()
|
||||
return self._apply_radar_limits_to_ui(limits)
|
||||
|
||||
def _fallback_to_mock_mode(self, reason: str) -> None:
|
||||
"""Handle unavailable native limits without mutating JSON-backed mode."""
|
||||
self._log_warning(reason)
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
|
||||
def _apply_radar_limits_to_ui(self, limits: dict[str, float | int] | None) -> bool:
|
||||
"""Apply optional radar limits and clamp dependent GUI fields."""
|
||||
previous_limits = dict(self._radar_limits) if self._radar_limits is not None else None
|
||||
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.gpr_locator import collection_has_gpr_payloads
|
||||
@@ -181,7 +181,9 @@ class AppWindowPipelineMixin:
|
||||
self._start_run()
|
||||
|
||||
def _prepare_radar_for_native_acquisition(self, config: RunConfigModel) -> None:
|
||||
"""Preconfigure native LibreVNA using current sweep settings."""
|
||||
"""Preconfigure native single-radar hardware using current sweep settings."""
|
||||
if config.radar.model == RunConfigModel.COMPACT_M_K209_MODEL and config.radar.driver_mode != "native":
|
||||
raise RuntimeError("Compact-M K209 requires radar.driver_mode='native'")
|
||||
if config.radar.driver_mode != "native":
|
||||
self._log("Radar pre-configuration skipped (mock mode)")
|
||||
return
|
||||
@@ -189,8 +191,8 @@ class AppWindowPipelineMixin:
|
||||
self._log("Multi-device raw producer will configure all LibreVNA devices")
|
||||
return
|
||||
|
||||
radar_service = LibreVnaService(serial=config.radar.serial or None)
|
||||
if not radar_service.driver_available:
|
||||
radar_service = create_single_radar_service(config)
|
||||
if not getattr(radar_service, "driver_available", True):
|
||||
raise RuntimeError("LibreVNA Python driver is not available for native pre-configuration")
|
||||
|
||||
try:
|
||||
@@ -199,7 +201,7 @@ class AppWindowPipelineMixin:
|
||||
finally:
|
||||
radar_service.close()
|
||||
|
||||
self._log("Radar pre-configured via Python driver")
|
||||
self._log(f"Radar pre-configured via Python driver: model={config.radar.model}")
|
||||
|
||||
def _stop_run(self) -> None:
|
||||
"""Stop acquisition-side processes and close readers as needed."""
|
||||
|
||||
@@ -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}")
|
||||
@@ -57,6 +57,8 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
|
||||
model.radar.model = str(radar_payload.get("model", model.radar.model))
|
||||
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
|
||||
model.radar.remote_host = str(radar_payload.get("remote_host", model.radar.remote_host))
|
||||
model.radar.remote_port = int(radar_payload.get("remote_port", model.radar.remote_port))
|
||||
model.radar.driver_mode = str(radar_payload.get("driver_mode", model.radar.driver_mode))
|
||||
model.radar.mock_signal_hz = float(radar_payload.get("mock_signal_hz", model.radar.mock_signal_hz))
|
||||
|
||||
@@ -239,6 +241,8 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"radar": {
|
||||
"model": model.radar.model,
|
||||
"serial": model.radar.serial,
|
||||
"remote_host": model.radar.remote_host,
|
||||
"remote_port": model.radar.remote_port,
|
||||
"driver_mode": model.radar.driver_mode,
|
||||
"mock_signal_hz": model.radar.mock_signal_hz,
|
||||
"multi_device": {
|
||||
|
||||
@@ -43,6 +43,8 @@ class RadarModel:
|
||||
|
||||
model: str = "librevna"
|
||||
serial: str = ""
|
||||
remote_host: str = "127.0.0.1"
|
||||
remote_port: int = 50209
|
||||
driver_mode: str = "mock"
|
||||
mock_signal_hz: float = 1_000_000.0
|
||||
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
|
||||
|
||||
@@ -22,8 +22,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.models.run_config_model import RadarSweepModel
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
|
||||
|
||||
@@ -71,22 +71,15 @@ def _read_native_summary(config_path: Path) -> str:
|
||||
|
||||
def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None:
|
||||
"""Preconfigure native radar through Python service when requested."""
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
radar = config["radar"]
|
||||
if radar["driver_mode"] != "native":
|
||||
config_payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config = RunConfigModel.from_dict(config_payload)
|
||||
if config.radar.driver_mode != "native":
|
||||
return "Radar pre-configuration skipped (mock mode)."
|
||||
if config.is_multi_device:
|
||||
return "Radar pre-configuration skipped (multi-device producer config)."
|
||||
|
||||
sweep = radar["sweep"]
|
||||
sweep_model = RadarSweepModel(
|
||||
start_hz=float(sweep["start_hz"]),
|
||||
stop_hz=float(sweep["stop_hz"]),
|
||||
points=int(sweep["points"]),
|
||||
if_bandwidth_hz=float(sweep["if_bandwidth_hz"]),
|
||||
power_dbm=float(sweep.get("stimulus_power_dbm", -10.0)),
|
||||
)
|
||||
|
||||
radar_service = LibreVnaService(serial=radar.get("serial") or None)
|
||||
if not radar_service.driver_available:
|
||||
radar_service = create_single_radar_service(config)
|
||||
if not getattr(radar_service, "driver_available", True):
|
||||
message = "LibreVNA Python driver is unavailable: skipping pre-configuration"
|
||||
if strict:
|
||||
raise RuntimeError(message)
|
||||
@@ -94,7 +87,7 @@ def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None:
|
||||
|
||||
try:
|
||||
radar_service.open()
|
||||
radar_service.configure(sweep_model)
|
||||
radar_service.configure(config.radar.sweep)
|
||||
return "Radar pre-configuration completed."
|
||||
except Exception as exc:
|
||||
message = f"Radar pre-configuration failed ({exc})"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Run a TCP acquisition server for a locally connected Compact-M K209."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from python_app.hardware_full.compact_m_k209_service import CompactMK209Service
|
||||
from python_app.hardware_full.k209_remote_protocol import (
|
||||
COMMAND_ACQUIRE,
|
||||
COMMAND_CONFIGURE,
|
||||
COMMAND_IDENTITY,
|
||||
COMMAND_LIMITS,
|
||||
CONFIG_STRUCT,
|
||||
DEFAULT_REMOTE_PORT,
|
||||
LIMITS_STRUCT,
|
||||
STATUS_OK,
|
||||
recv_exact,
|
||||
send_error,
|
||||
send_float32_array,
|
||||
send_u32,
|
||||
)
|
||||
from python_app.models.run_config_model import RadarSweepModel
|
||||
|
||||
DEFAULT_RESOURCE = "TCPIP0::127.0.0.1::hislip0,4880::INSTR"
|
||||
|
||||
|
||||
class K209RemoteRequestHandler(socketserver.StreamRequestHandler):
|
||||
"""Handle one persistent K209 remote client connection."""
|
||||
|
||||
def setup(self) -> None:
|
||||
super().setup()
|
||||
self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
self.service = CompactMK209Service(
|
||||
resource=self.server.resource,
|
||||
timeout_ms=self.server.timeout_ms,
|
||||
preset_on_open=False,
|
||||
visa_library="@ivi",
|
||||
)
|
||||
self.service.open()
|
||||
|
||||
def finish(self) -> None:
|
||||
try:
|
||||
self.service.close()
|
||||
finally:
|
||||
super().finish()
|
||||
|
||||
def handle(self) -> None:
|
||||
while True:
|
||||
command = self.rfile.read(1)
|
||||
if not command:
|
||||
return
|
||||
try:
|
||||
if command == COMMAND_IDENTITY:
|
||||
self._handle_identity()
|
||||
elif command == COMMAND_LIMITS:
|
||||
self._handle_limits()
|
||||
elif command == COMMAND_CONFIGURE:
|
||||
self._handle_configure()
|
||||
elif command == COMMAND_ACQUIRE:
|
||||
self._handle_acquire()
|
||||
else:
|
||||
raise RuntimeError(f"unsupported command byte {command!r}")
|
||||
self.wfile.flush()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
send_error(self.wfile, str(exc))
|
||||
self.wfile.flush()
|
||||
|
||||
def _handle_identity(self) -> None:
|
||||
payload = self.service.query_identity().encode("utf-8")
|
||||
self.wfile.write(STATUS_OK)
|
||||
send_u32(self.wfile, len(payload))
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _handle_limits(self) -> None:
|
||||
limits = self.service.read_device_limits()
|
||||
self.wfile.write(STATUS_OK)
|
||||
self.wfile.write(
|
||||
LIMITS_STRUCT.pack(
|
||||
float(limits["min_frequency_hz"]),
|
||||
float(limits["max_frequency_hz"]),
|
||||
float(limits["min_ifbw_hz"]),
|
||||
float(limits["max_ifbw_hz"]),
|
||||
int(limits["max_points"]),
|
||||
float(limits["min_power_dbm"]),
|
||||
float(limits["max_power_dbm"]),
|
||||
)
|
||||
)
|
||||
|
||||
def _handle_configure(self) -> None:
|
||||
start_hz, stop_hz, points, ifbw_hz, power_dbm = CONFIG_STRUCT.unpack(
|
||||
recv_exact(self.rfile, CONFIG_STRUCT.size)
|
||||
)
|
||||
sweep = RadarSweepModel(
|
||||
start_hz=start_hz,
|
||||
stop_hz=stop_hz,
|
||||
points=int(points),
|
||||
if_bandwidth_hz=ifbw_hz,
|
||||
power_dbm=power_dbm,
|
||||
)
|
||||
self.service.configure(sweep)
|
||||
self.wfile.write(STATUS_OK)
|
||||
send_u32(self.wfile, int(points))
|
||||
send_float32_array(self.wfile, self.service.frequency_axis())
|
||||
|
||||
def _handle_acquire(self) -> None:
|
||||
sweep = self.service.acquire_interleaved()
|
||||
self.wfile.write(STATUS_OK)
|
||||
send_u32(self.wfile, int(sweep.frequency_hz.size))
|
||||
send_float32_array(self.wfile, sweep.s11_values)
|
||||
send_float32_array(self.wfile, sweep.s21_values)
|
||||
|
||||
|
||||
class K209RemoteServer(socketserver.TCPServer):
|
||||
"""Single-client TCP server with K209 connection settings."""
|
||||
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, server_address: tuple[str, int], resource: str, timeout_ms: int) -> None:
|
||||
self.resource = resource
|
||||
self.timeout_ms = timeout_ms
|
||||
super().__init__(server_address, K209RemoteRequestHandler)
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Serve a locally connected Compact-M K209 over TCP.")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Server bind address.")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_REMOTE_PORT, help="Server TCP port.")
|
||||
parser.add_argument("--resource", default=DEFAULT_RESOURCE, help="Local S2VNA VISA resource.")
|
||||
parser.add_argument("--timeout-ms", type=int, default=20_000, help="K209 VISA timeout.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
with K209RemoteServer((args.host, args.port), resource=args.resource, timeout_ms=args.timeout_ms) as server:
|
||||
print(f"K209 remote server listening on {args.host}:{args.port}")
|
||||
print(f"Local S2VNA resource: {args.resource}")
|
||||
server.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Smoke test for a remote Compact-M K209 server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from python_app.hardware_full.k209_remote_protocol import DEFAULT_REMOTE_HOST, DEFAULT_REMOTE_PORT
|
||||
from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service
|
||||
from python_app.models.run_config_model import RadarSweepModel
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Validate remote K209 connection and one sweep.")
|
||||
parser.add_argument("--host", default=DEFAULT_REMOTE_HOST, help="K209 remote server host.")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_REMOTE_PORT, help="K209 remote server port.")
|
||||
parser.add_argument("--start-hz", type=float, default=10_000_000.0)
|
||||
parser.add_argument("--stop-hz", type=float, default=100_000_000.0)
|
||||
parser.add_argument("--points", type=int, default=11)
|
||||
parser.add_argument("--ifbw-hz", type=float, default=10_000.0)
|
||||
parser.add_argument("--power-dbm", type=float, default=-20.0)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parse_args()
|
||||
sweep = RadarSweepModel(
|
||||
start_hz=args.start_hz,
|
||||
stop_hz=args.stop_hz,
|
||||
points=args.points,
|
||||
if_bandwidth_hz=args.ifbw_hz,
|
||||
power_dbm=args.power_dbm,
|
||||
)
|
||||
service = RemoteCompactMK209Service(host=args.host, port=args.port)
|
||||
try:
|
||||
service.open()
|
||||
print(f"K209 IDN: {service.query_identity()}")
|
||||
service.configure(sweep)
|
||||
result = service.acquire()
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
s11 = result.trace("s11")
|
||||
s21 = result.trace("s21")
|
||||
if result.x.size != args.points or s11.size != args.points or s21.size != args.points:
|
||||
raise RuntimeError("Remote K209 sweep returned an unexpected point count")
|
||||
if not np.all(np.isfinite(result.x)) or not np.all(np.isfinite(s11)) or not np.all(np.isfinite(s21)):
|
||||
raise RuntimeError("Remote K209 sweep contains non-finite values")
|
||||
if not np.all(np.diff(result.x) >= 0):
|
||||
raise RuntimeError("Remote K209 frequency axis is not monotonic")
|
||||
|
||||
print(
|
||||
"Remote K209 sweep OK: "
|
||||
f"points={args.points}, first_hz={result.x[0]:.3f}, last_hz={result.x[-1]:.3f}, "
|
||||
f"mean_abs_s11={float(np.mean(np.abs(s11))):.6g}, "
|
||||
f"mean_abs_s21={float(np.mean(np.abs(s21))):.6g}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
@@ -26,7 +26,7 @@ def capture_calibration_set(
|
||||
|
||||
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
|
||||
radar = LibreVnaService(serial=config.radar.serial or None)
|
||||
radar = create_single_radar_service(config)
|
||||
input_switch = SwitchService(
|
||||
name=config.input_switch.name,
|
||||
positions=config.input_switch.positions,
|
||||
|
||||
@@ -8,8 +8,8 @@ import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import ComboModel, RunConfigModel
|
||||
@@ -100,7 +100,7 @@ class MultiRadarSequentialCaptureSession:
|
||||
self._input_switch = None
|
||||
self._output_switch = None
|
||||
else:
|
||||
self._radar = LibreVnaService(serial=base_config.radar.serial or None)
|
||||
self._radar = create_single_radar_service(base_config)
|
||||
self._input_switch = SwitchService(
|
||||
name=base_config.input_switch.name,
|
||||
positions=base_config.input_switch.positions,
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
@@ -48,7 +48,7 @@ def capture_reference_set(
|
||||
|
||||
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
|
||||
radar = LibreVnaService(serial=config.radar.serial or None)
|
||||
radar = create_single_radar_service(config)
|
||||
input_switch = SwitchService(
|
||||
name=config.input_switch.name,
|
||||
positions=config.input_switch.positions,
|
||||
|
||||
@@ -8,8 +8,8 @@ import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import ComboModel, RunConfigModel
|
||||
@@ -70,7 +70,7 @@ class SequentialCaptureSession:
|
||||
self._input_switch = None
|
||||
self._output_switch = None
|
||||
else:
|
||||
self._radar = LibreVnaService(serial=config.radar.serial or None)
|
||||
self._radar = create_single_radar_service(config)
|
||||
self._input_switch = SwitchService(
|
||||
name=config.input_switch.name,
|
||||
positions=config.input_switch.positions,
|
||||
|
||||
Reference in New Issue
Block a user