Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+20 -134
View File
@@ -2,36 +2,21 @@
from __future__ import annotations
import argparse
from contextlib import ExitStack, suppress
from contextlib import ExitStack
import logging
from pathlib import Path
import signal
import threading
import time
import numpy as np
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.switch_service import SwitchService
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
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
from python_app.models.run_config_schema import RunConfigModel
from python_app.scripts.producer_common import RawPublisher, close_devices, load_producer_config, retry_open
logger = logging.getLogger(__name__)
# The producer waits for the Kamil ADC collector forever: a device/collector that
# is absent at boot or disappears mid-run must never kill the producer, only make
# it wait. Reconnect uses a capped exponential backoff so a long absence does not
# busy-spin, and every wait is interruptible by SIGINT/SIGTERM (stop_requested) for
# a prompt clean exit. Mirrors matrix_raw_producer._open_radar_with_retry.
_OPEN_RETRY_MIN_S = 1.0
_OPEN_RETRY_MAX_S = 10.0
# Throttle open-failure logging during a long wait so a permanently absent device
# does not flood the process log: log the first failure, then every Nth attempt.
_OPEN_RETRY_LOG_EVERY = 30
def _open_radar_with_retry(
config: RunConfigModel,
@@ -40,33 +25,10 @@ def _open_radar_with_retry(
output_switch: SwitchService | None,
stop_requested: threading.Event,
) -> bool:
"""Open+configure the radar and both switches, retrying forever until stop.
"""Reopen the ADC and Python-owned switches, cleaning partial opens before retry."""
close_devices(input_switch, output_switch, radar)
Used for both the initial open and every in-loop reconnect, so a collector
that is absent at boot or disappears mid-run never kills the producer — it just
waits. Any partially-opened components are closed before each retry so a
relaunched collector starts clean. Returns ``True`` once everything is open, or
``False`` if a stop was requested before the device became available. Backoff is
capped and every wait is interruptible by SIGTERM.
``input_switch``/``output_switch`` are ``None`` in switch-aware mode, where the
collector owns the GPIO lines and the producer must not open them.
"""
# Tear down any prior open first: open()/switch.open() are idempotent no-ops
# while still "open", so a mid-run reconnect must close them to force a fresh
# collector relaunch and TTY re-attach.
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if output_switch is not None:
with suppress(Exception):
output_switch.close()
with suppress(Exception):
radar.close()
attempt = 0
delay = _OPEN_RETRY_MIN_S
while not stop_requested.is_set():
def attempt() -> KamilAdcService:
try:
radar.open(stop_event=stop_requested)
radar.configure(config.radar.sweep)
@@ -74,82 +36,28 @@ def _open_radar_with_retry(
output_switch.open()
if input_switch is not None:
input_switch.open()
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
# Drop any partial open (collector process, TTY reader, switches)
# before the next attempt so the relaunch starts from a clean state.
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if output_switch is not None:
with suppress(Exception):
output_switch.close()
with suppress(Exception):
radar.close()
attempt += 1
if attempt == 1 or attempt % _OPEN_RETRY_LOG_EVERY == 0:
logger.warning(
"Kamil ADC not available (attempt %d); retrying every up to %.0fs "
"until the device is present: %s",
attempt,
_OPEN_RETRY_MAX_S,
exc,
)
if stop_requested.wait(delay):
return False
delay = min(delay * 2.0, _OPEN_RETRY_MAX_S)
continue
except Exception:
close_devices(input_switch, output_switch, radar)
raise
return radar
if attempt > 0:
logger.info("Kamil ADC opened after %d attempt(s).", attempt + 1)
return True
logger.debug("Stop requested before the Kamil ADC became available.")
return False
return retry_open(attempt, stop_requested, "Kamil ADC") is not None
def main() -> int:
"""Run producer process until config or signal requests exit."""
parser = argparse.ArgumentParser(description="Publish Kamil ADC raw sweeps to SHM rings")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
stop_requested = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_requested.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
config = RunConfigModel.load_from_path(args.config)
config_path, config, stop_requested = load_producer_config("Publish Kamil ADC raw sweeps to SHM rings")
if not config.is_kamil_adc:
raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'")
config.ensure_combos()
logger.info(
"Kamil ADC raw producer starting: config=%s, combos=%d, continuous=%s",
args.config, len(config.combos), config.runtime.continuous,
config_path, len(config.combos), config.runtime.continuous,
)
radar = input_switch = output_switch = None
cleanup = ExitStack()
try:
raw_writer = ShmRingWriter(
config.rings.raw.name,
config.rings.raw.capacity,
config.rings.raw.slot_size_bytes,
)
cleanup.callback(raw_writer.close)
raw_tap_writer = ShmRingWriter(
config.rings.raw_tap.name,
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes,
)
cleanup.callback(raw_tap_writer.close)
logger.debug(
"Opened SHM ring writers: raw=%s, raw_tap=%s",
config.rings.raw.name, config.rings.raw_tap.name,
)
publisher = RawPublisher(config.rings, cleanup)
# Switch-aware mode: with native switches the collector drives the RF switches
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
# — no sweep lost at a switch boundary. The producer then only reads tagged
@@ -162,7 +70,7 @@ def main() -> int:
)
radar = KamilAdcService(
config,
switch_config_path=str(args.config) if collector_driven else None,
switch_config_path=str(config_path) if collector_driven else None,
)
input_switch = None if collector_driven else SwitchService.from_model(config.input_switch)
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
@@ -175,7 +83,6 @@ def main() -> int:
return 0 # asked to stop before a device became available
collection_id = 1
publish_failures = 0
while not stop_requested.is_set():
collection_start = time.monotonic()
capture_start_ns = time.monotonic_ns()
@@ -193,9 +100,8 @@ def main() -> int:
else:
if input_switch is None or output_switch is None:
raise RuntimeError("Producer-driven switches are not initialized")
output_switch.switch_to(combo.output)
input_switch.switch_to(combo.input)
if config.runtime.settling_ms > 0 and stop_requested.wait(config.runtime.settling_ms / 1000.0):
if not select_and_settle(output_switch, input_switch, combo.output, combo.input,
config.runtime.settling_ms, wait=stop_requested.wait):
break
# Stamped after switching and settling so the window covers
# the sweep alone, not the dead time before it.
@@ -205,7 +111,6 @@ def main() -> int:
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
capture_start_ns=sweep_start_ns,
capture_end_ns=time.monotonic_ns(),
@@ -244,18 +149,7 @@ def main() -> int:
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
)
payload = serialize_trace_collection(collection, RAW_MAGIC)
if raw_writer.push(payload):
raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
else:
# Oversized payload vs the ring slot is a persistent config error, not
# a device fault: log (throttled) and skip rather than killing the producer.
publish_failures += 1
if publish_failures == 1 or publish_failures % 100 == 0:
logger.error(
"Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)",
len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures,
)
publisher.publish(collection)
if not config.runtime.continuous:
break
@@ -264,15 +158,7 @@ def main() -> int:
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
collection_id += 1
finally:
if output_switch is not None:
with suppress(Exception):
output_switch.close()
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if radar is not None:
with suppress(Exception):
radar.close()
close_devices(output_switch, input_switch, radar)
cleanup.close()
logger.info("Kamil ADC raw producer stopped")