new kamil adc

This commit is contained in:
Ayzen
2026-06-11 13:02:02 +03:00
parent 21f76d7cd2
commit 9661504e51
46 changed files with 12097 additions and 1063 deletions
@@ -0,0 +1,36 @@
"""Kamil ADC reference-channel acquisition.
Layered into small, single-responsibility modules:
* :mod:`~python_app.hardware_full.kamil_adc.protocol` — TTY wire format → aligned
:class:`RawSweep` (pure, incremental parser).
* :mod:`~python_app.hardware_full.kamil_adc.processing` — reference-phase
frequency axis, amplitude normalization, crop + resample to a fixed grid (pure).
* :mod:`~python_app.hardware_full.kamil_adc.tty_reader` — background thread that
drains the TTY and publishes the latest sweep.
* :mod:`~python_app.hardware_full.kamil_adc.service` — collector process lifecycle
and ``acquire() -> SweepResult``.
* :mod:`~python_app.hardware_full.kamil_adc.laser` — pre-acquisition laser setup.
"""
from python_app.hardware_full.kamil_adc.laser import apply_kamil_adc_laser_control
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.kamil_adc.protocol import (
KamilAdcStreamParser,
RawSweep,
)
from python_app.hardware_full.kamil_adc.service import KamilAdcService
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
__all__ = [
"KamilAdcProcessingParams",
"KamilAdcService",
"KamilAdcStreamParser",
"KamilAdcSweepProcessor",
"KamilAdcTtyReader",
"RawSweep",
"apply_kamil_adc_laser_control",
]
+103
View File
@@ -0,0 +1,103 @@
"""Laser-controller setup applied before Kamil ADC acquisition.
This mirrors the legacy ``device_main`` command sequence: connect to the laser
controller, reset it, and apply either manual or variation mode per
``radar.laser_control``. The wire protocol is unchanged from the standalone tool;
only its home moved into the project.
"""
from __future__ import annotations
import logging
from python_app.models.run_config_model import RunConfigModel
logger = logging.getLogger(__name__)
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"""Apply configured laser settings via the device_main command sequence.
Returns ``True`` when settings were applied, ``False`` when laser control is
disabled. The controller is always disconnected before returning.
"""
laser = config.radar.laser_control
if not laser.enabled:
logger.debug("Kamil ADC laser control disabled; skipping")
return False
_validate_laser_control_config(config)
from python_app.hardware_full.laser_control.controller import (
DEVICE_MAIN_MESSAGE_ID,
LaserController,
)
from python_app.hardware_full.laser_control.models import VariationType
controller = LaserController(
port=laser.port,
pi_coeff1_p=laser.pi_coeff1_p,
pi_coeff1_i=laser.pi_coeff1_i,
pi_coeff2_p=laser.pi_coeff2_p,
pi_coeff2_i=laser.pi_coeff2_i,
)
try:
controller.connect()
controller.reset()
mode = laser.mode.strip().lower()
logger.info("Applying Kamil ADC laser control in %s mode", mode)
if mode == "manual":
manual = laser.manual
controller.set_manual_mode(
temp1=manual.temp1,
temp2=manual.temp2,
current1=manual.current1,
current2=manual.current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
return True
if mode == "variation":
variation = laser.variation
try:
variation_type = VariationType[variation.variation_type]
except KeyError as exc:
raise ValueError(
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
) from exc
controller.set_manual_mode(
temp1=variation.static_temp1,
temp2=variation.static_temp2,
current1=variation.static_current1,
current2=variation.static_current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=variation_type,
params={
"static_temp1": variation.static_temp1,
"static_temp2": variation.static_temp2,
"static_current1": variation.static_current1,
"static_current2": variation.static_current2,
"min_value": variation.min_value,
"max_value": variation.max_value,
"step": variation.step,
"time_step": variation.time_step,
"delay_time": variation.delay_time,
},
)
return True
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
finally:
controller.disconnect()
def _validate_laser_control_config(config: RunConfigModel) -> None:
laser = config.radar.laser_control
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'")
if mode == "variation" and not laser.variation.variation_type:
raise ValueError("radar.laser_control.variation.variation_type is required")
@@ -0,0 +1,185 @@
"""Signal processing for the Kamil ADC 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.kamil_adc.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.
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 dataclass
import numpy as np
from python_app.models.run_config_schema import KamilAdcModel
# 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
@dataclass(frozen=True, slots=True)
class KamilAdcProcessingParams:
"""Immutable phase→frequency calibration and output-grid definition.
Built from :class:`~python_app.models.run_config_schema.KamilAdcModel` via
:meth:`from_kamil_model`, but kept decoupled from the config types so the
processing can be exercised with plain numbers in tests.
"""
phase0_rad: float
freq0_hz: float
phase1_rad: float
freq1_hz: float
band_start_hz: float
band_stop_hz: float
band_points: int
reference_amplitude_floor: float = _REFERENCE_AMPLITUDE_FLOOR
def __post_init__(self) -> None:
if not np.isfinite([self.phase0_rad, self.phase1_rad, self.freq0_hz, self.freq1_hz]).all():
raise ValueError("Kamil ADC phase calibration anchors must be finite")
if self.phase0_rad == self.phase1_rad:
raise ValueError("Kamil ADC phase calibration anchors must use distinct phases")
if self.freq0_hz == self.freq1_hz:
raise ValueError("Kamil ADC phase calibration anchors must map to distinct frequencies")
if not (np.isfinite(self.band_start_hz) and np.isfinite(self.band_stop_hz)):
raise ValueError("Kamil ADC band edges must be finite")
if self.band_stop_hz <= self.band_start_hz:
raise ValueError("Kamil ADC band stop_hz must be greater than start_hz")
if self.band_points < 2:
raise ValueError("Kamil ADC band points must be >= 2")
if self.reference_amplitude_floor <= 0.0:
raise ValueError("Kamil ADC reference amplitude floor must be > 0")
@classmethod
def from_kamil_model(cls, kamil_adc: KamilAdcModel) -> KamilAdcProcessingParams:
"""Build parameters from the ``radar.kamil_adc`` config section."""
calibration = kamil_adc.phase_calibration
band = kamil_adc.band
return cls(
phase0_rad=float(calibration.phase0_rad),
freq0_hz=float(calibration.freq0_hz),
phase1_rad=float(calibration.phase1_rad),
freq1_hz=float(calibration.freq1_hz),
band_start_hz=float(band.start_hz),
band_stop_hz=float(band.stop_hz),
band_points=int(band.points),
)
@property
def hz_per_rad(self) -> float:
"""Frequency change per radian of reference phase (the calibration slope)."""
return (self.freq1_hz - self.freq0_hz) / (self.phase1_rad - self.phase0_rad)
class KamilAdcSweepProcessor:
"""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.
"""
__slots__ = ("_params", "_grid_hz")
def __init__(self, params: KamilAdcProcessingParams) -> None:
self._params = params
self._grid_hz = np.linspace(
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
)
@property
def params(self) -> KamilAdcProcessingParams:
return self._params
@property
def grid_hz(self) -> np.ndarray:
"""The fixed output frequency axis (float32), identical for every sweep."""
return self._grid_hz.astype(np.float32)
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
Returns frequencies in *step order* (not sorted); see the module docstring
for the calibration law.
"""
phase = np.unwrap(np.angle(np.asarray(reference)))
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
def process(self, main: np.ndarray, reference: np.ndarray) -> np.ndarray | 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).
freqs = self.reference_frequency_axis(reference)
# 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 > self._params.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._params.band_start_hz or freqs[-1] < self._params.band_stop_hz:
return None
# 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,141 @@
"""Wire protocol for the Kamil ADC collector's TTY stream.
The collector publishes a stream of 8-byte little-endian frames, four 16-bit
words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame
kinds appear in ``do8_freq_ref`` mode:
* **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps.
* **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``.
* **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with
the main sample of the same ``step`` (emitted only where the DI8 loopback
settled, so reference points are sparser than main points).
:class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one
:class:`RawSweep` per completed sweep. Main and reference points are aligned by
step index; only steps carrying *both* survive (a step needs its reference for
the frequency axis and its main for the signal). Anything other than the three
known frame kinds is a protocol violation and raises — the reader fails fast and
lets the supervisor relaunch a clean collector rather than silently resyncing.
"""
from __future__ import annotations
from dataclasses import dataclass
import struct
import numpy as np
# Wire-format constants.
FRAME_BYTES = 8
MAIN_MARKER = 0x000A
REFERENCE_MARKER = 0x00A8
_BOUNDARY_STEP = 0xFFFF
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
_FRAME = struct.Struct("<HHhh")
# The last three words of a boundary frame are all 0xFFFF; a real step is
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
# (profile-dependent) start marker.
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
@dataclass(frozen=True, slots=True)
class RawSweep:
"""One sweep's main and reference samples, aligned and ordered by step.
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
samples at those steps. All three arrays share the same length.
"""
steps: np.ndarray
main: np.ndarray
reference: np.ndarray
@property
def size(self) -> int:
return int(self.steps.size)
class KamilAdcStreamParser:
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
sweeps completed by that chunk (usually zero or one). The parser is stateful
but holds no I/O and is cheap to unit-test.
"""
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
def __init__(self) -> None:
self._buffer = bytearray()
self._aligned = False
self._main: dict[int, complex] = {}
self._reference: dict[int, complex] = {}
def feed(self, data: bytes) -> list[RawSweep]:
"""Append ``data`` and return any sweeps completed by it."""
self._buffer.extend(data)
if not self._aligned and not self._align():
return []
sweeps: list[RawSweep] = []
buffer = self._buffer
while len(buffer) >= FRAME_BYTES:
frame = bytes(buffer[:FRAME_BYTES])
del buffer[:FRAME_BYTES]
if frame[2:] == _BOUNDARY_TAIL:
sweep = self._take_sweep()
if sweep is not None:
sweeps.append(sweep)
continue
marker, step, real, imag = _FRAME.unpack(frame)
if marker == MAIN_MARKER:
self._main[step] = complex(real, imag)
elif marker == REFERENCE_MARKER:
self._reference[step] = complex(real, imag)
else:
raise ValueError(
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
)
return sweeps
def reset(self) -> None:
"""Drop all buffered state (e.g. after the collector is relaunched)."""
self._buffer.clear()
self._aligned = False
self._main.clear()
self._reference.clear()
def _align(self) -> bool:
"""Discard pre-roll up to and including the first sweep boundary.
Returns ``True`` once frame-aligned. Keeps a short tail so a boundary
split across two feeds can still be found on the next chunk.
"""
index = self._buffer.find(_START_FRAME)
if index < 0:
if len(self._buffer) >= FRAME_BYTES:
del self._buffer[: -(FRAME_BYTES - 1)]
return False
del self._buffer[: index + FRAME_BYTES]
self._main.clear()
self._reference.clear()
self._aligned = True
return True
def _take_sweep(self) -> RawSweep | None:
"""Assemble the buffered points into a sweep and reset for the next one."""
shared = sorted(self._main.keys() & self._reference.keys())
main = self._main
reference = self._reference
self._main = {}
self._reference = {}
if not shared:
return None
return RawSweep(
steps=np.asarray(shared, dtype=np.int32),
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
)
@@ -0,0 +1,358 @@
"""Service that launches the Kamil ADC 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:`KamilAdcSweepProcessor`.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
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.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.kamil_adc.protocol import RawSweep
from python_app.hardware_full.kamil_adc.tty_reader import (
KamilAdcTtyReader,
raise_if_process_exited,
)
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
logger = logging.getLogger(__name__)
# Project root, used to resolve relative collector paths (e.g.
# ``build/bin/kamil_adc_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 KamilAdcService:
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
config: RunConfigModel
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
_rejected_count: int = field(init=False, default=0, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@property
def command(self) -> list[str]:
"""External collector command, including the generated ``tty:`` argument."""
adc = self.config.radar.kamil_adc
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
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
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
try:
self._start_process()
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
reader.open()
self._reader = reader
logger.info("Kamil ADC service opened")
except Exception:
logger.exception("Kamil ADC 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 Kamil ADC service")
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
def configure(self, sweep: RadarSweepModel) -> None:
"""Build the sweep processor from the ``radar.kamil_adc`` calibration/band.
The generic ``sweep`` argument is accepted for interface parity with the
other radar services but is not used: the Kamil ADC frequency axis comes
from the reference-phase calibration, and the output grid from
``radar.kamil_adc.band`` — never from the nominal sweep bounds.
"""
self._processor = KamilAdcSweepProcessor(
KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc)
)
self._rejected_count = 0
params = self._processor.params
logger.debug(
"Kamil ADC configured: band %.6g-%.6g Hz, %d points; calibration "
"(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)",
params.band_start_hz, params.band_stop_hz, params.band_points,
params.phase0_rad, params.freq0_hz, params.phase1_rad, params.freq1_hz,
)
def read_device_limits(self) -> dict[str, float | int]:
"""Kamil ADC has no runtime-readable sweep-limit API."""
raise RuntimeError("Kamil ADC device limits are not available")
def acquire(self) -> 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`).
"""
if self._processor is None:
raise RuntimeError("Kamil ADC service is not configured")
if self._reader is None:
raise RuntimeError("Kamil ADC 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"Kamil ADC collector is not running (code={return_code})")
grid = self._processor.grid_hz
points = int(grid.size)
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
while True:
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
"Timed out waiting for a Kamil ADC sweep covering the configured band"
)
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
s21 = self._processor.process(raw.main, raw.reference)
if s21 is not None:
return SweepResult(
x=grid.copy(),
traces={
"s11": np.zeros(points, dtype=np.complex64),
"s21": s21,
},
)
self._log_rejected_sweep(raw)
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("Kamil ADC service is not open")
return self._reader.read_sweep(
timeout_s=self.config.radar.kamil_adc.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 Kamil ADC 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.DEVNULL,
stderr=subprocess.STDOUT,
start_new_session=True,
)
def _stop_process(self) -> None:
process = self._process
self._process = None
if process is None or process.poll() is not None:
return
logger.info("Stopping Kamil ADC 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.kamil_adc.stop_timeout_s, _STOP_KILL_GRACE_S)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=grace_s)
return
except subprocess.TimeoutExpired:
pass
logger.warning("Kamil ADC 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)
def _wait_for_tty(
self,
previous_identity: tuple[object, ...] | None,
*,
stop_event: threading.Event | None = None,
) -> None:
adc = self.config.radar.kamil_adc
deadline = time.monotonic() + adc.startup_timeout_s
while time.monotonic() < deadline:
if stop_event is not None and stop_event.is_set():
raise RuntimeError("Kamil ADC 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("Kamil ADC startup aborted before TTY became available")
else:
time.sleep(0.05)
raise TimeoutError(
f"Timed out waiting for Kamil ADC 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.kamil_adc.executable_path)
def _resolve_project_dir(self) -> Path:
project_dir = self.config.radar.kamil_adc.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.kamil_adc
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_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
if self.config.radar.driver_mode != "native":
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
adc = self.config.radar.kamil_adc
if not adc.executable_path:
raise ValueError("radar.kamil_adc.executable_path is required")
if not adc.tty_path:
raise ValueError("radar.kamil_adc.tty_path is required")
if any(arg.startswith("tty:") for arg in adc.args):
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
if getattr(adc, name) <= 0.0:
raise ValueError(f"radar.kamil_adc.{name} must be > 0")
project_dir = self._resolve_project_dir()
if not project_dir.is_dir():
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
executable_path = self._resolve_executable()
if not executable_path.is_file():
raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
if not os.access(executable_path, os.X_OK):
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
# Surface a malformed calibration/band at open time, not mid-acquisition.
KamilAdcProcessingParams.from_kamil_model(adc)
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
params = self._processor.params # type: ignore[union-attr]
try:
freqs = self._processor.reference_frequency_axis(raw.reference) # type: ignore[union-attr]
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
except Exception: # noqa: BLE001 — diagnostics must never raise
covered = "<unavailable>"
logger.warning(
"Kamil ADC 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, params.band_start_hz, params.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,190 @@
"""Background TTY reader publishing the latest completed Kamil ADC sweep.
The collector emits sweeps continuously, faster than callers invoke
:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the
TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, 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.kamil_adc.protocol import KamilAdcStreamParser, 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"Kamil ADC collector exited with code {return_code}")
@dataclass(slots=True)
class KamilAdcTtyReader:
"""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)
_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._reader_error = None
self._published_count = 0
self._thread = threading.Thread(
target=self._reader_loop,
name=f"kamil-adc-tty-reader[{self.tty_path}]",
daemon=True,
)
self._thread.start()
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
def close(self) -> None:
"""Stop the reader thread and close the TTY descriptor."""
logger.debug("Stopping Kamil ADC 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("Kamil ADC 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._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,
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("Kamil ADC TTY reader is not open")
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
# 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.
if self._latest_sweep is not None:
sweep = self._latest_sweep
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 Kamil ADC sweep after {float(timeout_s):.3f}s"
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
def _reader_loop(self) -> None:
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
parser = KamilAdcStreamParser()
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("Kamil ADC 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 Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
if not chunk:
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
return chunk
def _publish_sweep(self, sweep: RawSweep) -> None:
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
with self._mailbox_cv:
self._latest_sweep = sweep
self._published_count += 1
self._mailbox_cv.notify()
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()