Files
2026-10-06 19:36:00 +03:00

418 lines
19 KiB
Python

"""Service that serves processed RadioPhotonic radar (RPH) sweeps.
With ``source == "e502"`` it owns the external collector process and its TTY; with
``"rfg_udp"`` it reads the RFG board's UDP stream directly. Each raw (main,
reference) sweep maps to a :class:`SweepResult` on the fixed processing grid via
:class:`RphRadarSweepProcessor`.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import asdict, dataclass, field
import json
import logging
import os
from pathlib import Path
import signal
import stat
import subprocess
import threading
import time
import numpy as np
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
from python_app.hardware_full.laser_control.conversions import variation_step_count
from python_app.hardware_full.rph_radar.protocol import RawSweep, RfgSweepAssembler
from python_app.hardware_full.rph_radar.stream_reader import RphRadarStreamReader, raise_if_process_exited
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.dataset_model import ComboKey
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
from python_app.models.run_config_validation import validate_rfg_source, validate_rph_radar_model
logger = logging.getLogger(__name__)
# Project root, used to resolve relative collector paths (e.g.
# ``build/bin/rph_radar_collector``) independent of the launching CWD.
_REPO_ROOT = Path(__file__).resolve().parents[3]
# Default home of the proprietary L-Card runtime libraries the collector loads
# via dlopen. Prepended to LD_LIBRARY_PATH unless the config pins it explicitly.
_DEFAULT_LCARD_LIB_DIR = "~/.local/lib"
# Rejected-sweep logging is throttled so a persistently mis-set band/calibration
# does not flood the log: log the first rejection, then every Nth.
_REJECT_LOG_EVERY = 50
# The collector's graceful X502 teardown can block, so a stop gives it only this
# brief window to release the device cleanly before escalating to SIGKILL. Caps
# the configured stop_timeout_s so a stop can never hang.
_STOP_KILL_GRACE_S = 0.5
@dataclass(slots=True)
class RphRadarService:
"""Serve processed RadioPhotonic radar (RPH) sweeps from the E-502 collector or the RFG stream."""
config: RunConfigModel
collector_driven: bool = False
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: RphRadarStreamReader | None = field(init=False, default=None, repr=False)
_processor: RphRadarSweepProcessor | None = field(init=False, default=None, repr=False)
_rejected_count: int = field(init=False, default=0, repr=False)
_prepared_combo: ComboKey | None = field(init=False, default=None, repr=False)
last_cycle_id: int = field(init=False, default=0)
_output_thread: threading.Thread | None = field(init=False, default=None, repr=False)
_stop_event: threading.Event | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@property
def command(self) -> list[str]:
"""Pass the current typed settings once; the collector never rereads the profile."""
adc = self.config.radar.rph_radar
cmd = [str(self._resolve_executable()), *adc.args]
if self.collector_driven:
switching = {"input": asdict(self.config.input_switch), "output": asdict(self.config.output_switch),
"combos": [[combo.input, combo.output] for combo in self.config.combos],
"settling_ms": self.config.runtime.settling_ms}
cmd.append("switching:" + json.dumps(switching, separators=(",", ":")))
cmd.append(f"tty:{adc.tty_path}")
return cmd
def open(self, *, stop_event: threading.Event | None = None) -> None:
"""Bind the RFG UDP stream, or launch the E-502 collector and start its TTY reader.
An optional ``stop_event`` lets a caller abort the TTY-wait loop promptly
(e.g. on shutdown) instead of blocking for the full startup timeout.
"""
if self._reader is not None:
return
adc = self.config.radar.rph_radar
if adc.source == "rfg_udp":
variation = self.config.radar.laser_control.variation
steps = variation_step_count(variation.min_value, variation.max_value, variation.step)
step_ns = variation.time_step * 1000
# Lower bound of the sweep period: the firmware idles at least delay_time - 1 ms.
period_ns = steps * step_ns + (variation.delay_time - 1) * 1_000_000
reader = RphRadarStreamReader(adc.udp_bind, RfgSweepAssembler(steps, step_ns, period_ns))
reader.open()
self._reader = reader
logger.info("RadioPhotonic radar (RPH) RFG stream opened on %s (%d steps per sweep)", adc.udp_bind, steps)
return
if self._process is not None and self._process.poll() is None:
raise RuntimeError("Previous RPH collector did not stop; cannot start a fresh capture")
self._stop_event = stop_event
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.rph_radar.tty_path)
try:
self._start_process()
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
reader = RphRadarStreamReader(self.config.radar.rph_radar.tty_path)
reader.open()
self._reader = reader
logger.info("RadioPhotonic radar (RPH) service opened")
except Exception:
logger.exception("RadioPhotonic radar (RPH) service failed to open; cleaning up")
self.close()
raise
def close(self) -> None:
"""Stop the reader and, for E-502, the external collector process."""
logger.debug("Closing RadioPhotonic radar (RPH) service")
self._prepared_combo = None
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
if self._output_thread is not None:
self._output_thread.join(timeout=1.0)
self._output_thread = None
def configure(self, sweep: RadarSweepModel) -> None:
"""Build the sweep processor from the ``radar.rph_radar`` calibration/band.
The generic ``sweep`` argument is accepted for interface parity with the
other radar services but is not used: the RadioPhotonic radar (RPH) frequency axis comes
from the reference-phase calibration, and the output grid from
``radar.rph_radar.band`` — never from the nominal sweep bounds.
"""
adc = self.config.radar.rph_radar
self._processor = RphRadarSweepProcessor(adc)
self._rejected_count = 0
phase, band = adc.phase_calibration, adc.band
logger.debug(
"RadioPhotonic radar (RPH) configured: band %.6g-%.6g Hz, %d points; calibration "
"(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)",
band.start_hz, band.stop_hz, band.points,
phase.phase0_rad, phase.freq0_hz, phase.phase1_rad, phase.freq1_hz,
)
def read_device_limits(self) -> dict[str, float | int]:
"""RadioPhotonic radar (RPH) has no runtime-readable sweep-limit API."""
raise RuntimeError("RadioPhotonic radar (RPH) device limits are not available")
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
"""Return the next sweep that covers the band, as S21 on the fixed grid.
Sweeps whose floated frequency range does not span the configured band are
rejected and the next sweep is read, until one passes or the sweep timeout
elapses (which then surfaces as a :class:`TimeoutError`).
When ``combo`` is given (switch-aware mode), only the clean sweep captured
under that switch combination is returned; the collector drives the switches
and tags each sweep. When ``None`` (calibration / non-switch mode), the
single newest sweep is returned regardless of combination.
"""
if self._processor is None:
raise RuntimeError("RadioPhotonic radar (RPH) service is not configured")
if self._reader is None:
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
process = self._process
if self.config.radar.rph_radar.source == "e502" and (process is None or process.poll() is not None):
return_code = None if process is None else process.poll()
raise RuntimeError(f"RadioPhotonic radar (RPH) collector is not running (code={return_code})")
grid = self._processor.grid_hz
deadline = time.monotonic() + self.config.radar.rph_radar.sweep_timeout_s
while True:
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
"Timed out waiting for a RadioPhotonic radar (RPH) sweep covering the configured band"
)
raw = self._reader.read_sweep(timeout_s=remaining_s, combo=combo, process=process)
s21 = self._processor.process(raw.main, raw.reference)
if s21 is not None:
self.last_cycle_id = raw.cycle_id
return SweepResult(
x=grid,
traces={
"s21": s21,
},
capture_start_ns=raw.capture_start_ns,
capture_end_ns=raw.capture_end_ns,
)
self._log_rejected_sweep(raw)
def prepare_combo(self, combo: ComboKey) -> None:
"""Start a fresh ADC session after switching; retain phase branch tracking."""
if self._prepared_combo == combo:
return
self.close()
if self.config.runtime.settling_ms < 1:
time.sleep(.001) # The caller already waited any configured settling.
self.open(stop_event=self._stop_event)
self._prepared_combo = combo
def read_raw_sweep(self) -> RawSweep:
"""Return the next raw (main, reference) sweep without any processing.
Bypasses the frequency mapping, normalization, crop and resample of
:meth:`acquire` — intended for calibration tooling that needs the
unprocessed reference samples. Raises if the service is not open.
"""
if self._reader is None:
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
return self._reader.read_sweep(
timeout_s=self.config.radar.rph_radar.sweep_timeout_s,
process=self._process,
)
# ------------------------------------------------------------------
# Process / TTY lifecycle
# ------------------------------------------------------------------
def _start_process(self) -> None:
if self._process is not None and self._process.poll() is None:
return
logger.info("Starting RadioPhotonic radar (RPH) collector: %s", " ".join(self.command))
self._process = subprocess.Popen(
self.command,
cwd=str(self._resolve_project_dir()),
env=self._build_env(),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
self._output_thread = threading.Thread(target=self._log_output, args=(self._process,),
name="rph-radar-log", daemon=True)
self._output_thread.start()
@staticmethod
def _log_output(process: subprocess.Popen[bytes]) -> None:
if process.stdout is not None:
with process.stdout:
for line in process.stdout:
logger.info("Collector: %s", line.decode("utf-8", errors="replace").rstrip())
def _stop_process(self) -> None:
process = self._process
if process is None or process.poll() is not None:
self._process = None
return
logger.info("Stopping RadioPhotonic radar (RPH) collector (pid=%d)", process.pid)
# The collector's graceful X502 teardown can block, so give it only a brief
# window to release the device cleanly, then SIGKILL the whole group hard.
grace_s = min(self.config.radar.rph_radar.stop_timeout_s, _STOP_KILL_GRACE_S)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=grace_s)
self._process = None
return
except subprocess.TimeoutExpired:
pass
logger.warning("RadioPhotonic radar (RPH) collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
# close() must never raise: a collector wedged in uninterruptible I/O
# (USB D-state in the L-Card driver) may not be reaped within the grace
# window even after SIGKILL. Best-effort wait; the OS reaps it eventually.
with suppress(subprocess.TimeoutExpired):
process.wait(timeout=1.0)
# Retain ownership in D-state so retries cannot launch a second collector.
if process.poll() is not None:
self._process = None
def _wait_for_tty(
self,
previous_identity: tuple[object, ...] | None,
*,
stop_event: threading.Event | None = None,
) -> None:
adc = self.config.radar.rph_radar
deadline = time.monotonic() + adc.startup_timeout_s
while time.monotonic() < deadline:
if stop_event is not None and stop_event.is_set():
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
raise_if_process_exited(self._process)
identity = _tty_identity(adc.tty_path)
if identity is not None and identity != previous_identity:
return
if stop_event is not None:
if stop_event.wait(0.05):
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
else:
time.sleep(0.05)
raise TimeoutError(
f"Timed out waiting for RadioPhotonic radar (RPH) TTY `{adc.tty_path}` to be created by the collector"
)
# ------------------------------------------------------------------
# Path / environment resolution
# ------------------------------------------------------------------
def _resolve_executable(self) -> Path:
return self._resolve_path(self.config.radar.rph_radar.executable_path)
def _resolve_project_dir(self) -> Path:
project_dir = self.config.radar.rph_radar.project_dir
return self._resolve_path(project_dir) if project_dir else _REPO_ROOT
@staticmethod
def _resolve_path(path_str: str) -> Path:
"""Expand ``~`` and resolve a relative path against the project root."""
path = Path(path_str).expanduser()
return path if path.is_absolute() else (_REPO_ROOT / path)
def _build_env(self) -> dict[str, str]:
"""Child environment: inherited env + config env, with the L-Card lib path.
Config ``env`` values are ``~``/``$VAR`` expanded. Unless the config pins
``LD_LIBRARY_PATH`` itself, the default L-Card library directory is
prepended so the collector's dlopen of libx502api/libe502api succeeds.
"""
adc = self.config.radar.rph_radar
env = os.environ.copy()
for key, value in adc.env.items():
env[key] = os.path.expandvars(os.path.expanduser(value))
if "LD_LIBRARY_PATH" not in adc.env:
lcard_dir = os.path.expanduser(_DEFAULT_LCARD_LIB_DIR)
existing = env.get("LD_LIBRARY_PATH", "")
env["LD_LIBRARY_PATH"] = f"{lcard_dir}{os.pathsep}{existing}" if existing else lcard_dir
return env
# ------------------------------------------------------------------
# Validation / diagnostics
# ------------------------------------------------------------------
def _validate_config(self) -> None:
if not self.config.is_rph_radar:
raise RuntimeError("RphRadarService requires radar.model='rph_radar'")
if self.config.radar.driver_mode != "native":
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
adc = self.config.radar.rph_radar
validate_rph_radar_model(adc)
if adc.source == "rfg_udp":
validate_rfg_source(self.config)
return
if not adc.executable_path:
raise ValueError("radar.rph_radar.executable_path is required")
if not adc.tty_path:
raise ValueError("radar.rph_radar.tty_path is required")
if any(arg.startswith("tty:") for arg in adc.args):
raise ValueError("radar.rph_radar.args must not contain tty:<path>; use tty_path instead")
project_dir = self._resolve_project_dir()
if not project_dir.is_dir():
raise RuntimeError(f"radar.rph_radar.project_dir is not a directory: {project_dir}")
executable_path = self._resolve_executable()
if not executable_path.is_file():
raise RuntimeError(f"radar.rph_radar.executable_path is not a file: {executable_path}")
if not os.access(executable_path, os.X_OK):
raise RuntimeError(f"radar.rph_radar.executable_path is not executable: {executable_path}")
def _log_rejected_sweep(self, raw) -> None:
"""Log a band-coverage rejection (throttled) with the measured span."""
self._rejected_count += 1
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
return
assert self._processor is not None
band = self.config.radar.rph_radar.band
try:
freqs = self._processor.reference_frequency_axis(raw.reference)
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
except Exception: # noqa: BLE001 — diagnostics must never raise
covered = "<unavailable>"
logger.warning(
"RadioPhotonic radar (RPH) sweep rejected (count=%d): covered %s Hz does not span band "
"[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.",
self._rejected_count, covered, band.start_hz, band.stop_hz, raw.size,
)
def _tty_identity(path: str) -> tuple[object, ...] | None:
try:
if os.path.islink(path):
stat_result = os.lstat(path)
return (
"link",
os.readlink(path),
int(stat_result.st_dev),
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
stat_result = os.stat(path)
except FileNotFoundError:
return None
return (
"node",
int(stat_result.st_dev),
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
"""Remove a stale generated TTY symlink/file before starting the collector."""
try:
stat_result = os.lstat(path)
except FileNotFoundError:
return None
if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
os.unlink(path)
return None