Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user