Refactor radar system
This commit is contained in:
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.file_io import atomic_write_json
|
||||
from python_app.models.run_config_model import RunConfigModel, parse_combos_from_text
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_KEYS,
|
||||
@@ -47,32 +45,9 @@ class ConfigWriter:
|
||||
asset.bundle_path = str(bundle_path)
|
||||
|
||||
def write(self, config: RunConfigModel, output_path: Path) -> Path:
|
||||
"""Atomically write the run configuration JSON file.
|
||||
|
||||
Dump to a sibling ``.tmp``, flush + ``fsync`` to durably commit the bytes on
|
||||
the SD-card-backed Pi, then ``os.replace()`` atomically onto the destination.
|
||||
A C++ consumer can therefore never observe a half-written config (which would
|
||||
abort it with an opaque JSON parse error), even across a crash or power loss.
|
||||
"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# allow_nan=False: a stray NaN/Infinity must fail loudly here in Python
|
||||
# rather than serialize to a non-standard token that aborts every C++
|
||||
# consumer at startup with an opaque JSON parse error.
|
||||
serialized = json.dumps(config.to_dict(), indent=2, allow_nan=False)
|
||||
tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(serialized)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, output_path)
|
||||
except Exception:
|
||||
logger.exception("Failed to write run config to %s; removing temp file", output_path)
|
||||
# Never leave a half-written .tmp behind on a write/fsync failure.
|
||||
with suppress(OSError):
|
||||
tmp_path.unlink()
|
||||
raise
|
||||
logger.debug("Wrote run config to %s (%d bytes)", output_path, len(serialized))
|
||||
"""Atomically write and sync the run configuration JSON file."""
|
||||
atomic_write_json(output_path, config.to_dict(), durable=True)
|
||||
logger.debug("Wrote run config to %s", output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.file_io import atomic_write_json
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ class GuiSessionStateStore:
|
||||
|
||||
try:
|
||||
payload = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
except (OSError, UnicodeError, 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.
|
||||
@@ -55,11 +56,6 @@ class GuiSessionStateStore:
|
||||
|
||||
def write(self, state: GuiSessionState) -> Path:
|
||||
"""Atomically write session-state JSON file."""
|
||||
temp_path = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
temp_path.write_text(
|
||||
json.dumps({"last_profile_path": state.last_profile_path}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temp_path.replace(self._path)
|
||||
atomic_write_json(self._path, {"last_profile_path": state.last_profile_path}, durable=True)
|
||||
logger.debug("Wrote GUI session-state to %s", self._path)
|
||||
return self._path
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.file_io import atomic_write_json
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@ class ProcessingLiveConfig:
|
||||
pass_through_y_max_db: float = 0.0
|
||||
bscan_axis: str = "abs"
|
||||
bscan_channel: str = "s21"
|
||||
bscan_cut_m: float = 0.824
|
||||
bscan_max_depth_m: float = 1.0
|
||||
bscan_cut_m: float = 0.0
|
||||
bscan_max_depth_m: float = 8.0
|
||||
bscan_gain: float = 1.0
|
||||
bscan_start_freq_mhz: float = 100.0
|
||||
bscan_stop_freq_mhz: float = 8800.0
|
||||
legacy_gpr_mode: str = "point"
|
||||
relative_permittivity: float | None = None
|
||||
tx_geometry: list[dict] | None = None
|
||||
rx_geometry: list[dict] | None = None
|
||||
gpr_input_positions: list[int] | None = None
|
||||
gpr_output_positions: list[int] | None = None
|
||||
gpr_min_depth_m: float = 2.0
|
||||
@@ -88,6 +91,7 @@ class ProcessingLiveConfig:
|
||||
reprocess_current_result: bool = True
|
||||
history_command_seq: int = 0
|
||||
history_command: str = "none"
|
||||
settings_revision: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalize optional list fields to concrete integer lists."""
|
||||
@@ -102,60 +106,10 @@ class ProcessingLiveConfig:
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Convert live config to JSON-serializable dictionary."""
|
||||
optional_geometry = {"relative_permittivity", "tx_geometry", "rx_geometry"}
|
||||
return {
|
||||
"processor_mode": str(self.processor_mode),
|
||||
"pass_through_channel": str(self.pass_through_channel),
|
||||
"pass_through_fixed_y_enabled": bool(self.pass_through_fixed_y_enabled),
|
||||
"pass_through_y_min_db": float(self.pass_through_y_min_db),
|
||||
"pass_through_y_max_db": float(self.pass_through_y_max_db),
|
||||
"bscan_axis": str(self.bscan_axis),
|
||||
"bscan_channel": str(self.bscan_channel),
|
||||
"bscan_cut_m": float(self.bscan_cut_m),
|
||||
"bscan_max_depth_m": float(self.bscan_max_depth_m),
|
||||
"bscan_gain": float(self.bscan_gain),
|
||||
"bscan_start_freq_mhz": float(self.bscan_start_freq_mhz),
|
||||
"bscan_stop_freq_mhz": float(self.bscan_stop_freq_mhz),
|
||||
"legacy_gpr_mode": str(self.legacy_gpr_mode),
|
||||
"gpr_input_positions": [int(value) for value in self.gpr_input_positions],
|
||||
"gpr_output_positions": [int(value) for value in self.gpr_output_positions],
|
||||
"gpr_min_depth_m": float(self.gpr_min_depth_m),
|
||||
"gpr_max_depth_m": float(self.gpr_max_depth_m),
|
||||
"gpr_range_comp_power": float(self.gpr_range_comp_power),
|
||||
"gpr_comp_power": float(self.gpr_comp_power),
|
||||
"gpr_object_min_frac": float(self.gpr_object_min_frac),
|
||||
"gpr_score_mode": str(self.gpr_score_mode),
|
||||
"gpr_motion_mode": str(self.gpr_motion_mode),
|
||||
"gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw),
|
||||
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
|
||||
"gpr_object_approach_min_frames": int(self.gpr_object_approach_min_frames),
|
||||
"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),
|
||||
"gpr_stop_freq_mhz": float(self.gpr_stop_freq_mhz),
|
||||
"gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled),
|
||||
"gpr_background_mean_count": int(self.gpr_background_mean_count),
|
||||
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
|
||||
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
|
||||
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
|
||||
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
|
||||
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
|
||||
"gpr_visible_z_min_m": float(self.gpr_visible_z_min_m),
|
||||
"gpr_visible_z_max_m": float(self.gpr_visible_z_max_m),
|
||||
"sar3d_frame_radius": int(self.sar3d_frame_radius),
|
||||
"sar3d_voxel_m": float(self.sar3d_voxel_m),
|
||||
"sar3d_bp_ground_depth_m": float(self.sar3d_bp_ground_depth_m),
|
||||
"sar3d_comp_range_power": float(self.sar3d_comp_range_power),
|
||||
"sar3d_use_compensation": bool(self.sar3d_use_compensation),
|
||||
"sar3d_normalize_each_frame": bool(self.sar3d_normalize_each_frame),
|
||||
"ignore_socket_speed": bool(self.ignore_socket_speed),
|
||||
"reprocess_current_result": bool(self.reprocess_current_result),
|
||||
"history_command_seq": int(self.history_command_seq),
|
||||
"history_command": str(self.history_command),
|
||||
name: value for name, value in asdict(self).items()
|
||||
if name not in optional_geometry or value is not None
|
||||
}
|
||||
|
||||
|
||||
@@ -180,7 +134,4 @@ class ProcessingLiveConfigWriter:
|
||||
config.processor_mode,
|
||||
self._config_path,
|
||||
)
|
||||
temp_path = self._config_path.with_suffix(self._config_path.suffix + ".tmp")
|
||||
temp_path.write_text(json.dumps(config.to_dict(), indent=2), encoding="utf-8")
|
||||
temp_path.replace(self._config_path)
|
||||
return self._config_path
|
||||
return atomic_write_json(self._config_path, config.to_dict())
|
||||
|
||||
@@ -11,7 +11,6 @@ from python_app.models.run_config_model import PreprocessAssetModel, RunConfigMo
|
||||
class PreprocessAssetSpec:
|
||||
"""Stable description of one preprocess asset."""
|
||||
|
||||
key: str
|
||||
display_name: str
|
||||
set_kind: str
|
||||
runtime_filename: str
|
||||
@@ -19,48 +18,12 @@ class PreprocessAssetSpec:
|
||||
|
||||
|
||||
PREPROCESS_ASSET_SPECS = {
|
||||
"s21_calibration": PreprocessAssetSpec(
|
||||
key="s21_calibration",
|
||||
display_name="S21 Calibration",
|
||||
set_kind="s21_calibration",
|
||||
runtime_filename="s21_calibration_bundle.bin",
|
||||
channel="s21",
|
||||
),
|
||||
"s21_reference": PreprocessAssetSpec(
|
||||
key="s21_reference",
|
||||
display_name="S21 Reference",
|
||||
set_kind="s21_reference",
|
||||
runtime_filename="s21_reference_bundle.bin",
|
||||
channel="s21",
|
||||
),
|
||||
"s11_open": PreprocessAssetSpec(
|
||||
key="s11_open",
|
||||
display_name="S11 Open",
|
||||
set_kind="s11_open",
|
||||
runtime_filename="s11_open_calibration_bundle.bin",
|
||||
channel="s11",
|
||||
),
|
||||
"s11_short": PreprocessAssetSpec(
|
||||
key="s11_short",
|
||||
display_name="S11 Short",
|
||||
set_kind="s11_short",
|
||||
runtime_filename="s11_short_calibration_bundle.bin",
|
||||
channel="s11",
|
||||
),
|
||||
"s11_load": PreprocessAssetSpec(
|
||||
key="s11_load",
|
||||
display_name="S11 Load",
|
||||
set_kind="s11_load",
|
||||
runtime_filename="s11_load_calibration_bundle.bin",
|
||||
channel="s11",
|
||||
),
|
||||
"s11_reference": PreprocessAssetSpec(
|
||||
key="s11_reference",
|
||||
display_name="S11 Reference",
|
||||
set_kind="s11_reference",
|
||||
runtime_filename="s11_reference_bundle.bin",
|
||||
channel="s11",
|
||||
),
|
||||
"s21_calibration": PreprocessAssetSpec("S21 Calibration", "s21_calibration", "s21_calibration_bundle.bin", "s21"),
|
||||
"s21_reference": PreprocessAssetSpec("S21 Reference", "s21_reference", "s21_reference_bundle.bin", "s21"),
|
||||
"s11_open": PreprocessAssetSpec("S11 Open", "s11_open", "s11_open_calibration_bundle.bin", "s11"),
|
||||
"s11_short": PreprocessAssetSpec("S11 Short", "s11_short", "s11_short_calibration_bundle.bin", "s11"),
|
||||
"s11_load": PreprocessAssetSpec("S11 Load", "s11_load", "s11_load_calibration_bundle.bin", "s11"),
|
||||
"s11_reference": PreprocessAssetSpec("S11 Reference", "s11_reference", "s11_reference_bundle.bin", "s11"),
|
||||
}
|
||||
|
||||
PREPROCESS_ASSET_KEYS = tuple(PREPROCESS_ASSET_SPECS.keys())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""One acquisition producer selection shared by supervisor and launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from python_app.models.run_config_schema import RadarModel
|
||||
|
||||
|
||||
def acquisition_command(project_root: Path, config_path: Path) -> list[str]:
|
||||
"""Select replay first, then the configured model's acquisition producer."""
|
||||
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
radar = payload.get("radar", {}) if isinstance(payload, dict) else {}
|
||||
radar = radar if isinstance(radar, dict) else {}
|
||||
replay = radar.get("replay", {})
|
||||
dataset = replay.get("dataset_dir") if isinstance(replay, dict) else None
|
||||
defaults = RadarModel()
|
||||
model = radar.get("model")
|
||||
if model is None and isinstance(payload, dict):
|
||||
model = defaults.model
|
||||
driver_mode = radar.get("driver_mode")
|
||||
if driver_mode is None:
|
||||
driver_mode = defaults.driver_mode
|
||||
if model in ("librevna", "librevna_multi") and driver_mode == "mock":
|
||||
model = "librevna_multi"
|
||||
module = "replay_raw_producer" if isinstance(dataset, str) and dataset.strip() else {
|
||||
"librevna_multi": "matrix_raw_producer",
|
||||
"kamil_adc": "kamil_adc_raw_producer",
|
||||
}.get(model if isinstance(model, str) else "")
|
||||
command = [sys.executable, "-m", f"python_app.scripts.{module}"] if module else [
|
||||
str(project_root / "build/bin/sweep_orchestrator"),
|
||||
]
|
||||
return [*command, "--config", str(config_path)]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""GCC atomic operations on aligned shared-memory fields.
|
||||
|
||||
The libatomic ABI omits the builtin compare-exchange ``weak`` argument:
|
||||
https://github.com/gcc-mirror/gcc/blob/master/libatomic/cas_n.c
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
from functools import cache
|
||||
import mmap
|
||||
|
||||
_ACQUIRE, _RELEASE, _ACQ_REL, _SEQ_CST = 2, 3, 4, 5
|
||||
|
||||
|
||||
@cache
|
||||
def _library() -> ctypes.CDLL:
|
||||
try:
|
||||
library = ctypes.CDLL("libatomic.so.1")
|
||||
except OSError as exc:
|
||||
raise RuntimeError("Shared memory requires libatomic.so.1 (install libatomic1)") from exc
|
||||
library.__atomic_is_lock_free.argtypes = [ctypes.c_size_t, ctypes.c_void_p]
|
||||
library.__atomic_is_lock_free.restype = ctypes.c_bool
|
||||
for width, number in ((4, ctypes.c_uint32), (8, ctypes.c_uint64)):
|
||||
if not library.__atomic_is_lock_free(width, None):
|
||||
raise RuntimeError(f"Shared memory requires lock-free {width}-byte atomics")
|
||||
load = getattr(library, f"__atomic_load_{width}")
|
||||
load.argtypes, load.restype = [ctypes.c_void_p, ctypes.c_int], number
|
||||
store = getattr(library, f"__atomic_store_{width}")
|
||||
store.argtypes, store.restype = [ctypes.c_void_p, number, ctypes.c_int], None
|
||||
library.__atomic_compare_exchange_8.argtypes = [
|
||||
ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint64, ctypes.c_int, ctypes.c_int,
|
||||
]
|
||||
library.__atomic_compare_exchange_8.restype = ctypes.c_bool
|
||||
library.atomic_thread_fence.argtypes, library.atomic_thread_fence.restype = [ctypes.c_int], None
|
||||
return library
|
||||
|
||||
|
||||
def _address(buffer: mmap.mmap, offset: int, width: int) -> int:
|
||||
if offset % width or offset < 0 or offset + width > len(buffer):
|
||||
raise ValueError("Atomic field must be aligned and inside the mapping")
|
||||
return ctypes.addressof(ctypes.c_char.from_buffer(buffer, offset))
|
||||
|
||||
|
||||
def load(buffer: mmap.mmap, offset: int, width: int) -> int:
|
||||
return getattr(_library(), f"__atomic_load_{width}")(_address(buffer, offset, width), _ACQUIRE)
|
||||
|
||||
|
||||
def store(buffer: mmap.mmap, offset: int, value: int, width: int) -> None:
|
||||
if not 0 <= value < 1 << (8 * width):
|
||||
raise ValueError("Atomic field value exceeds its unsigned wire range")
|
||||
getattr(_library(), f"__atomic_store_{width}")(_address(buffer, offset, width), value, _RELEASE)
|
||||
|
||||
|
||||
def advance(buffer: mmap.mmap, offset: int, desired: int) -> None:
|
||||
"""Advance a shared cursor with CAS; concurrent progress can never be undone."""
|
||||
address = _address(buffer, offset, 8)
|
||||
expected = ctypes.c_uint64(load(buffer, offset, 8))
|
||||
while expected.value < desired:
|
||||
if _library().__atomic_compare_exchange_8(address, ctypes.byref(expected), desired, _ACQ_REL, _ACQUIRE):
|
||||
break
|
||||
|
||||
|
||||
def fence() -> None:
|
||||
"""Order invalidation/copy/validation on CPUs with weak memory ordering."""
|
||||
_library().atomic_thread_fence(_SEQ_CST)
|
||||
@@ -119,7 +119,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||
image_y_axis = np.frombuffer(cursor.read_bytes(y_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||
value_count = x_count * y_count
|
||||
image_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||
image = image_values.reshape((y_count, x_count)) if value_count > 0 else np.zeros((0, 0), dtype=np.float32)
|
||||
image = image_values.reshape((y_count, x_count))
|
||||
return ResultPayload(
|
||||
processing_name=name,
|
||||
kind=kind,
|
||||
@@ -130,13 +130,11 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||
if kind == 4:
|
||||
table_columns = cursor.read_u32()
|
||||
table_rows = cursor.read_u32()
|
||||
if table_rows and not table_columns:
|
||||
raise ValueError("Result table with rows must have at least one column")
|
||||
value_count = table_columns * table_rows
|
||||
table_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||
table = (
|
||||
table_values.reshape((table_rows, table_columns))
|
||||
if value_count > 0 and table_columns > 0
|
||||
else np.zeros((0, 0), dtype=np.float32)
|
||||
)
|
||||
table = table_values.reshape((table_rows, table_columns))
|
||||
return ResultPayload(
|
||||
processing_name=name,
|
||||
kind=kind,
|
||||
|
||||
@@ -5,11 +5,11 @@ from __future__ import annotations
|
||||
import logging
|
||||
import mmap
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import time
|
||||
from typing import Final
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.orchestration.shm import atomics
|
||||
from python_app.orchestration.shm.decoder import (
|
||||
PREPROC_MAGIC,
|
||||
RAW_MAGIC,
|
||||
@@ -30,8 +30,8 @@ class ShmRingReader:
|
||||
|
||||
def __init__(self, ring_name: str, open_timeout_s: float = 2.0, open_poll_s: float = 0.01) -> None:
|
||||
"""Open and validate ring by name, for example `/radar_results`."""
|
||||
if not ring_name.startswith("/"):
|
||||
raise ValueError("ring_name must start with '/'")
|
||||
if not ring_name.startswith("/") or len(ring_name) < 2 or "/" in ring_name[1:] or "\x00" in ring_name:
|
||||
raise ValueError("ring_name must be '/' followed by a single nonempty filename")
|
||||
|
||||
self._ring_name = ring_name
|
||||
self._path = Path("/dev/shm") / ring_name[1:]
|
||||
@@ -79,7 +79,7 @@ class ShmRingReader:
|
||||
if sequence != read_seq + 1:
|
||||
# Producer overwrote this slot before we read it. Resync to latest.
|
||||
logger.debug("Ring %s: slot lapped before read, resyncing to write_seq=%d", self._ring_name, write_seq)
|
||||
self._write_u64(32, write_seq)
|
||||
atomics.advance(self._mmap, 32, write_seq)
|
||||
return None
|
||||
|
||||
payload_size = self._read_u32(slot_offset)
|
||||
@@ -92,20 +92,21 @@ class ShmRingReader:
|
||||
payload_size,
|
||||
self.slot_size_bytes,
|
||||
)
|
||||
self._write_u64(32, write_seq)
|
||||
atomics.advance(self._mmap, 32, write_seq)
|
||||
return None
|
||||
|
||||
payload_offset = slot_offset + _SLOT_HEADER_SIZE
|
||||
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
|
||||
atomics.fence()
|
||||
|
||||
# Re-read the slot sequence after the copy; if it changed, the producer
|
||||
# overwrote this slot mid-copy and the payload is torn — discard and resync.
|
||||
if self._read_u64(slot_offset + 8) != read_seq + 1:
|
||||
logger.debug("Ring %s: slot overwritten mid-copy, discarding torn payload", self._ring_name)
|
||||
self._write_u64(32, write_seq)
|
||||
atomics.advance(self._mmap, 32, write_seq)
|
||||
return None
|
||||
|
||||
self._write_u64(32, read_seq + 1)
|
||||
atomics.advance(self._mmap, 32, read_seq + 1)
|
||||
return payload
|
||||
|
||||
def pop_raw_collection(self) -> SweepCollection | None:
|
||||
@@ -157,17 +158,11 @@ class ShmRingReader:
|
||||
return None
|
||||
payload_offset = slot_offset + _SLOT_HEADER_SIZE
|
||||
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
|
||||
atomics.fence()
|
||||
if self._read_u64(slot_offset + 8) != latest_seq + 1:
|
||||
return None
|
||||
return payload
|
||||
|
||||
def peek_latest_result_collection(self) -> ResultCollection | None:
|
||||
"""Return the most recently published result collection without consuming it."""
|
||||
payload = self.peek_latest_payload()
|
||||
if payload is None:
|
||||
return None
|
||||
return decode_result_collection(payload)
|
||||
|
||||
def drop_all(self) -> int:
|
||||
"""Mark all unread slots as consumed and return number of dropped payloads."""
|
||||
write_seq = self._read_u64(24)
|
||||
@@ -175,7 +170,7 @@ class ShmRingReader:
|
||||
if read_seq >= write_seq:
|
||||
return 0
|
||||
dropped = int(write_seq - read_seq)
|
||||
self._write_u64(32, write_seq)
|
||||
atomics.advance(self._mmap, 32, write_seq)
|
||||
logger.debug("Ring %s: dropped %d unread payload(s)", self._ring_name, dropped)
|
||||
return dropped
|
||||
|
||||
@@ -196,7 +191,11 @@ class ShmRingReader:
|
||||
magic = self._mmap[:8]
|
||||
version = self._read_u32(8)
|
||||
if magic == _MAGIC and version == _VERSION:
|
||||
return
|
||||
capacity = self.capacity
|
||||
slot_size = self.slot_size_bytes
|
||||
expected_size = _HEADER_SIZE + capacity * (_SLOT_HEADER_SIZE + slot_size)
|
||||
if capacity > 0 and slot_size > 0 and slot_size % 8 == 0 and expected_size == len(self._mmap):
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
if magic != _MAGIC:
|
||||
@@ -206,29 +205,32 @@ class ShmRingReader:
|
||||
f"Shared memory ring magic mismatch for {self._ring_name}: "
|
||||
f"got=0x{got_magic}, expected=0x{expected_magic}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Shared memory ring version mismatch for {self._ring_name}: "
|
||||
f"got={version}, expected={_VERSION}"
|
||||
)
|
||||
if version != _VERSION:
|
||||
raise RuntimeError(
|
||||
f"Shared memory ring version mismatch for {self._ring_name}: "
|
||||
f"got={version}, expected={_VERSION}"
|
||||
)
|
||||
raise RuntimeError(f"Shared memory ring geometry mismatch for {self._ring_name}")
|
||||
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _wait_for_ring_file(self, timeout_s: float, poll_s: float) -> None:
|
||||
"""Wait until ring file appears in `/dev/shm`."""
|
||||
"""Wait for the creator to allocate the ring before mapping its header."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while not self._path.exists():
|
||||
while True:
|
||||
try:
|
||||
if self._path.stat().st_size >= _HEADER_SIZE:
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if time.monotonic() >= deadline:
|
||||
raise FileNotFoundError(f"Shared memory ring does not exist: {self._ring_name}")
|
||||
time.sleep(poll_s)
|
||||
|
||||
def _read_u32(self, offset: int) -> int:
|
||||
"""Read little-endian u32 at mmap offset."""
|
||||
return struct.unpack_from("<I", self._mmap, offset)[0]
|
||||
return atomics.load(self._mmap, offset, 4)
|
||||
|
||||
def _read_u64(self, offset: int) -> int:
|
||||
"""Read little-endian u64 at mmap offset."""
|
||||
return struct.unpack_from("<Q", self._mmap, offset)[0]
|
||||
|
||||
def _write_u64(self, offset: int, value: int) -> None:
|
||||
"""Write little-endian u64 at mmap offset."""
|
||||
struct.pack_into("<Q", self._mmap, offset, value)
|
||||
return atomics.load(self._mmap, offset, 8)
|
||||
|
||||
@@ -6,9 +6,11 @@ import logging
|
||||
import mmap
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
from typing import Final
|
||||
|
||||
from python_app.models.run_config_schema import RingEndpointModel
|
||||
from python_app.models.run_config_validation import validate_ring_endpoint
|
||||
from python_app.orchestration.shm import atomics
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_HEADER_SIZE: Final[int] = 64
|
||||
@@ -32,19 +34,21 @@ class ShmRingWriter:
|
||||
|
||||
def __init__(self, ring_name: str, capacity: int, slot_size_bytes: int) -> None:
|
||||
"""Open or create a POSIX SHM ring by name (as the ring owner)."""
|
||||
if not ring_name.startswith("/"):
|
||||
raise ValueError("ring_name must start with '/'")
|
||||
if capacity <= 0:
|
||||
raise ValueError("capacity must be > 0")
|
||||
if slot_size_bytes <= 0:
|
||||
raise ValueError("slot_size_bytes must be > 0")
|
||||
if not ring_name.startswith("/") or len(ring_name) < 2 or "/" in ring_name[1:] or "\x00" in ring_name:
|
||||
raise ValueError("ring_name must be '/' followed by a single nonempty filename")
|
||||
validate_ring_endpoint(RingEndpointModel(ring_name, capacity, slot_size_bytes))
|
||||
|
||||
self._ring_name = ring_name
|
||||
self._capacity = int(capacity)
|
||||
self._slot_size_bytes = int(slot_size_bytes)
|
||||
self._mapped_size = _HEADER_SIZE + self._capacity * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
|
||||
self._path = Path("/dev/shm") / ring_name[1:]
|
||||
self._open_owned()
|
||||
self._file = self._mmap = None
|
||||
try:
|
||||
self._open_owned()
|
||||
except BaseException:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def _open_owned(self) -> None:
|
||||
"""Open the ring, recreating it from scratch on a geometry/header mismatch.
|
||||
@@ -63,6 +67,10 @@ class ShmRingWriter:
|
||||
# under a producer/consumer that still expects the old layout.
|
||||
if not created:
|
||||
logger.info("Ring %s: stale segment with wrong size, recreating", self._ring_name)
|
||||
self._file.close()
|
||||
self._unlink_if_present()
|
||||
fd = os.open(self._path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o660)
|
||||
self._file = os.fdopen(fd, "r+b", buffering=0)
|
||||
self._file.truncate(self._mapped_size)
|
||||
created = True
|
||||
|
||||
@@ -84,7 +92,7 @@ class ShmRingWriter:
|
||||
self._mmap.close()
|
||||
self._file.close()
|
||||
self._unlink_if_present()
|
||||
fd = os.open(self._path, os.O_RDWR | os.O_CREAT, 0o660)
|
||||
fd = os.open(self._path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o660)
|
||||
self._file = os.fdopen(fd, "r+b", buffering=0)
|
||||
self._file.truncate(self._mapped_size)
|
||||
self._mmap = mmap.mmap(self._file.fileno(), self._mapped_size)
|
||||
@@ -101,8 +109,12 @@ class ShmRingWriter:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close mmap and file handle."""
|
||||
self._mmap.close()
|
||||
self._file.close()
|
||||
if self._mmap is not None:
|
||||
self._mmap.close()
|
||||
self._mmap = None
|
||||
if self._file is not None:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
logger.debug("Closed SHM ring writer %s", self._ring_name)
|
||||
|
||||
def push(self, payload: bytes) -> bool:
|
||||
@@ -121,13 +133,17 @@ class ShmRingWriter:
|
||||
# re-read it first and move it only forward: a concurrent reader may have
|
||||
# already advanced it, and clobbering that backward would re-deliver an
|
||||
# already-consumed slot as a duplicate.
|
||||
self._write_u64(32, max(self._read_u64(32), read_seq + 1))
|
||||
atomics.advance(self._mmap, 32, read_seq + 1)
|
||||
self._write_u64(40, self._read_u64(40) + 1)
|
||||
|
||||
index = write_seq % self._capacity
|
||||
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
|
||||
payload_offset = slot_offset + _SLOT_HEADER_SIZE
|
||||
|
||||
# Invalidate the previous publication before changing its bytes. A peek or
|
||||
# a lapped consumer must not accept the old sequence during this overwrite.
|
||||
self._write_u64(slot_offset + 8, 0)
|
||||
atomics.fence()
|
||||
self._write_u32(slot_offset, len(payload))
|
||||
self._write_u32(slot_offset + 4, 0)
|
||||
self._mmap[payload_offset : payload_offset + len(payload)] = payload
|
||||
@@ -146,7 +162,9 @@ class ShmRingWriter:
|
||||
return self._slot_size_bytes
|
||||
|
||||
def _initialize_header(self) -> None:
|
||||
self._mmap[:] = b"\x00" * self._mapped_size
|
||||
# A new ftruncate-extended segment is already zero-filled by the kernel;
|
||||
# avoid allocating and copying another complete ring just to initialize it.
|
||||
self._mmap[:_HEADER_SIZE] = b"\x00" * _HEADER_SIZE
|
||||
self._mmap[:8] = _MAGIC
|
||||
self._write_u32(8, _VERSION)
|
||||
self._write_u32(12, self._capacity)
|
||||
@@ -170,13 +188,13 @@ class ShmRingWriter:
|
||||
)
|
||||
|
||||
def _read_u32(self, offset: int) -> int:
|
||||
return struct.unpack_from("<I", self._mmap, offset)[0]
|
||||
return atomics.load(self._mmap, offset, 4)
|
||||
|
||||
def _read_u64(self, offset: int) -> int:
|
||||
return struct.unpack_from("<Q", self._mmap, offset)[0]
|
||||
return atomics.load(self._mmap, offset, 8)
|
||||
|
||||
def _write_u32(self, offset: int, value: int) -> None:
|
||||
struct.pack_into("<I", self._mmap, offset, int(value))
|
||||
atomics.store(self._mmap, offset, int(value), 4)
|
||||
|
||||
def _write_u64(self, offset: int, value: int) -> None:
|
||||
struct.pack_into("<Q", self._mmap, offset, int(value))
|
||||
atomics.store(self._mmap, offset, int(value), 8)
|
||||
|
||||
Reference in New Issue
Block a user