some fixes and improvements

This commit is contained in:
Ayzen
2026-05-28 14:33:12 +03:00
parent 83a934f251
commit eacea436a4
29 changed files with 2114 additions and 424 deletions
+12 -14
View File
@@ -169,14 +169,15 @@ class AppWindow(
def _init_history_state(self) -> None:
"""Initialize runtime history buffers and render-cache state."""
history_limit = self._history_limit_from_config()
self._raw_history: deque[SweepCollection] = deque(maxlen=history_limit)
self._pre_history: deque[SweepCollection] = deque(maxlen=history_limit)
self._result_history: deque[ResultCollection] = deque(maxlen=history_limit)
bscan_history_limit = self._history_limit_from_config()
save_history_limit = self._save_history_limit_from_config()
self._raw_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
self._pre_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
self._result_history: deque[ResultCollection] = deque(maxlen=save_history_limit)
# Sequence id must survive GUI restarts so history commands stay monotonic.
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
self._bscan_history_limit = history_limit
self._bscan_history_limit = bscan_history_limit
self._bscan_history_by_combo = {}
self._bscan_depth_axis_by_combo = {}
self._bscan_history_floor_collection_id = 0
@@ -200,15 +201,12 @@ class AppWindow(
self._radar_limits: dict[str, float | int] | None = None
def _history_limit_from_config(self) -> int:
"""Return unified GUI history limit derived from configured ring capacities."""
return max(
1,
min(
int(self._defaults_config.rings.raw_tap.capacity),
int(self._defaults_config.rings.preprocessed_tap.capacity),
int(self._defaults_config.rings.results.capacity),
),
)
"""Return B-scan render history limit derived from configured ring capacities."""
return self._history_limit_for_config(self._defaults_config)
def _save_history_limit_from_config(self) -> int:
"""Return maxlen for GUI snapshot-save deques (independent of ring capacities)."""
return self._save_history_limit_for_config(self._defaults_config)
def _init_runtime_limits(self) -> None:
"""Initialize read/drain loop limits used by polling and snapshot code."""
@@ -81,6 +81,10 @@ class AppWindowLiveProcessingMixin:
gpr_draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
gpr_speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
gpr_look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
gpr_apply_freq_phase_correction=bool(
self._legacy_gpr_apply_freq_phase_correction.isChecked()
),
gpr_reference_mode=self._legacy_gpr_reference_mode.currentText(),
gpr_snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
gpr_snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()),
gpr_start_freq_mhz=gpr_start_freq_mhz,
@@ -117,12 +117,19 @@ class AppWindowConfigProfileIOMixin:
self._optical_variation_panel.setVisible(mode == "variation")
def _apply_history_limit_from_config(self, config) -> None:
"""Resize in-memory history buffers to match the loaded config."""
history_limit = self._history_limit_for_config(config)
self._raw_history = deque(self._raw_history, maxlen=history_limit)
self._pre_history = deque(self._pre_history, maxlen=history_limit)
self._result_history = deque(self._result_history, maxlen=history_limit)
self._bscan_history_limit = history_limit
"""Resize in-memory history buffers to match the loaded config.
Save-side deques use a config-independent limit so that processing-side
ring capacities can stay small without truncating the save buffer. The
B-scan render limit still follows ring capacities to keep plot updates
responsive.
"""
save_history_limit = self._save_history_limit_for_config(config)
bscan_history_limit = self._history_limit_for_config(config)
self._raw_history = deque(self._raw_history, maxlen=save_history_limit)
self._pre_history = deque(self._pre_history, maxlen=save_history_limit)
self._result_history = deque(self._result_history, maxlen=save_history_limit)
self._bscan_history_limit = bscan_history_limit
self._clear_bscan_plot_history()
def _save_current_config(self) -> None:
@@ -283,6 +290,8 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_speed_m_s,
self._legacy_gpr_ignore_socket_speed_enabled,
self._legacy_gpr_look_angle_deg,
self._legacy_gpr_apply_freq_phase_correction,
self._legacy_gpr_reference_mode,
self._legacy_gpr_background_subtract_enabled,
self._legacy_gpr_background_mean_count,
self._legacy_gpr_render_mode,
@@ -451,6 +460,13 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(ignore_socket_speed_enabled)
self._legacy_gpr_speed_m_s.setEnabled(ignore_socket_speed_enabled)
self._legacy_gpr_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
self._legacy_gpr_apply_freq_phase_correction.setChecked(
bool(gui_state.processing.legacy_gpr.apply_freq_phase_correction)
)
self._set_combo_current_text(
self._legacy_gpr_reference_mode,
gui_state.processing.legacy_gpr.reference_mode,
)
self._legacy_gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.background_subtract_enabled)
)
@@ -27,6 +27,14 @@ from python_app.orchestration.preprocess_assets import (
from python_app.storage.npz_store import radar_key_from_config
# GUI snapshot-save deques (`_raw_history`, `_pre_history`, `_result_history`)
# are intentionally decoupled from SHM ring capacities: rings are sized for the
# C++ processing pipeline, while save buffers only retain what the GUI poll
# loop already read out. Growing this number lets the user save more recent
# history without touching the processing-side ring sizes.
GUI_SAVE_HISTORY_LIMIT: int = 1000
class AppWindowConfigStateBuildersMixin:
"""Build stable and GUI-only config models from current widget state."""
@@ -156,7 +164,7 @@ class AppWindowConfigStateBuildersMixin:
@staticmethod
def _history_limit_for_config(config: RunConfigModel) -> int:
"""Return unified GUI history limit derived from config ring capacities."""
"""Return B-scan render history limit derived from config ring capacities."""
return max(
1,
min(
@@ -166,6 +174,18 @@ class AppWindowConfigStateBuildersMixin:
),
)
@staticmethod
def _save_history_limit_for_config(config: RunConfigModel) -> int:
"""Return maxlen for GUI snapshot-save deques.
Independent of ring capacities see :data:`GUI_SAVE_HISTORY_LIMIT`.
The `config` argument is kept for symmetry with
:meth:`_history_limit_for_config` and possible future per-profile
overrides.
"""
del config
return GUI_SAVE_HISTORY_LIMIT
def _default_gui_state_for_config(self, config: RunConfigModel) -> GuiStateModel:
"""Build fallback GUI-only defaults for a stable run config."""
default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0)
@@ -352,6 +372,10 @@ class AppWindowConfigStateBuildersMixin:
speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
ignore_socket_speed_enabled=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
apply_freq_phase_correction=bool(
self._legacy_gpr_apply_freq_phase_correction.isChecked()
),
reference_mode=self._legacy_gpr_reference_mode.currentText(),
snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()),
background_subtract_enabled=bool(self._legacy_gpr_background_subtract_enabled.isChecked()),
@@ -355,7 +355,8 @@ class AppWindowPipelineMixin:
def _read_all_raw(self) -> SweepCollection | None:
"""Read available raw collections from raw ring."""
assert self._raw_reader is not None
if self._raw_reader is None:
raise RuntimeError("Raw ring reader is not initialised")
latest: SweepCollection | None = None
for _ in range(self._max_pop_per_poll):
collection = self._raw_reader.pop_raw_collection()
@@ -388,7 +389,8 @@ class AppWindowPipelineMixin:
def _read_all_results(self) -> ResultCollection | None:
"""Read available result collections from results ring."""
assert self._result_reader is not None
if self._result_reader is None:
raise RuntimeError("Result ring reader is not initialised")
latest: ResultCollection | None = None
for _ in range(self._max_pop_per_poll):
collection = self._result_reader.pop_result_collection()
@@ -403,6 +403,22 @@ def build_processing_group(owner) -> QGroupBox:
owner._legacy_gpr_look_angle_deg.setSingleStep(0.1)
owner._legacy_gpr_look_angle_deg.setValue(float(legacy_gpr_defaults.look_angle_deg))
# Intra-sweep phase compensation toggle. When checked, S21 phase is
# corrected per frequency point before the IFFT so the radar's motion
# during one sweep is removed at the spectrum stage. Only matters when
# speed != 0; otherwise has no effect by construction.
owner._legacy_gpr_apply_freq_phase_correction = QCheckBox("Apply intra-sweep phase correction")
owner._legacy_gpr_apply_freq_phase_correction.setChecked(
bool(legacy_gpr_defaults.apply_freq_phase_correction)
)
# Anchor point for the motion model's per-event `dt_ref`. `frame_center`
# spreads motion symmetrically around the frame midpoint; `first_tx_event`
# accumulates it forward from the first event center.
owner._legacy_gpr_reference_mode = QComboBox()
owner._legacy_gpr_reference_mode.addItems(["frame_center", "first_tx_event"])
owner._set_combo_current_text(owner._legacy_gpr_reference_mode, legacy_gpr_defaults.reference_mode)
owner._legacy_gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections")
owner._legacy_gpr_background_subtract_enabled.setChecked(bool(legacy_gpr_defaults.background_subtract_enabled))
@@ -460,6 +476,8 @@ def build_processing_group(owner) -> QGroupBox:
("Start MHz", owner._legacy_gpr_start_freq_mhz),
("Stop MHz", owner._legacy_gpr_stop_freq_mhz),
("Look angle deg", owner._legacy_gpr_look_angle_deg),
("Reference mode", owner._legacy_gpr_reference_mode),
owner._legacy_gpr_apply_freq_phase_correction,
("Visible X min m", owner._legacy_gpr_visible_x_min_m),
("Visible X max m", owner._legacy_gpr_visible_x_max_m),
("Visible Z min m", owner._legacy_gpr_visible_z_min_m),
@@ -526,6 +544,8 @@ def build_processing_group(owner) -> QGroupBox:
owner._on_legacy_gpr_ignore_socket_speed_toggled
)
owner._legacy_gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_apply_freq_phase_correction.toggled.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_reference_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._legacy_gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
+235 -203
View File
@@ -1,4 +1,22 @@
"""Service for acquiring sweeps from the external Kamil ADC collector."""
"""Service for acquiring sweeps from the external Kamil ADC collector.
The external `kamil_adc` binary publishes its samples on a PTY/TTY device as a
stream of 8-byte frames:
* **Start marker**: `0x000A 0xFFFF 0xFFFF 0xFFFF` delimits sweep boundaries.
* **Point frame**: `0x000A step real_i16 imag_i16` one complex sample per
frame, with `step` running 1, 2, , N for an N-point sweep.
The hardware emits sweeps continuously, faster than callers tend to invoke
:meth:`KamilAdcService.acquire`. To avoid TTY-buffer overruns and stale data,
a daemon thread drains the device end of the TTY non-stop, parses complete
sweeps as they arrive, and publishes the **latest** one to a one-slot mailbox.
:meth:`acquire` simply waits for the next sweep to appear in that mailbox.
Sweep length is determined by the first sweep observed at runtime and stays
constant for the life of the service; any later mismatch is treated as a
protocol violation rather than something to silently discard.
"""
from __future__ import annotations
@@ -13,6 +31,7 @@ import signal
import stat
import struct
import subprocess
import threading
import time
import numpy as np
@@ -22,233 +41,255 @@ from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
logger = logging.getLogger(__name__)
# Wire-format constants for the Kamil ADC TTY protocol.
KAMIL_ADC_MARKER = 0x000A
KAMIL_ADC_START_STEP = 0xFFFF
KAMIL_ADC_FRAME_BYTES = 8
KAMIL_ADC_MAX_STEP = 0xFFFE
_RAW_FRAME_STRUCT = struct.Struct("<HHHH")
_POINT_FRAME_STRUCT = struct.Struct("<HHhh")
_START_FRAME = _RAW_FRAME_STRUCT.pack(
KAMIL_ADC_MARKER,
KAMIL_ADC_START_STEP,
KAMIL_ADC_START_STEP,
KAMIL_ADC_START_STEP,
_START_FRAME: bytes = struct.pack(
"<HHHH", KAMIL_ADC_MARKER, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP
)
# Point frames carry signed 16-bit real/imag components; start markers reuse
# the same 8-byte slot but with all four words unsigned. Comparing the raw
# bytes against :data:`_START_FRAME` is therefore the correct boundary check.
_POINT_STRUCT = struct.Struct("<HHhh")
# Larger TTY reads keep up with bursty USB CDC-ACM writers without raising the
# syscall rate. 64 KiB matches the typical Linux PTY buffer size.
_READ_CHUNK_BYTES = 65536
# select() poll interval inside the reader thread — short enough to react to
# `close()` requests, long enough that idle CPU stays near zero.
_READ_POLL_INTERVAL_S = 0.1
class KamilAdcFrameParser:
"""Strict parser for Kamil ADC 4-word TTY frames."""
@staticmethod
def is_packet_start(frame: bytes) -> bool:
"""Return whether `frame` is the packet-start marker."""
return frame == _START_FRAME
@staticmethod
def parse_point(frame: bytes, expected_step: int) -> complex:
"""Parse one `0x000A step real imag` frame and validate ordering."""
if len(frame) != KAMIL_ADC_FRAME_BYTES:
raise ValueError(
f"Kamil ADC frame must be {KAMIL_ADC_FRAME_BYTES} bytes, got {len(frame)}"
)
marker, step, real, imag = _POINT_FRAME_STRUCT.unpack(frame)
if marker != KAMIL_ADC_MARKER:
raise ValueError(f"Kamil ADC marker mismatch: got 0x{marker:04x}, expected 0x000a")
if step != expected_step:
raise ValueError(f"Kamil ADC step mismatch: got {step}, expected {expected_step}")
return complex(real, imag)
def _parse_point_frame(frame: bytes, expected_step: int) -> complex:
"""Parse one 8-byte point frame; validate marker and step ordering."""
marker, step, real, imag = _POINT_STRUCT.unpack(frame)
if marker != KAMIL_ADC_MARKER:
raise ValueError(f"Kamil ADC marker mismatch: got 0x{marker:04x}, expected 0x000a")
if step != expected_step:
raise ValueError(f"Kamil ADC step mismatch: got {step}, expected {expected_step}")
return complex(real, imag)
@dataclass(slots=True)
class KamilAdcTtyReader:
"""Read full Kamil ADC sweep packets from a nonblocking TTY stream."""
"""Background-thread TTY reader publishing the latest completed sweep.
The reader spawns a daemon thread on :meth:`open` which continuously
drains the TTY, parses frames into complete sweeps, and stores the most
recent one in a single-slot mailbox. Consumers call :meth:`read_sweep` to
take that sweep; if a newer one arrives before the consumer reads, it
overwrites the previous unread value by design, since consumers always
want the freshest data.
"""
tty_path: str
_fd: int | None = field(init=False, default=None, repr=False)
_buffer: bytearray = field(init=False, default_factory=bytearray, repr=False)
_packet_start_pending: bool = field(init=False, default=False, 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: np.ndarray | None = field(init=False, default=None, repr=False)
_reader_error: Exception | None = field(init=False, default=None, repr=False)
_locked_points: int | None = field(init=False, default=None, repr=False)
_published_count: int = field(init=False, default=0, repr=False)
def open(self) -> None:
"""Open the configured TTY path for binary reads."""
"""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._locked_points = 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()
def close(self) -> None:
"""Close the TTY file descriptor."""
if self._fd is None:
return
try:
os.close(self._fd)
finally:
self._fd = None
self._buffer.clear()
self._packet_start_pending = False
"""Stop the reader thread and close the TTY descriptor."""
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)
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
self._locked_points = None
@property
def locked_points(self) -> int | None:
"""Return the sweep point count established by the first sweep, or `None`."""
return self._locked_points
@property
def published_count(self) -> int:
"""Return the 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,
expected_points: int | None = None,
) -> np.ndarray:
"""Read one full packet, optionally discarding packets with an unexpected point count."""
if self._fd is None:
"""Wait for and return the next published sweep.
Raises :class:`TimeoutError` if no sweep arrives within `timeout_s`,
:class:`RuntimeError` if the external collector process exited, and
propagates any exception caught by the reader thread.
"""
if self._thread is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
if expected_points is not None:
if expected_points <= 0:
raise ValueError("Kamil ADC expected points must be > 0")
if expected_points > KAMIL_ADC_MAX_STEP:
raise ValueError(f"Kamil ADC expected points must be <= {KAMIL_ADC_MAX_STEP}")
deadline = time.monotonic() + float(timeout_s)
while True:
values = self._read_one_sweep(deadline, process)
if expected_points is None or int(values.size) == int(expected_points):
return values
logger.warning(
"Discarding Kamil ADC sweep with %d points; expected %d",
int(values.size),
int(expected_points),
)
with self._mailbox_cv:
while True:
# Always deliver a pending sweep first: if the reader thread
# both published a sweep and then died, the consumer should
# still see the good data and only meet 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
self._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"
)
# Wake periodically so we can re-check process liveness.
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
def _read_one_sweep(
self,
deadline: float,
process: subprocess.Popen[bytes] | None,
) -> np.ndarray:
"""Read one packet from start marker to the next start marker."""
if self._packet_start_pending:
self._packet_start_pending = False
else:
self._read_until_packet_start(deadline, process)
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
def _reader_loop(self) -> None:
"""Drain TTY → parse frames → publish completed sweeps until stop."""
buffer = bytearray()
try:
if not self._skip_to_first_start_marker(buffer):
return
while not self._stop_event.is_set():
sweep = self._read_one_sweep(buffer)
if sweep is None:
return
self._publish_sweep(sweep)
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
self._publish_error(exc)
def _skip_to_first_start_marker(self, buffer: bytearray) -> bool:
"""Discard pre-roll bytes until a start marker is consumed from `buffer`."""
while not self._stop_event.is_set():
start_index = buffer.find(_START_FRAME)
if start_index >= 0:
del buffer[: start_index + KAMIL_ADC_FRAME_BYTES]
return True
# Keep just enough trailing bytes that a marker split across read
# boundaries can still be reassembled on the next chunk.
if len(buffer) >= KAMIL_ADC_FRAME_BYTES:
del buffer[: -(KAMIL_ADC_FRAME_BYTES - 1)]
if not self._read_more(buffer):
return False
return False
def _read_one_sweep(self, buffer: bytearray) -> np.ndarray | None:
"""Parse frames from `buffer` until the next start marker; return the sweep."""
values: list[complex] = []
expected_step = 1
while True:
frame = self._read_frame(deadline, process, received_points=len(values))
if KamilAdcFrameParser.is_packet_start(frame):
while not self._stop_event.is_set():
while len(buffer) < KAMIL_ADC_FRAME_BYTES:
if not self._read_more(buffer):
return None
frame = bytes(buffer[:KAMIL_ADC_FRAME_BYTES])
del buffer[:KAMIL_ADC_FRAME_BYTES]
if frame == _START_FRAME:
if not values:
# Two consecutive markers — ignore the empty sweep and keep parsing.
continue
self._packet_start_pending = True
self._validate_and_lock_point_count(len(values))
return np.asarray(values, dtype=np.complex64)
if expected_step > KAMIL_ADC_MAX_STEP:
raise RuntimeError(f"Kamil ADC sweep exceeded {KAMIL_ADC_MAX_STEP} points without packet end")
values.append(KamilAdcFrameParser.parse_point(frame, expected_step))
if self._locked_points is not None and expected_step > self._locked_points:
raise RuntimeError(
f"Kamil ADC sweep exceeded locked point count {self._locked_points} "
"without a start marker"
)
values.append(_parse_point_frame(frame, expected_step))
expected_step += 1
return None
def discard_pending(self, process: subprocess.Popen[bytes] | None = None) -> None:
"""Discard stale bytes while keeping the newest packet-start boundary."""
if self._fd is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
self._buffer.clear()
self._packet_start_pending = False
fd = self._require_fd()
while True:
self._raise_if_process_exited(process)
def _validate_and_lock_point_count(self, points: int) -> None:
"""Lock the point count on the first sweep; reject mismatches thereafter."""
if self._locked_points is None:
self._locked_points = points
logger.info("Kamil ADC sweep point count locked to %d", points)
return
if points != self._locked_points:
raise RuntimeError(
f"Kamil ADC sweep length changed: locked={self._locked_points}, got={points}"
)
def _read_more(self, buffer: bytearray) -> bool:
"""Block on `select` until bytes arrive, then append them to `buffer`.
Returns `False` if the reader was asked to stop, `True` if at least one
byte was appended. Raises on stream-level errors.
"""
fd = self._fd
if fd is None:
return False
while not self._stop_event.is_set():
try:
readable, _, _ = select.select([fd], [], [], 0.0)
readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S)
except InterruptedError:
continue
if not readable:
return
continue
try:
chunk = os.read(fd, 4096)
chunk = os.read(fd, _READ_CHUNK_BYTES)
except BlockingIOError:
return
continue
except OSError as exc:
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
return
raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
continue
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 draining")
self._buffer.extend(chunk)
self._keep_latest_packet_start_tail()
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
buffer.extend(chunk)
return True
return False
def _keep_latest_packet_start_tail(self) -> None:
"""Keep only bytes from the latest complete packet-start marker onward."""
start_index = self._buffer.rfind(_START_FRAME)
if start_index >= 0:
del self._buffer[:start_index]
return
if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES:
del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1]
def _publish_sweep(self, sweep: np.ndarray) -> None:
"""Store `sweep` as the latest mailbox value, overwriting any prior unread one."""
with self._mailbox_cv:
self._latest_sweep = sweep
self._published_count += 1
self._mailbox_cv.notify()
def _read_until_packet_start(
self,
deadline: float,
process: subprocess.Popen[bytes] | None,
) -> None:
while True:
start_index = self._buffer.find(_START_FRAME)
if start_index >= 0:
del self._buffer[: start_index + KAMIL_ADC_FRAME_BYTES]
return
if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES:
del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1]
self._read_available(deadline, process)
def _read_frame(
self,
deadline: float,
process: subprocess.Popen[bytes] | None,
*,
received_points: int,
expected_points: int | None = None,
) -> bytes:
while len(self._buffer) < KAMIL_ADC_FRAME_BYTES:
self._read_available(deadline, process, received_points, expected_points)
frame = bytes(self._buffer[:KAMIL_ADC_FRAME_BYTES])
del self._buffer[:KAMIL_ADC_FRAME_BYTES]
return frame
def _read_available(
self,
deadline: float,
process: subprocess.Popen[bytes] | None,
received_points: int | None = None,
expected_points: int | None = None,
) -> None:
self._raise_if_process_exited(process)
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
if received_points is None or expected_points is None:
if received_points is not None:
raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep end: received {received_points} points"
)
raise TimeoutError("Timed out waiting for Kamil ADC packet-start marker")
raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points"
)
fd = self._require_fd()
wait_s = min(0.05, remaining_s)
try:
readable, _, _ = select.select([fd], [], [], wait_s)
except InterruptedError:
return
if not readable:
return
try:
chunk = os.read(fd, 4096)
except BlockingIOError:
return
except OSError as exc:
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
return
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")
self._buffer.extend(chunk)
def _require_fd(self) -> int:
if self._fd is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
return self._fd
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()
@staticmethod
def _raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
@@ -261,14 +302,13 @@ class KamilAdcTtyReader:
@dataclass(slots=True)
class KamilAdcService:
"""Launch `kamil_adc` and acquire TTY sweeps."""
"""Launch the external `kamil_adc` collector and serve its 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)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
_expected_points: int | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@@ -281,10 +321,9 @@ class KamilAdcService:
return [executable_path, *adc.args, f"tty:{adc.tty_path}"]
def open(self) -> None:
"""Launch the collector and open its TTY stream."""
"""Launch the collector and start the TTY reader thread."""
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()
@@ -297,50 +336,40 @@ class KamilAdcService:
raise
def close(self) -> None:
"""Close TTY and stop the external collector."""
"""Stop the TTY reader and the external collector process."""
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store sweep settings used to construct the synthetic frequency axis."""
"""Store sweep settings used to build the synthetic frequency axis."""
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = None
self._expected_points = None
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:
"""Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros."""
"""Return the most recent completed sweep as S21 (S11 filled with zeros)."""
if self._settings 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:
code = None if process is None else process.poll()
raise RuntimeError(f"Kamil ADC process is not running (code={code})")
return_code = None if process is None else process.poll()
raise RuntimeError(f"Kamil ADC process is not running (code={return_code})")
self._reader.discard_pending(process)
s21 = self._reader.read_sweep(
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
process=process,
expected_points=self._expected_points,
)
points = int(s21.size)
if points <= 0:
raise RuntimeError("Kamil ADC sweep contained no points")
if self._expected_points is None:
self._expected_points = points
self._frequency_hz = self._build_frequency_axis(points)
logger.info("Kamil ADC sweep point count locked to %d", points)
if self._frequency_hz is None:
if self._frequency_hz is None or self._frequency_hz.size != points:
self._frequency_hz = self._build_frequency_axis(points)
return SweepResult(
x=self._frequency_hz.copy(),
@@ -350,10 +379,13 @@ class KamilAdcService:
},
)
# ------------------------------------------------------------------
# Process / TTY lifecycle
# ------------------------------------------------------------------
def _start_process(self) -> None:
if self._process is not None and self._process.poll() is None:
return
adc = self.config.radar.kamil_adc
env = os.environ.copy()
env.update(adc.env)
@@ -371,11 +403,8 @@ class KamilAdcService:
def _stop_process(self) -> None:
process = self._process
self._process = None
if process is None:
if process is None or process.poll() is not None:
return
if process.poll() is not None:
return
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
@@ -383,7 +412,6 @@ class KamilAdcService:
return
except subprocess.TimeoutExpired:
pass
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=1.0)
@@ -401,6 +429,10 @@ class KamilAdcService:
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
)
# ------------------------------------------------------------------
# Validation helpers
# ------------------------------------------------------------------
def _validate_config(self) -> None:
if not self.config.is_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
@@ -216,11 +216,13 @@ class MultiDeviceVnaController:
def _configure_reference_clocks(self) -> None:
for device_connection in self._all_devices:
# 1 s ACK timeout plus one retry caps worst-case at ~2 s per device
# so a stuck reference apply cannot stall recovery for minutes.
self._send_command_and_wait_for_acknowledgement(
device_connection,
PacketType.REFERENCE_SETTINGS,
build_reference_settings_payload(0, self._force_external_reference),
timeout_seconds=3.0,
timeout_seconds=1.0,
retry_count=1,
)
@@ -241,6 +243,8 @@ class MultiDeviceVnaController:
(self._master_device, True),
]
for device_connection, is_synchronization_master in sweep_configuration_commands:
# 1 s ACK timeout plus one retry caps worst-case at ~2 s per device
# so a stuck sweep apply cannot stall recovery for minutes.
self._send_command_and_wait_for_acknowledgement(
device_connection,
PacketType.SWEEP_SETTINGS,
@@ -250,7 +254,7 @@ class MultiDeviceVnaController:
synchronization_enabled=self._synchronization_enabled,
master_stimulus_ports=master_stimulus_ports,
),
timeout_seconds=3.0,
timeout_seconds=1.0,
retry_count=1,
)
self._last_applied_sweep_configuration = replace(sweep_configuration)
@@ -23,6 +23,19 @@ from python_app.hardware_full.librevna_multi_device_driver.transport import Libr
LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS = 1.5
# Hard upper bound on how long one full sweep cycle is allowed to take from
# the moment the collection thread enters its loop. Even a device that keeps
# streaming valid-looking datapoints will be abandoned once this deadline
# elapses, so the caller's recovery loop can re-open it instead of waiting
# forever. Kept comfortably above the worst real-world cycle (≈ points / IFBW).
_MAX_FULL_CYCLE_SECONDS = 8.0
# Maximum time we let collection threads linger after `stop_collection_requested`
# has been set. They are all daemon threads and self-poll the flag every
# ~0.2 s, so a 2 s grace period is generous. Past this point we stop joining
# and let the orphan thread die when the producer process exits.
_THREAD_JOIN_TIMEOUT_SECONDS = 2.0
def collect_complete_running_sweep_cycles(
*,
@@ -82,8 +95,13 @@ def collect_complete_running_sweep_cycles(
) -> None:
datapoints_received = 0
expected_datapoint_count = cycle_count * point_count
last_datapoint_timestamp = time.monotonic()
collection_loop_start = last_datapoint_timestamp
loop_start_timestamp = time.monotonic()
# Tracks the last time we actually accepted a datapoint into the cycle.
# Crucially, *not* updated on rejected datapoints — a device that keeps
# streaming valid-looking frames the handler ignores (e.g. waiting on
# point_index=0, or after cycle_count has been reached) must still hit
# the per-device timeout and trigger recovery instead of looping forever.
last_consumed_timestamp = loop_start_timestamp
has_consumed_any_datapoint = False
while datapoints_received < expected_datapoint_count:
@@ -91,19 +109,19 @@ def collect_complete_running_sweep_cycles(
return
now = time.monotonic()
remaining_timeout_seconds = (last_datapoint_timestamp + datapoint_timeout_seconds) - now
remaining_timeout_seconds = (last_consumed_timestamp + datapoint_timeout_seconds) - now
if remaining_timeout_seconds <= 0:
collection_errors.append(
TimeoutError(
f"No datapoints from {device_connection.serial_number} for "
f"No usable datapoints from {device_connection.serial_number} for "
f"{datapoint_timeout_seconds:.1f} s "
f"(received {datapoints_received}/{point_count})"
f"(received {datapoints_received}/{expected_datapoint_count})"
)
)
stop_collection_requested.set()
return
if not has_consumed_any_datapoint and (now - collection_loop_start) > cycle_start_guard_seconds:
if not has_consumed_any_datapoint and (now - loop_start_timestamp) > cycle_start_guard_seconds:
collection_errors.append(
TimeoutError(
f"Device {device_connection.serial_number} streamed datapoints but never "
@@ -114,6 +132,22 @@ def collect_complete_running_sweep_cycles(
stop_collection_requested.set()
return
# Hard wallclock deadline for the entire cycle. Even if every
# datapoint refreshes `last_consumed_timestamp` and the per-packet
# timeout never trips, we still bail out once the cycle has dragged
# on for too long — this is the safety net the per-device timeout
# cannot provide by itself.
if (now - loop_start_timestamp) > _MAX_FULL_CYCLE_SECONDS:
collection_errors.append(
TimeoutError(
f"Device {device_connection.serial_number} did not finish a sweep cycle "
f"within {_MAX_FULL_CYCLE_SECONDS:.1f} s "
f"(received {datapoints_received}/{expected_datapoint_count})"
)
)
stop_collection_requested.set()
return
try:
packet_type, payload = device_connection.receive_packet(
timeout_seconds=min(1.0, remaining_timeout_seconds)
@@ -136,9 +170,11 @@ def collect_complete_running_sweep_cycles(
parsed_datapoint = parse_vna_datapoint_payload(payload)
if parsed_datapoint and 0 <= parsed_datapoint.point_index < point_count:
last_datapoint_timestamp = time.monotonic()
datapoint_was_consumed = handle_datapoint(parsed_datapoint)
if datapoint_was_consumed:
# Only refreshed on accepted datapoints so the no-progress
# timeout above stays honest about real cycle progress.
last_consumed_timestamp = time.monotonic()
has_consumed_any_datapoint = True
datapoint_counts_by_device_serial[device_connection.serial_number] += 1
datapoints_received += 1
@@ -269,8 +305,35 @@ def collect_complete_running_sweep_cycles(
for collection_thread in collection_threads:
collection_thread.start()
# Bounded join. Threads self-poll `stop_collection_requested` at most every
# ~0.2 s (the queue.get timeout inside `receive_packet`), so a 2 s grace
# period is more than enough for a cooperative shutdown. Anything still
# alive after that is treated as an orphan: we set the flag a second time,
# record an error so callers go through recovery, and stop waiting. The
# thread is a daemon and will die with the producer process.
deadline = time.monotonic() + _THREAD_JOIN_TIMEOUT_SECONDS
for collection_thread in collection_threads:
collection_thread.join()
remaining_seconds = deadline - time.monotonic()
collection_thread.join(timeout=max(0.0, remaining_seconds))
stalled_threads = [
collection_thread for collection_thread in collection_threads if collection_thread.is_alive()
]
if stalled_threads:
stop_collection_requested.set()
# Give them one more short window in case they were just slow to react.
secondary_deadline = time.monotonic() + 0.5
for stalled_thread in stalled_threads:
stalled_thread.join(timeout=max(0.0, secondary_deadline - time.monotonic()))
still_stalled = [stalled_thread for stalled_thread in stalled_threads if stalled_thread.is_alive()]
if still_stalled:
collection_errors.append(
RuntimeError(
"Sweep collector thread(s) failed to stop within the join deadline: "
+ ", ".join(stalled_thread.name for stalled_thread in still_stalled)
)
)
if collection_errors:
raise RuntimeError(f"Sweep collection failed: {collection_errors[0]}") from collection_errors[0]
@@ -20,6 +20,12 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Delays applied between successive USB reopen attempts inside recover(). Picked
# to give libusb time to re-enumerate a stuck device while staying short enough
# that a healthy reconnect feels instant. The total worst-case wait is the sum
# of all entries (1.75 s today) plus the cost of close()/open() themselves.
_REOPEN_BACKOFF_SECONDS: tuple[float, ...] = (0.25, 0.5, 1.0)
_INPUT_S_PARAMETERS_BY_OUTPUT: dict[int, tuple[str, ...]] = {
0: ("s31", "s41", "s51", "s61"),
1: ("s32", "s42", "s52", "s62"),
@@ -77,18 +83,60 @@ class MultiDeviceLibreVnaService:
self._controller = None
def close(self) -> None:
"""Close native device transports."""
if self._controller is not None:
self._controller.close()
self._controller = None
"""Close native device transports; never raises.
Recovery loops rely on `close()` being safe to call on a half-open or
already-broken controller. We swallow any transport-level exception here
and just drop the reference so the next `open()` starts fresh.
"""
controller = self._controller
self._controller = None
if controller is None:
return
try:
controller.close()
except Exception as exc: # noqa: BLE001 — recovery path, never propagate
logger.warning("Multi-device close() ignored transport error: %s", exc)
def recover(self) -> None:
"""Reopen native device transports after a failed acquisition."""
"""Reopen native device transports after a failed acquisition.
Tries several short backoffs so a transient USB stall does not kill the
producer on the very first retry. Raises the last error only after
every attempt failed the outer acquisition loop is expected to count
these as recovery_attempts.
"""
if self._using_mock_backend:
return
self.close()
time.sleep(0.25)
self.open()
last_error: Exception | None = None
for attempt_index, delay_s in enumerate(_REOPEN_BACKOFF_SECONDS, start=1):
time.sleep(delay_s)
try:
self.open()
if self._controller is not None:
logger.info(
"Multi-device reopen succeeded on attempt %d/%d (after %.2fs)",
attempt_index,
len(_REOPEN_BACKOFF_SECONDS),
delay_s,
)
return
except Exception as exc: # noqa: BLE001 — propagate only the last failure
last_error = exc
logger.warning(
"Multi-device reopen attempt %d/%d failed after %.2fs: %s",
attempt_index,
len(_REOPEN_BACKOFF_SECONDS),
delay_s,
exc,
)
self.close() # tidy partially-opened state before next try
if last_error is not None:
raise last_error
raise RuntimeError("Multi-device recover() exhausted all reopen attempts")
def configure(self, sweep: RadarSweepModel) -> None:
"""Store sweep settings for subsequent full-matrix acquisitions."""
@@ -132,7 +180,21 @@ class MultiDeviceLibreVnaService:
exc,
exc_info=True,
)
self.recover()
# recover() may itself fail when libusb cannot re-enumerate the
# device fast enough; treat that as the same kind of recovery
# attempt and try again on the next loop iteration, so a
# transient USB hiccup cannot kill the whole producer.
try:
self.recover()
except Exception as recover_exc: # noqa: BLE001
last_error = recover_exc
logger.warning(
"multi-device recover() failed (%d/%d): %s",
attempt_index + 1,
self.recovery_attempts,
recover_exc,
exc_info=True,
)
assert last_error is not None
raise last_error
+9 -1
View File
@@ -10,12 +10,15 @@ synchronized SCPI round trip.
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import time
from typing import Any
import numpy as np
import pyvisa
logger = logging.getLogger(__name__)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel
@@ -324,7 +327,12 @@ class Sn9000Service:
try:
instrument.read_bytes(1, break_on_termchar=True)
return
except Exception:
except pyvisa.errors.VisaIOError as exc:
# Timeouts on a trailing newline are routine; anything else
# likely means HiSLIP framing is out of sync and the next
# request will hang — surface it in the logs.
if exc.error_code != pyvisa.constants.StatusCode.error_timeout:
logger.warning("SN9000 terminator drain failed: %s", exc)
return
def _read_response_bytes(self, count: int) -> bytes:
+14
View File
@@ -377,6 +377,14 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.legacy_gpr.ignore_socket_speed_enabled,
),
look_angle_deg=legacy_float("look_angle_deg", gui.processing.legacy_gpr.look_angle_deg),
apply_freq_phase_correction=legacy_bool(
"apply_freq_phase_correction",
gui.processing.legacy_gpr.apply_freq_phase_correction,
),
reference_mode=legacy_string(
"reference_mode",
gui.processing.legacy_gpr.reference_mode,
),
snr_thresh=legacy_float("snr_thresh", gui.processing.legacy_gpr.snr_thresh),
snr_comp_max=legacy_float("snr_comp_max", gui.processing.legacy_gpr.snr_comp_max),
background_subtract_enabled=legacy_bool(
@@ -410,6 +418,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
raise ValueError("gui.processing.legacy_gpr.mode must be one of: point, extended")
if gui.processing.legacy_gpr.render_mode not in {"heatmap", "objects_only"}:
raise ValueError("gui.processing.legacy_gpr.render_mode must be one of: heatmap, objects_only")
if gui.processing.legacy_gpr.reference_mode not in {"frame_center", "first_tx_event"}:
raise ValueError(
"gui.processing.legacy_gpr.reference_mode must be one of: frame_center, first_tx_event"
)
if gui.processing.gpr.range_comp_power < 0.0:
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
if gui.processing.gpr.angle_comp_power < 0.0:
@@ -552,6 +564,8 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"speed_m_s": gui.processing.legacy_gpr.speed_m_s,
"ignore_socket_speed_enabled": gui.processing.legacy_gpr.ignore_socket_speed_enabled,
"look_angle_deg": gui.processing.legacy_gpr.look_angle_deg,
"apply_freq_phase_correction": gui.processing.legacy_gpr.apply_freq_phase_correction,
"reference_mode": gui.processing.legacy_gpr.reference_mode,
"snr_thresh": gui.processing.legacy_gpr.snr_thresh,
"snr_comp_max": gui.processing.legacy_gpr.snr_comp_max,
"background_subtract_enabled": gui.processing.legacy_gpr.background_subtract_enabled,
+2
View File
@@ -88,6 +88,8 @@ class GuiLegacyGprStateModel:
speed_m_s: float = 0.0
ignore_socket_speed_enabled: bool = False
look_angle_deg: float = 0.0
apply_freq_phase_correction: bool = True
reference_mode: str = "frame_center"
snr_thresh: float = 4.5
snr_comp_max: float = 25.0
background_subtract_enabled: bool = True
+14 -2
View File
@@ -24,10 +24,22 @@ def _as_dict(value: Any, context: str) -> dict[str, Any]:
return value
def _read_str(payload: dict[str, Any], key: str, default: str) -> str:
"""Return payload string, treating an explicit JSON `null` as missing.
`payload.get(key, default)` returns `None` when the key exists with value
`null`, which is then coerced into the literal string `"None"` by `str()`.
"""
value = payload.get(key, default)
if value is None:
return default
return str(value)
def _load_preprocess_asset(payload: dict[str, Any], target: PreprocessAssetModel) -> None:
"""Load preprocess asset fields into target model."""
target.set_name = str(payload.get("set_name", target.set_name))
target.bundle_path = str(payload.get("bundle_path", target.bundle_path))
target.set_name = _read_str(payload, "set_name", target.set_name)
target.bundle_path = _read_str(payload, "bundle_path", target.bundle_path)
def _load_string_list(payload: dict[str, Any], key: str, context: str) -> list[str]:
+15 -3
View File
@@ -4,8 +4,11 @@ from __future__ import annotations
from dataclasses import dataclass
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class GuiSessionState:
@@ -32,13 +35,22 @@ class GuiSessionStateStore:
if not self._path.exists():
return GuiSessionState()
payload = json.loads(self._path.read_text(encoding="utf-8"))
try:
payload = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
# The state file is GUI-local cache — a corrupted file should not
# prevent the app from starting. Reset to defaults and let the
# next write overwrite it.
logger.warning("Resetting unreadable GUI session-state %s: %s", self._path, exc)
return GuiSessionState()
if not isinstance(payload, dict):
raise ValueError(f"GUI session-state root must be JSON object: {self._path}")
logger.warning("Resetting GUI session-state with non-object root: %s", self._path)
return GuiSessionState()
raw_path = payload.get("last_profile_path", "")
if not isinstance(raw_path, str):
raise ValueError("GUI session-state `last_profile_path` must be a string")
logger.warning("Resetting GUI session-state with non-string last_profile_path: %s", self._path)
return GuiSessionState()
return GuiSessionState(last_profile_path=raw_path)
def write(self, state: GuiSessionState) -> Path:
@@ -36,6 +36,16 @@ class ProcessingLiveConfig:
gpr_draw_top_m_objects: int = 2
gpr_speed_m_s: float = 0.0
gpr_look_angle_deg: float = 0.0
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
# which way later events appear deeper (+1) vs shallower (-1) along Z.
# `apply_freq_phase_correction` enables intra-sweep frequency-domain phase
# compensation that fires *before* the IFFT — needed when the radar moves
# appreciably during one sweep.
gpr_direction_sign: float = 1.0
gpr_apply_freq_phase_correction: bool = True
# Anchor for the motion model's per-event `dt_ref`: 'frame_center' (default)
# or 'first_tx_event'. Mirrors Python `MOTION_CONFIG.reference_mode`.
gpr_reference_mode: str = "frame_center"
gpr_snr_thresh: float = 4.5
gpr_snr_comp_max: float = 25.0
gpr_start_freq_mhz: float = 3000.0
@@ -93,6 +103,9 @@ class ProcessingLiveConfig:
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
"gpr_speed_m_s": float(self.gpr_speed_m_s),
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
"gpr_direction_sign": float(self.gpr_direction_sign),
"gpr_apply_freq_phase_correction": bool(self.gpr_apply_freq_phase_correction),
"gpr_reference_mode": str(self.gpr_reference_mode),
"gpr_snr_thresh": float(self.gpr_snr_thresh),
"gpr_snr_comp_max": float(self.gpr_snr_comp_max),
"gpr_start_freq_mhz": float(self.gpr_start_freq_mhz),
+69 -6
View File
@@ -4,18 +4,41 @@ from __future__ import annotations
import argparse
import logging
from contextlib import suppress
from pathlib import Path
import signal
import threading
import time
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.shm import ShmRingWriter
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
logger = logging.getLogger(__name__)
# Maximum number of acquisitions allowed to fail in a row before we give up and
# let the supervisor restart the whole process. Picked high enough to survive
# transient USB stalls (each retry triggers a full reset cycle of ~1-2s) but
# bounded so a permanently broken device does not loop forever.
_MAX_CONSECUTIVE_ACQUIRE_FAILURES = 20
# Cooldown applied between a failed acquire and the next reset attempt. Stops
# us from busy-spinning when the device keeps refusing to come back.
_ACQUIRE_FAILURE_COOLDOWN_S = 1.0
def _reset_radar_service(
config: RunConfigModel, previous: MatrixRadarService | None
) -> MatrixRadarService:
"""Close `previous` (best-effort) and return a freshly opened+configured service."""
if previous is not None:
with suppress(Exception):
previous.close()
radar = create_matrix_radar_service(config)
radar.open()
radar.configure(config.radar.sweep)
return radar
def main() -> int:
"""Run producer process until config or signal requests exit."""
@@ -50,15 +73,53 @@ def main() -> int:
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes,
)
radar = create_matrix_radar_service(config)
radar: MatrixRadarService | None = None
consecutive_failures = 0
try:
radar.open()
radar.configure(config.radar.sweep)
radar = _reset_radar_service(config, previous=None)
collection_id = 1
while not stop_requested.is_set():
collection_start = time.monotonic()
collection = radar.acquire_collection(collection_id=collection_id)
try:
if radar is None:
radar = _reset_radar_service(config, previous=None)
collection = radar.acquire_collection(collection_id=collection_id)
except Exception as exc: # noqa: BLE001 — top-level recovery is the point
consecutive_failures += 1
if consecutive_failures > _MAX_CONSECUTIVE_ACQUIRE_FAILURES:
logger.error(
"Matrix radar acquisition failed %d times in a row; giving up. "
"Last error: %s",
consecutive_failures - 1,
exc,
)
raise
logger.warning(
"Matrix radar acquisition failed (%d/%d), resetting service: %s",
consecutive_failures,
_MAX_CONSECUTIVE_ACQUIRE_FAILURES,
exc,
exc_info=True,
)
# Cooldown gives slow USB stacks (and the device firmware) time
# to settle before the next open() attempt.
if stop_requested.wait(_ACQUIRE_FAILURE_COOLDOWN_S):
break
try:
radar = _reset_radar_service(config, previous=radar)
except Exception as reset_exc: # noqa: BLE001
logger.warning(
"Matrix radar reset (%d/%d) failed, will retry: %s",
consecutive_failures,
_MAX_CONSECUTIVE_ACQUIRE_FAILURES,
reset_exc,
exc_info=True,
)
radar = None
continue
consecutive_failures = 0
payload = serialize_trace_collection(collection, RAW_MAGIC)
if not raw_writer.push(payload):
@@ -80,7 +141,9 @@ def main() -> int:
)
collection_id += 1
finally:
radar.close()
if radar is not None:
with suppress(Exception):
radar.close()
raw_tap_writer.close()
raw_writer.close()
+42 -23
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import suppress
from datetime import datetime
import json
from pathlib import Path
@@ -65,7 +66,6 @@ class NpzStore(StoreApi):
}
)
np.savez(npz_path, **payload)
meta = {
"collection_id": int(collection.collection_id),
"monotonic_ns": int(collection.monotonic_ns),
@@ -73,7 +73,22 @@ class NpzStore(StoreApi):
"capture_end_ns": int(collection.capture_end_ns),
"combos": combo_records,
}
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
# Write both files to temporary paths first, then atomically rename so a
# crash never leaves an .npz without its meta (or vice versa).
npz_tmp = npz_path.with_name(npz_path.name + ".tmp")
meta_tmp = meta_path.with_name(meta_path.name + ".tmp")
try:
with npz_tmp.open("wb") as npz_file:
np.savez(npz_file, **payload)
meta_tmp.write_text(json.dumps(meta, indent=2), encoding="utf-8")
npz_tmp.replace(npz_path)
meta_tmp.replace(meta_path)
except BaseException:
for tmp_path in (npz_tmp, meta_tmp):
with suppress(OSError):
tmp_path.unlink(missing_ok=True)
raise
def load_set(self, kind: str, radar_key: str, set_name: str) -> SweepCollection:
"""Load named preprocess set from NPZ representation."""
@@ -84,30 +99,34 @@ class NpzStore(StoreApi):
if not npz_path.exists() or not meta_path.exists():
raise FileNotFoundError(f"Missing set files for {kind}/{radar_key}/{set_name}")
meta = json.loads(meta_path.read_text(encoding="utf-8"))
arrays = np.load(npz_path)
set_label = f"{kind}/{radar_key}/{set_name}"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
arrays = np.load(npz_path, allow_pickle=False)
traces: list[TraceData] = []
for combo in meta["combos"]:
freq = np.asarray(arrays[combo["freq_key"]], dtype=np.float32)
s11 = np.asarray(arrays[combo["s11_key"]], dtype=np.complex64)
s21 = np.asarray(arrays[combo["s21_key"]], dtype=np.complex64)
traces.append(
TraceData(
combo=ComboKey(input=int(combo["input"]), output=int(combo["output"])),
frequency_hz=freq,
s11=s11,
s21=s21,
traces: list[TraceData] = []
for combo in meta["combos"]:
freq = np.asarray(arrays[combo["freq_key"]], dtype=np.float32)
s11 = np.asarray(arrays[combo["s11_key"]], dtype=np.complex64)
s21 = np.asarray(arrays[combo["s21_key"]], dtype=np.complex64)
traces.append(
TraceData(
combo=ComboKey(input=int(combo["input"]), output=int(combo["output"])),
frequency_hz=freq,
s11=s11,
s21=s21,
)
)
)
return SweepCollection(
collection_id=int(meta["collection_id"]),
monotonic_ns=int(meta["monotonic_ns"]),
traces=traces,
capture_start_ns=int(meta.get("capture_start_ns", 0)),
capture_end_ns=int(meta.get("capture_end_ns", 0)),
)
return SweepCollection(
collection_id=int(meta["collection_id"]),
monotonic_ns=int(meta["monotonic_ns"]),
traces=traces,
capture_start_ns=int(meta.get("capture_start_ns", 0)),
capture_end_ns=int(meta.get("capture_end_ns", 0)),
)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
raise RuntimeError(f"Corrupted preprocess set {set_label}: {exc}") from exc
def list_sets(self, kind: str, radar_key: str) -> list[str]:
"""List available set names for `(kind, radar_key)`."""
+110 -64
View File
@@ -1,4 +1,4 @@
"""Tests for Kamil ADC config, parser, and producer wiring."""
"""Tests for Kamil ADC config, frame parsing, TTY reader, and producer wiring."""
from __future__ import annotations
@@ -9,10 +9,14 @@ import pty
import struct
import sys
import tempfile
import time
import tty
import unittest
from python_app.hardware_full.kamil_adc_service import KamilAdcFrameParser, KamilAdcTtyReader
from python_app.hardware_full.kamil_adc_service import (
KamilAdcTtyReader,
_parse_point_frame,
)
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.process_supervisor import ProcessSupervisor
@@ -25,28 +29,41 @@ def _point_frame(step: int, real: int, imag: int, *, marker: int = 0x000A) -> by
return struct.pack("<HHhh", marker, step, real, imag)
class KamilAdcFrameParserTest(unittest.TestCase):
def test_parse_valid_point(self) -> None:
value = KamilAdcFrameParser.parse_point(_point_frame(1, 123, -45), expected_step=1)
class ParsePointFrameTest(unittest.TestCase):
def test_parses_valid_point(self) -> None:
value = _parse_point_frame(_point_frame(1, 123, -45), expected_step=1)
self.assertEqual(value, complex(123, -45))
def test_bad_marker_is_rejected(self) -> None:
def test_rejects_bad_marker(self) -> None:
with self.assertRaisesRegex(ValueError, "marker mismatch"):
KamilAdcFrameParser.parse_point(_point_frame(1, 10, 20, marker=0x001A), expected_step=1)
_parse_point_frame(_point_frame(1, 10, 20, marker=0x001A), expected_step=1)
def test_wrong_step_is_rejected(self) -> None:
def test_rejects_wrong_step(self) -> None:
with self.assertRaisesRegex(ValueError, "step mismatch"):
KamilAdcFrameParser.parse_point(_point_frame(2, 10, 20), expected_step=1)
_parse_point_frame(_point_frame(2, 10, 20), expected_step=1)
class KamilAdcTtyReaderTest(unittest.TestCase):
def test_valid_stream_reads_complex_sweep(self) -> None:
"""End-to-end tests over a PTY exercising the background reader thread."""
def _open_pty_reader(self) -> tuple[int, int, KamilAdcTtyReader]:
master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
return master_fd, slave_fd, reader
@staticmethod
def _close(master_fd: int, slave_fd: int, reader: KamilAdcTtyReader) -> None:
try:
reader.close()
finally:
os.close(master_fd)
os.close(slave_fd)
def test_publishes_first_complete_sweep(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
os.write(
master_fd,
_start_frame()
@@ -54,23 +71,39 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
+ _point_frame(2, -20, 2)
+ _start_frame(),
)
values = reader.read_sweep(timeout_s=1.0)
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
self.assertEqual(reader.locked_points, 2)
finally:
if reader is not None:
reader.close()
os.close(master_fd)
os.close(slave_fd)
self._close(master_fd, slave_fd, reader)
def test_stream_reads_consecutive_variable_length_sweeps(self) -> None:
master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None
def test_consecutive_constant_length_sweeps(self) -> None:
"""Each newly-completed sweep is delivered once new data arrives after a read."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame(),
)
first = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)])
os.write(
master_fd,
_point_frame(1, 30, -3) + _point_frame(2, -40, 4) + _start_frame(),
)
second = reader.read_sweep(timeout_s=1.0)
self.assertEqual(second.tolist(), [complex(30, -3), complex(-40, 4)])
finally:
self._close(master_fd, slave_fd, reader)
def test_shorter_sweep_after_lock_raises(self) -> None:
"""A later sweep with fewer points than the locked-in count fails fast."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
os.write(
master_fd,
_start_frame()
@@ -80,60 +113,73 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
+ _point_frame(1, 30, -3)
+ _start_frame(),
)
first = reader.read_sweep(timeout_s=1.0)
second = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)])
self.assertEqual(second.tolist(), [complex(30, -3)])
with self.assertRaisesRegex(RuntimeError, "sweep length changed"):
reader.read_sweep(timeout_s=1.0)
finally:
if reader is not None:
reader.close()
os.close(master_fd)
os.close(slave_fd)
self._close(master_fd, slave_fd, reader)
def test_expected_point_count_discards_mismatched_sweep(self) -> None:
master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None
def test_longer_sweep_after_lock_raises(self) -> None:
"""A later sweep with more points than the locked-in count fails fast."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 5, -5)
+ _start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame()
+ _point_frame(1, 30, -3)
+ _point_frame(2, -40, 4)
+ _start_frame(),
)
values = reader.read_sweep(timeout_s=1.0, expected_points=2)
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
first = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.tolist(), [complex(10, -1)])
with self.assertRaisesRegex(RuntimeError, "exceeded locked point count"):
reader.read_sweep(timeout_s=1.0)
finally:
if reader is not None:
reader.close()
os.close(master_fd)
os.close(slave_fd)
self._close(master_fd, slave_fd, reader)
def test_stream_without_next_start_times_out_with_received_count(self) -> None:
master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None
def test_no_completed_sweep_times_out(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
# Start marker plus a partial sweep with no follow-up boundary.
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1))
with self.assertRaisesRegex(TimeoutError, "sweep end: received 1 points"):
reader.read_sweep(timeout_s=0.05)
with self.assertRaisesRegex(TimeoutError, "Timed out waiting for Kamil ADC sweep"):
reader.read_sweep(timeout_s=0.1)
finally:
if reader is not None:
reader.close()
os.close(master_fd)
os.close(slave_fd)
self._close(master_fd, slave_fd, reader)
def test_only_latest_sweep_is_published(self) -> None:
"""If multiple sweeps arrive before the consumer reads, only the newest survives."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
payload = (
_start_frame()
+ _point_frame(1, 1, 0)
+ _point_frame(2, 2, 0)
+ _start_frame()
+ _point_frame(1, 3, 0)
+ _point_frame(2, 4, 0)
+ _start_frame()
+ _point_frame(1, 5, 0)
+ _point_frame(2, 6, 0)
+ _start_frame()
)
os.write(master_fd, payload)
# Wait until the reader thread has parsed all three sweeps before
# reading from the mailbox — otherwise we'd race the producer and
# might consume an intermediate value.
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline and reader.published_count < 3:
time.sleep(0.005)
self.assertGreaterEqual(reader.published_count, 3)
values = reader.read_sweep(timeout_s=1.0)
# The reader thread overwrites unread sweeps; the consumer sees the
# most recently completed one.
self.assertEqual(values.tolist(), [complex(5, 0), complex(6, 0)])
finally:
self._close(master_fd, slave_fd, reader)
class KamilAdcConfigTest(unittest.TestCase):
@@ -217,8 +217,8 @@ class MultiRadarSequentialCaptureSession:
display_traces.append(combined_collection.traces[-1])
variant_labels.append(variant.display_name)
else:
assert self._input_switch is not None
assert self._output_switch is not None
if self._input_switch is None or self._output_switch is None:
raise RuntimeError("Switches are not initialised for combo capture")
self._output_switch.switch_to(combo.output)
self._input_switch.switch_to(combo.input)
if self._base_config.runtime.settling_ms > 0:
@@ -179,8 +179,8 @@ class SequentialCaptureSession:
self._next_index = len(self._combos)
return combined_collection.traces[-1]
assert self._input_switch is not None
assert self._output_switch is not None
if self._input_switch is None or self._output_switch is None:
raise RuntimeError("Switches are not initialised for combo capture")
self._output_switch.switch_to(combo.output)
self._input_switch.switch_to(combo.input)
if self._config.runtime.settling_ms > 0: