some kamil_adc fixes
This commit is contained in:
@@ -29,6 +29,12 @@ import numpy as np
|
||||
FRAME_BYTES = 8
|
||||
MAIN_MARKER = 0x000A
|
||||
REFERENCE_MARKER = 0x00A8
|
||||
# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware
|
||||
# collector right after a sweep boundary to label the upcoming sweep with the RF
|
||||
# switch combination it was captured under (``dirty != 0`` means the sweep straddled
|
||||
# a switch transition and must be dropped). Absent in the standalone/calibration
|
||||
# collector, where every sweep carries no combo (``combo is None``).
|
||||
COMBO_MARKER = 0x00C0
|
||||
_BOUNDARY_STEP = 0xFFFF
|
||||
|
||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
||||
@@ -52,6 +58,8 @@ class RawSweep:
|
||||
steps: np.ndarray
|
||||
main: np.ndarray
|
||||
reference: np.ndarray
|
||||
combo: tuple[int, int] | None = None
|
||||
dirty: bool = False
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
@@ -66,13 +74,17 @@ class KamilAdcStreamParser:
|
||||
but holds no I/O and is cheap to unit-test.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
|
||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._aligned = False
|
||||
self._main: dict[int, complex] = {}
|
||||
self._reference: dict[int, complex] = {}
|
||||
# Combo tag for the sweep currently being accumulated (set by the combo
|
||||
# frame right after each boundary; ``None`` in non-switch collector modes).
|
||||
self._pending_combo: tuple[int, int] | None = None
|
||||
self._pending_dirty = False
|
||||
|
||||
def feed(self, data: bytes) -> list[RawSweep]:
|
||||
"""Append ``data`` and return any sweeps completed by it."""
|
||||
@@ -95,6 +107,10 @@ class KamilAdcStreamParser:
|
||||
self._main[step] = complex(real, imag)
|
||||
elif marker == REFERENCE_MARKER:
|
||||
self._reference[step] = complex(real, imag)
|
||||
elif marker == COMBO_MARKER:
|
||||
# step = input_pos, real = output_pos, imag = dirty flag.
|
||||
self._pending_combo = (int(step), int(real))
|
||||
self._pending_dirty = imag != 0
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
||||
@@ -107,6 +123,8 @@ class KamilAdcStreamParser:
|
||||
self._aligned = False
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
|
||||
def _align(self) -> bool:
|
||||
"""Discard pre-roll up to and including the first sweep boundary.
|
||||
@@ -122,6 +140,8 @@ class KamilAdcStreamParser:
|
||||
del self._buffer[: index + FRAME_BYTES]
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
self._aligned = True
|
||||
return True
|
||||
|
||||
@@ -130,12 +150,21 @@ class KamilAdcStreamParser:
|
||||
shared = sorted(self._main.keys() & self._reference.keys())
|
||||
main = self._main
|
||||
reference = self._reference
|
||||
combo = self._pending_combo
|
||||
dirty = self._pending_dirty
|
||||
self._main = {}
|
||||
self._reference = {}
|
||||
# The next sweep's combo is set by its own combo frame (right after this
|
||||
# boundary); clear so a sweep without one reports combo=None rather than
|
||||
# inheriting a stale tag.
|
||||
self._pending_combo = None
|
||||
self._pending_dirty = False
|
||||
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),
|
||||
combo=combo,
|
||||
dirty=dirty,
|
||||
)
|
||||
|
||||
@@ -47,6 +47,10 @@ _REJECT_LOG_EVERY = 50
|
||||
# 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
|
||||
# Sweeps to skip after a Python-driven switch change before trusting a capture: one
|
||||
# for the pre-switch sweep still in the mailbox, one for a possible transition
|
||||
# straddler in flight. Calibration speed is not critical, so we err on safety.
|
||||
_SWITCH_DRAIN_SWEEPS = 2
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -54,6 +58,10 @@ class KamilAdcService:
|
||||
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
|
||||
|
||||
config: RunConfigModel
|
||||
# When set, the collector is launched with ``config:<path>`` so it drives the RF
|
||||
# switches itself (switch-aware mode) from this run_config.json. ``None`` keeps
|
||||
# the standalone collector that streams a single channel (calibration / mock).
|
||||
switch_config_path: str | None = None
|
||||
_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)
|
||||
@@ -64,9 +72,18 @@ class KamilAdcService:
|
||||
|
||||
@property
|
||||
def command(self) -> list[str]:
|
||||
"""External collector command, including the generated ``tty:`` argument."""
|
||||
"""External collector command, including the generated ``tty:`` argument.
|
||||
|
||||
In switch-aware mode (``switch_config_path`` set) the collector also gets
|
||||
``config:<path>`` so it reads the switch/combo configuration and drives the
|
||||
switches in lock-step with the sweeps.
|
||||
"""
|
||||
adc = self.config.radar.kamil_adc
|
||||
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
|
||||
cmd = [str(self._resolve_executable()), *adc.args]
|
||||
if self.switch_config_path is not None:
|
||||
cmd.append(f"config:{self.switch_config_path}")
|
||||
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.
|
||||
@@ -122,12 +139,17 @@ class KamilAdcService:
|
||||
"""Kamil ADC has no runtime-readable sweep-limit API."""
|
||||
raise RuntimeError("Kamil ADC device limits are not available")
|
||||
|
||||
def acquire(self) -> SweepResult:
|
||||
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("Kamil ADC service is not configured")
|
||||
@@ -147,7 +169,10 @@ class KamilAdcService:
|
||||
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)
|
||||
if combo is None:
|
||||
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
|
||||
else:
|
||||
raw = self._reader.read_sweep_for(combo, timeout_s=remaining_s, process=process)
|
||||
s21 = self._processor.process(raw.main, raw.reference)
|
||||
if s21 is not None:
|
||||
return SweepResult(
|
||||
@@ -159,6 +184,31 @@ class KamilAdcService:
|
||||
)
|
||||
self._log_rejected_sweep(raw)
|
||||
|
||||
def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None:
|
||||
"""Discard sweeps captured before / across a just-applied switch change.
|
||||
|
||||
The collector free-runs, so right after the RF switches move the reader
|
||||
still holds a sweep captured in the *previous* combination, and a sweep that
|
||||
straddles the transition may still be in flight. Without this, the next
|
||||
:meth:`acquire` would return that stale data and the capture would be
|
||||
attributed to the wrong combination (an off-by-one across the sequence).
|
||||
|
||||
Block until ``sweeps`` freshly-published sweeps have gone by, so the next
|
||||
:meth:`acquire` returns a sweep captured entirely in the new switch state.
|
||||
Used by the Python-driven calibration capture, where the collector does not
|
||||
tag sweeps; the switch-aware collector path handles this with combo tags
|
||||
instead. No-op when the service is not open.
|
||||
"""
|
||||
if self._reader is None:
|
||||
return
|
||||
target = self._reader.published_count + max(1, int(sweeps))
|
||||
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
|
||||
while self._reader.published_count < target:
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change")
|
||||
raise_if_process_exited(self._process)
|
||||
time.sleep(0.005)
|
||||
|
||||
def read_raw_sweep(self) -> RawSweep:
|
||||
"""Return the next raw (main, reference) sweep without any processing.
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ class KamilAdcTtyReader:
|
||||
_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); read_sweep_for() serves a specific combination.
|
||||
_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)
|
||||
|
||||
@@ -62,6 +66,7 @@ class KamilAdcTtyReader:
|
||||
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(
|
||||
@@ -89,6 +94,7 @@ class KamilAdcTtyReader:
|
||||
finally:
|
||||
self._fd = None
|
||||
self._latest_sweep = None
|
||||
self._combo_slots = {}
|
||||
self._reader_error = None
|
||||
|
||||
@property
|
||||
@@ -131,6 +137,39 @@ class KamilAdcTtyReader:
|
||||
)
|
||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||
|
||||
def read_sweep_for(
|
||||
self,
|
||||
combo: tuple[int, int],
|
||||
*,
|
||||
timeout_s: float,
|
||||
process: subprocess.Popen[bytes] | None = None,
|
||||
) -> RawSweep:
|
||||
"""Wait for and return the latest clean sweep for ``combo``.
|
||||
|
||||
Used in switch-aware mode, where the collector drives the switches and tags
|
||||
each sweep with its combination. Only clean sweeps are delivered (the reader
|
||||
thread drops the dirty ones); the slot is consumed on read so each caller
|
||||
gets a fresh capture. Raises like :meth:`read_sweep`.
|
||||
"""
|
||||
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:
|
||||
sweep = self._combo_slots.pop(combo, None)
|
||||
if sweep is not 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 for combo {combo} "
|
||||
f"after {float(timeout_s):.3f}s"
|
||||
)
|
||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reader-thread internals
|
||||
# ------------------------------------------------------------------
|
||||
@@ -177,11 +216,22 @@ class KamilAdcTtyReader:
|
||||
return chunk
|
||||
|
||||
def _publish_sweep(self, sweep: RawSweep) -> None:
|
||||
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
|
||||
"""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._latest_sweep = sweep
|
||||
self._published_count += 1
|
||||
self._mailbox_cv.notify()
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user