Rename kamil_adc to rph_radar and fix its data path

This commit is contained in:
Ayzen
2026-10-06 18:15:18 +03:00
parent aef549ea76
commit a185e61495
94 changed files with 2851 additions and 9485 deletions
@@ -0,0 +1 @@
"""L-Card acquisition, reference-phase processing and laser configuration."""
@@ -0,0 +1,37 @@
"""Apply constant laser settings or LD1 current variation before ADC acquisition."""
from dataclasses import asdict
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.models.run_config_schema import RunConfigModel
def apply_rph_radar_laser_control(config: RunConfigModel) -> bool:
"""Validate every command before connecting/resetting; leave a started sweep running."""
laser = config.radar.laser_control
if not laser.enabled:
return False
if not laser.port:
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
mode = laser.mode.strip().lower()
if mode not in {"manual", "variation"}:
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
pi = {name: getattr(laser, name) for name in ("pi_coeff1_p", "pi_coeff1_i", "pi_coeff2_p", "pi_coeff2_i")}
manual = (asdict(laser.manual) if mode == "manual" else
{name: getattr(laser.variation, "static_" + name) for name in ("temp1", "temp2", "current1", "current2")})
variation = asdict(laser.variation)
Protocol.encode_decode_enable(**manual, **pi, message_id=DEVICE_MAIN_MESSAGE_ID)
if mode == "variation":
Protocol.encode_task_enable(**variation, **pi)
controller = LaserController(port=laser.port, **pi)
try:
controller.connect()
controller.reset()
controller.set_manual_mode(**manual, message_id=DEVICE_MAIN_MESSAGE_ID)
if mode == "variation":
controller.start_variation(params=variation)
return True
finally:
controller.disconnect()
@@ -0,0 +1,182 @@
"""Signal processing for the RadioPhotonic radar (RPH) reference-channel acquisition.
Each sweep arrives as two aligned complex arrays — the *main* signal and a
*reference* signal — sampled at the same step indices (see
:mod:`python_app.hardware_full.rph_radar.protocol`). Turning that into a stable,
comparable S21 trace is a fixed three-stage pipeline:
1. **Frequency axis from the reference phase.** The reference arm has a constant
electrical delay, so its unwrapped phase is an affine function of frequency.
Two fixed calibration anchors ``(phase0, freq0)`` and ``(phase1, freq1)`` —
supplied by config, *never* derived from the live sweep — define that law::
f(phase) = freq0 + (phase - phase0) * (freq1 - freq0) / (phase1 - phase0)
Trigger jitter shifts every sample's absolute phase together, so the measured
band floats from sweep to sweep around the fixed calibration. ``np.unwrap``
anchors each sweep's ramp to ``np.angle(reference[0])`` on the (-pi, pi] branch,
so once that float carries the anchor across the +/-pi cut the whole ramp jumps
one 2*pi turn (~117 MHz on the rig) even though nothing physical moved. The
genuine float is slow (well under pi between consecutive sweeps) while the wrap
is a discrete 2*pi step, so the processor *unwraps the anchor across sweeps*:
each sweep's anchor is snapped onto the branch nearest the previous accepted
sweep (the first sweep onto the calibration ``phase0`` branch). Validated on
10 000 live sweeps: 506 branch wraps, yet the largest cross-sweep anchor step
stayed at 1.6 rad (< pi), and the correction cut badly-distorted pass-through
sweeps from 580 to 11 while leaving clean sweeps untouched.
2. **Amplitude normalization.** ``S = main / |reference|`` divides out the
stimulus amplitude. Only the magnitude is removed; the reference phase is used
solely for the axis above.
3. **Crop + resample onto a fixed grid.** The floating per-sweep axis is resampled
(linear, on real and imaginary parts) onto a single hardcoded grid
``linspace(band_start, band_stop, band_points)``. Every sweep then shares a
byte-identical frequency axis, so traces can be averaged and subtracted. A
sweep whose floated range does not fully span the band is *rejected* rather
than edge-extrapolated.
Everything here is pure and free of I/O so it can be unit-tested in isolation.
"""
from __future__ import annotations
from dataclasses import replace
import numpy as np
from numpy.typing import NDArray
from python_app.models.run_config_schema import RphRadarModel
from python_app.models.run_config_validation import validate_rph_radar_model
# A reference sample whose magnitude falls to (effectively) zero carries no usable
# amplitude or phase; such points are dropped before normalization rather than
# producing a division blow-up. The threshold only guards genuine zeros — real
# reference frames are emitted from settled measurements and sit far above it.
_REFERENCE_AMPLITUDE_FLOOR = 1e-9
# Linear interpolation onto the band needs at least two distinct source samples;
# a sweep yielding fewer usable points is malformed and rejected.
_MIN_USABLE_POINTS = 2
# One full turn of reference phase. ``np.unwrap`` anchors each sweep's phase ramp
# to the raw angle of the first sample on the (-pi, pi] branch, so a stray sweep
# whose anchor crossed the +/-pi cut is offset by exactly this; the cross-sweep
# anchor tracking snaps it back (see ``RphRadarSweepProcessor._align_phase_branch``).
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
class RphRadarSweepProcessor:
"""Turns aligned (main, reference) sweeps into S21 traces on a fixed grid.
The output grid is computed once from the parameters and reused for every
sweep, so all traces this processor emits share one identical frequency axis.
"""
def __init__(self, config: RphRadarModel) -> None:
validate_rph_radar_model(config)
self._calibration = replace(config.phase_calibration)
self._band = replace(config.band)
self._hz_per_rad = ((self._calibration.freq1_hz - self._calibration.freq0_hz)
/ (self._calibration.phase1_rad - self._calibration.phase0_rad))
self._grid_hz = np.linspace(self._band.start_hz, self._band.stop_hz, self._band.points)
self.grid_hz = self._grid_hz.astype(np.float32)
self.grid_hz.setflags(write=False)
self._previous_anchor_rad: float | None = None
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
Stateless and *uncorrected* (no cross-sweep branch tracking), so it shows
the raw per-sweep axis — used by diagnostics. The live path
(:meth:`process`) applies the branch correction. Returns frequencies in
*step order* (not sorted); see the module docstring for the calibration law.
"""
return self._phase_to_frequency(np.unwrap(np.angle(np.asarray(reference))))
def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray:
"""Apply the affine ``phase -> frequency`` calibration law."""
return self._calibration.freq0_hz + (phase - self._calibration.phase0_rad) * self._hz_per_rad
def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]:
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
``np.unwrap`` pins the whole ramp to ``phase[0]`` on the (-pi, pi] branch,
so a slow physical float that drags the anchor over the +/-pi cut flips the
entire sweep by one :data:`_PHASE_BRANCH_PERIOD_RAD`. We snap this sweep's
anchor onto the branch nearest the previous accepted sweep's anchor (the
first sweep onto the calibration ``phase0``), then shift the whole ramp by
the same whole number of turns.
Returns ``(branch_aligned_phase, anchor_to_commit)``. The caller commits the
anchor only once the sweep is accepted, so a rejected/corrupt sweep can
never latch the tracker onto a wrong branch. Genuine sub-pi sweep-to-sweep
float rounds to zero turns and is preserved untouched.
"""
anchor = float(phase[0])
reference_anchor = (
self._previous_anchor_rad
if self._previous_anchor_rad is not None
else self._calibration.phase0_rad
)
turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD)
if turns:
shift = turns * _PHASE_BRANCH_PERIOD_RAD
return phase + shift, anchor + shift
return phase, anchor
def process(self, main: np.ndarray, reference: np.ndarray) -> NDArray[np.complex64] | None:
"""Return the S21 trace resampled onto the fixed grid, or ``None`` to reject.
``main`` and ``reference`` are equal-length complex arrays ordered by
ascending step index. ``None`` is returned when the sweep is malformed
(too few usable points) or does not fully cover the configured band.
"""
main = np.asarray(main, dtype=np.complex128)
reference = np.asarray(reference, dtype=np.complex128)
if main.size < _MIN_USABLE_POINTS or main.size != reference.size:
return None
# Frequency axis from the absolute unwrapped reference phase (step order),
# with a stray +/-pi anchor wrap undone relative to the last accepted sweep.
# The aligned anchor is committed only if this sweep is accepted (below).
phase, candidate_anchor = self._align_phase_branch(np.unwrap(np.angle(reference)))
freqs = self._phase_to_frequency(phase)
# Amplitude-only normalization; drop points where the reference vanished.
reference_amplitude = np.abs(reference)
with np.errstate(divide="ignore", invalid="ignore"):
s21 = main / reference_amplitude
usable = (
(reference_amplitude > _REFERENCE_AMPLITUDE_FLOOR)
& np.isfinite(freqs)
& np.isfinite(s21.real)
& np.isfinite(s21.imag)
)
if int(np.count_nonzero(usable)) < _MIN_USABLE_POINTS:
return None
freqs = freqs[usable]
s21 = s21[usable]
# Sort onto a monotonically increasing axis (handles either sweep
# direction) so interpolation and the coverage check are well defined.
order = np.argsort(freqs, kind="stable")
freqs = freqs[order]
s21 = s21[order]
# Reject sweeps that do not span the whole band: interpolating past the
# measured edge would inject flat, non-physical points.
if freqs[0] > self._band.start_hz or freqs[-1] < self._band.stop_hz:
return None
# The sweep is accepted: commit its branch-aligned anchor so the next
# sweep is tracked relative to it (and a wrap is measured against a real,
# in-band reference rather than a rejected one).
self._previous_anchor_rad = candidate_anchor
# Linear interpolation on real/imaginary parts. Because the band lies
# within [freqs[0], freqs[-1]], np.interp never extrapolates here.
real = np.interp(self._grid_hz, freqs, s21.real)
imag = np.interp(self._grid_hz, freqs, s21.imag)
return (real + 1j * imag).astype(np.complex64)
@@ -0,0 +1,89 @@
"""Whole-sweep RPH1 packets from the collector's PTY (little endian).
The 48-byte header carries payload size, source sweep/cycle IDs, monotonic capture
window, input/output (65535 for untagged) and dirty flag. Payload points are
8-byte ``marker, step, I, Q`` frames: A for main and A8 for reference.
Only complete queued packets may be dropped; a packet already being written is
always finished. Thus overflow cannot splice two physical sweeps together.
"""
from dataclasses import dataclass
import struct
import numpy as np
from numpy.typing import NDArray
MAIN_MARKER = 0x000A
REFERENCE_MARKER = 0x00A8
_HEADER = struct.Struct("<4sIQQQQHHI")
_POINT = struct.Struct("<HHhh")
_MAX_PAYLOAD_BYTES = 65534 * 2 * _POINT.size
@dataclass(frozen=True, slots=True)
class RawSweep:
steps: NDArray[np.int32]
main: NDArray[np.complex64]
reference: NDArray[np.complex64]
source_id: int
cycle_id: int
capture_start_ns: int
capture_end_ns: int
combo: tuple[int, int] | None = None
dirty: bool = False
@property
def size(self) -> int:
return int(self.steps.size)
class RphRadarStreamParser:
"""Incremental packet parser; corrupt headers or point markers fail closed."""
def __init__(self) -> None:
self._buffer = bytearray()
self._aligned = False
def reset(self) -> None:
self._buffer.clear()
self._aligned = False
def feed(self, data: bytes) -> list[RawSweep]:
self._buffer.extend(data)
if not self._aligned:
index = self._buffer.find(b"RPH1")
if index < 0:
del self._buffer[:-3]
return []
del self._buffer[:index]
self._aligned = True
sweeps = []
while len(self._buffer) >= _HEADER.size:
magic, size, source_id, cycle_id, start, end, inp, out, flags = _HEADER.unpack_from(self._buffer)
if (magic != b"RPH1" or size > _MAX_PAYLOAD_BYTES or size % _POINT.size
or not source_id or not cycle_id or not 0 < start <= end
or flags & ~1 or (inp == 65535) != (out == 65535)):
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid sweep header")
packet_size = _HEADER.size + size
if len(self._buffer) < packet_size:
break
main: dict[int, complex] = {}
reference: dict[int, complex] = {}
for marker, step, real, imag in _POINT.iter_unpack(self._buffer[_HEADER.size:packet_size]):
if marker not in (MAIN_MARKER, REFERENCE_MARKER) or not 0 < step < 65535:
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid point frame")
target = main if marker == MAIN_MARKER else reference
if step in target:
raise ValueError("RadioPhotonic radar (RPH) protocol violation: duplicate point")
target[step] = complex(real, imag)
del self._buffer[:packet_size]
shared = sorted(main.keys() & reference.keys())
if shared:
sweeps.append(RawSweep(
np.asarray(shared, dtype=np.int32),
np.asarray([main[step] for step in shared], dtype=np.complex64),
np.asarray([reference[step] for step in shared], dtype=np.complex64),
source_id, cycle_id, start, end,
None if inp == 65535 else (inp, out), bool(flags & 1),
))
return sweeps
@@ -0,0 +1,403 @@
"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed sweeps.
Owns the lifecycle of the external collector process and its TTY reader, and maps
each raw (main, reference) sweep to an :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.rph_radar.protocol import RawSweep
from python_app.hardware_full.rph_radar.tty_reader import (
RphRadarTtyReader,
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_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:
"""Launch the external RadioPhotonic radar (RPH) collector and serve its processed sweeps."""
config: RunConfigModel
collector_driven: bool = False
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: RphRadarTtyReader | 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:
"""Launch the collector and start the TTY reader thread.
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
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 = RphRadarTtyReader(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 TTY reader and 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 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 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
@@ -0,0 +1,213 @@
"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep.
The collector emits sweeps continuously, faster than callers invoke
:meth:`RphRadarService.acquire`. A daemon thread drains the device end of the
TTY non-stop, feeds the bytes to a :class:`RphRadarStreamParser`, and stores the
most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep`
returns the freshest sweep; if a newer one arrives before the consumer reads, it
overwrites the previous unread value — by design, since consumers always want the
latest data. Parser/stream errors are captured and re-raised on the consumer
thread (fail-fast; the supervisor relaunches a clean collector).
"""
from __future__ import annotations
from dataclasses import dataclass, field
import errno
import logging
import os
import select
import subprocess
import threading
import time
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser, RawSweep
logger = logging.getLogger(__name__)
# Large reads keep up with bursty CDC-ACM/PTY writers without raising the syscall
# rate; 64 KiB matches the typical Linux PTY buffer size.
_READ_CHUNK_BYTES = 65536
# select() poll interval — short enough to react to close() promptly, long enough
# that idle CPU stays near zero.
_READ_POLL_INTERVAL_S = 0.1
def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
"""Raise if the external collector process has exited."""
if process is None:
return
return_code = process.poll()
if return_code is not None:
raise RuntimeError(f"RadioPhotonic radar (RPH) collector exited with code {return_code}")
@dataclass(slots=True)
class RphRadarTtyReader:
"""Daemon-thread TTY reader publishing the latest completed sweep."""
tty_path: str
_fd: int | None = field(init=False, default=None, repr=False)
_thread: threading.Thread | None = field(init=False, default=None, repr=False)
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
# Latest clean sweep per switch combination, for the switch-aware collector.
# read_sweep() ignores this and serves the single newest sweep (calibration /
# non-switch mode); an optional combo selects its own slot.
_combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False)
_reader_error: Exception | None = field(init=False, default=None, repr=False)
_published_count: int = field(init=False, default=0, repr=False)
def open(self) -> None:
"""Open the TTY and start the background reader thread."""
if self._fd is not None:
return
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
self._stop_event.clear()
self._latest_sweep = None
self._combo_slots = {}
self._reader_error = None
self._published_count = 0
self._thread = threading.Thread(
target=self._reader_loop,
name=f"rph-radar-tty-reader[{self.tty_path}]",
daemon=True,
)
try:
self._thread.start()
except Exception:
self._thread = None
fd, self._fd = self._fd, None
os.close(fd)
raise
logger.info("RadioPhotonic radar (RPH) TTY reader started on %s", self.tty_path)
def close(self) -> None:
"""Stop the reader thread and close the TTY descriptor."""
logger.debug("Stopping RadioPhotonic radar (RPH) TTY reader on %s", self.tty_path)
self._stop_event.set()
with self._mailbox_cv:
self._mailbox_cv.notify_all()
if self._thread is not None:
self._thread.join(timeout=1.0)
if self._thread.is_alive():
logger.warning("RadioPhotonic radar (RPH) reader thread did not stop within 1.0s")
self._thread = None
if self._fd is not None:
try:
os.close(self._fd)
finally:
self._fd = None
self._latest_sweep = None
self._combo_slots = {}
self._reader_error = None
@property
def published_count(self) -> int:
"""Total number of sweeps the reader thread has produced."""
with self._mailbox_cv:
return self._published_count
def read_sweep(
self,
*,
timeout_s: float,
combo: tuple[int, int] | None = None,
process: subprocess.Popen[bytes] | None = None,
) -> RawSweep:
"""Wait for and return the next published sweep.
Raises :class:`TimeoutError` if none arrives within ``timeout_s``,
:class:`RuntimeError` if the collector process exited, and re-raises any
error caught by the reader thread.
"""
if self._thread is None:
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is not open")
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
if self._stop_event.is_set():
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is closed")
# Deliver a pending sweep first: if the reader both published a
# sweep and then died, the consumer still sees the good data and
# only meets the error on the next call.
sweep = self._latest_sweep if combo is None else self._combo_slots.pop(combo, None)
if sweep is not None:
if combo is None:
self._latest_sweep = None
return sweep
if self._reader_error is not None:
raise self._reader_error
raise_if_process_exited(process)
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s"
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
def _reader_loop(self) -> None:
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
parser = RphRadarStreamParser()
try:
while not self._stop_event.is_set():
chunk = self._read_available()
if not chunk:
continue
for sweep in parser.feed(chunk):
self._publish_sweep(sweep)
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.tty_path)
self._publish_error(exc)
def _read_available(self) -> bytes:
"""Block on ``select`` up to the poll interval; return new bytes (maybe empty).
Returns ``b""`` when no data is ready yet or a stop was requested; raises
:class:`RuntimeError` on stream close or an unrecoverable read error.
"""
fd = self._fd
if fd is None or self._stop_event.is_set():
return b""
try:
readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S)
except InterruptedError:
return b""
if not readable:
return b""
try:
chunk = os.read(fd, _READ_CHUNK_BYTES)
except BlockingIOError:
return b""
except OSError as exc:
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
return b""
raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) TTY `{self.tty_path}`: {exc}") from exc
if not chunk:
raise RuntimeError(f"RadioPhotonic radar (RPH) TTY `{self.tty_path}` closed while reading")
return chunk
def _publish_sweep(self, sweep: RawSweep) -> None:
"""Publish a completed sweep to the mailbox(es), waking any waiter.
Dirty sweeps (those that straddled a switch transition) are counted but not
delivered: the collector re-takes that combination on the next sweep. Clean
tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode)
only update the single newest-sweep mailbox that read_sweep() serves.
"""
with self._mailbox_cv:
self._published_count += 1
if sweep.dirty:
self._mailbox_cv.notify_all()
return
self._latest_sweep = sweep
if sweep.combo is not None:
self._combo_slots[sweep.combo] = sweep
self._mailbox_cv.notify_all()
def _publish_error(self, exc: Exception) -> None:
"""Record ``exc`` as the reader fault and wake any waiter."""
with self._mailbox_cv:
self._reader_error = exc
self._mailbox_cv.notify_all()