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
+4 -29
View File
@@ -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())
+6 -43
View File
@@ -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())
+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
@@ -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)]
+66
View File
@@ -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)
+4 -6
View File
@@ -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,
+30 -28
View File
@@ -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)
+35 -17
View File
@@ -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)