Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+37 -94
View File
@@ -3,18 +3,19 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import logging
import os
from pathlib import Path
import shlex
import signal
import subprocess
import sys
import time
from typing import Iterable
from typing import Sequence
from python_app.file_io import atomic_write_text
from python_app.orchestration.producer_selection import acquisition_command
logger = logging.getLogger(__name__)
# Cap each child log so a long-lived daemon cannot fill the SD card. On reaching
@@ -244,31 +245,23 @@ class ProcessSupervisor:
# on every write, so a truncate genuinely restarts the file at offset 0.
# `_roll_log_to_prev` above already renamed any previous log away, so not
# truncating on open costs nothing.
stdout_file = open(stdout_path, "ab")
stderr_file = open(stderr_path, "ab")
try:
handle = subprocess.Popen(
command,
cwd=self._project_root,
stdout=stdout_file,
stderr=stderr_file,
# Own session/process group so signalling the group on stop also
# reaches device-I/O grandchildren the producer may have spawned.
start_new_session=True,
)
with stdout_path.open("ab") as stdout_file, stderr_path.open("ab") as stderr_file:
handle = subprocess.Popen(
command,
cwd=self._project_root,
stdout=stdout_file,
stderr=stderr_file,
# Device-I/O grandchildren share this group and stop with it.
start_new_session=True,
)
except OSError as exc:
stdout_file.close()
stderr_file.close()
command_text = shlex.join(command)
logger.error("Failed to spawn `%s`: %s: %s", name, type(exc).__name__, exc)
raise RuntimeError(
f"Failed to spawn {name} with command `{command_text}` from `{self._project_root}`: "
f"{type(exc).__name__}: {exc}"
) from exc
finally:
# The child holds its own dup'd fds; the parent's copies are not needed.
stdout_file.close()
stderr_file.close()
self._processes[name] = ManagedProcess(
name=name,
@@ -282,68 +275,8 @@ class ProcessSupervisor:
self._write_pidfile()
def _acquisition_command(self, config_path: Path) -> list[str]:
"""Return acquisition producer command selected by radar.model."""
# A configured replay dataset overrides the model's normal producer:
# recorded snapshots are replayed into the raw ring instead of
# acquiring from (mock) hardware.
replay_dir = self._read_replay_dataset_dir(config_path)
if replay_dir:
logger.debug("Selecting replay producer for dataset_dir=%s", replay_dir)
return [
sys.executable,
"-m",
"python_app.scripts.replay_raw_producer",
"--config",
str(config_path),
]
radar_model = self._read_radar_model(config_path)
logger.debug("Selecting acquisition producer for radar.model=%s", radar_model)
if radar_model in {"librevna_multi", "sn9000"}:
return [
sys.executable,
"-m",
"python_app.scripts.matrix_raw_producer",
"--config",
str(config_path),
]
if radar_model == "kamil_adc":
return [
sys.executable,
"-m",
"python_app.scripts.kamil_adc_raw_producer",
"--config",
str(config_path),
]
return [
str(self._project_root / "build/bin/sweep_orchestrator"),
"--config",
str(config_path),
]
@staticmethod
def _read_radar_model(config_path: Path) -> str:
"""Read `radar.model` cheaply without constructing the full config model."""
payload = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return "librevna"
radar_payload = payload.get("radar")
if not isinstance(radar_payload, dict):
return "librevna"
return str(radar_payload.get("model", "librevna"))
@staticmethod
def _read_replay_dataset_dir(config_path: Path) -> str:
"""Read `radar.replay.dataset_dir` cheaply, '' when replay is off."""
payload = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return ""
radar_payload = payload.get("radar")
if not isinstance(radar_payload, dict):
return ""
replay_payload = radar_payload.get("replay")
if not isinstance(replay_payload, dict):
return ""
return str(replay_payload.get("dataset_dir", "")).strip()
"""Select the same acquisition producer as the shell launcher."""
return acquisition_command(self._project_root, config_path)
def _stop_processes(self, names: Iterable[str]) -> None:
"""Gracefully terminate processes, then force-kill on per-process timeout."""
@@ -393,7 +326,12 @@ class ProcessSupervisor:
if pid is None:
return
try:
os.killpg(os.getpgid(pid), sig)
# Managed children lead their own sessions. Never signal an unrelated
# shell's group if a stale PID or an external collector does not.
if os.getpgid(pid) == pid:
os.killpg(pid, sig)
else:
os.kill(pid, sig)
except (ProcessLookupError, PermissionError):
# Group already gone, or could not resolve it; fall back to the child.
try:
@@ -401,7 +339,8 @@ class ProcessSupervisor:
except (ProcessLookupError, PermissionError):
pass
def _kill_kamil_collectors(self) -> None:
@staticmethod
def _kill_kamil_collectors() -> None:
"""SIGKILL any Kamil ADC collector found by name — a guaranteed device backstop.
The collector survives a force-killed producer (own session) and can hang
@@ -421,8 +360,9 @@ class ProcessSupervisor:
cmdline = (Path("/proc") / str(pid) / "cmdline").read_bytes()
except OSError:
continue
if _KAMIL_COLLECTOR_MARKER in cmdline:
self._signal_group(pid, signal.SIGKILL)
executable = cmdline.split(b"\x00", 1)[0].rsplit(b"/", 1)[-1]
if executable == _KAMIL_COLLECTOR_MARKER:
ProcessSupervisor._signal_group(pid, signal.SIGKILL)
killed += 1
if killed:
logger.warning("Force-killed %d orphaned Kamil ADC collector process(es)", killed)
@@ -569,7 +509,7 @@ class ProcessSupervisor:
for process in self._processes.values()
if process.handle.poll() is None and process.handle.pid is not None
]
self._pidfile_path.write_text("\n".join(live_pids), encoding="utf-8")
atomic_write_text(self._pidfile_path, "\n".join(live_pids))
except OSError:
# Best-effort bookkeeping: failure here must not break start/stop.
pass
@@ -599,7 +539,7 @@ class ProcessSupervisor:
logger.warning("Reaping stale pipeline child from prior run (pid=%d)", pid)
self._signal_group(pid, signal.SIGKILL)
try:
self._pidfile_path.write_text("", encoding="utf-8")
atomic_write_text(self._pidfile_path, "")
except OSError:
pass
# The ADC collector is a grandchild (spawned by the producer), not in the
@@ -612,17 +552,20 @@ class ProcessSupervisor:
Reads `/proc/<pid>/cmdline` so a recycled PID owned by an unrelated
process is never killed.
"""
markers = (
"build/bin/data_processor",
"build/bin/data_preprocessor",
"build/bin/sweep_orchestrator",
native_names = {"data_processor", "data_preprocessor", "sweep_orchestrator"}
producer_modules = {
"python_app.scripts.matrix_raw_producer",
"python_app.scripts.kamil_adc_raw_producer",
"python_app.scripts.replay_raw_producer",
)
}
try:
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
except OSError:
return False
cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace")
return any(marker in cmdline for marker in markers)
argv = raw.decode("utf-8", errors="replace").rstrip("\x00").split("\x00")
executable = Path(argv[0])
if executable.name in native_names:
expected = self._project_root / "build/bin" / executable.name
return executable == expected or executable == Path("build/bin") / executable.name
is_python = executable.name in {"python", "python3"} or executable.name.startswith("python3.")
return is_python and len(argv) >= 3 and argv[1] == "-m" and argv[2] in producer_modules