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
+146 -12
View File
@@ -13,13 +13,16 @@ from __future__ import annotations
import json
import os
import signal
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from python_app.models.run_config_model import RunConfigModel
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.process_supervisor import ProcessExitReport, ProcessSupervisor
from python_app.orchestration.restart_policy import RestartPolicy
@@ -53,6 +56,64 @@ class RestartPolicyTest(unittest.TestCase):
self.assertTrue(RestartPolicy().should_restart_now(now_s=5_000.0, last_restart_s=0.0, consecutive_failures=0))
class PipelineRecoveryPollingTest(unittest.TestCase):
def setUp(self) -> None:
self.window = mock.Mock(spec=AppWindowPipelineMixin)
self.window._pipeline_should_run = True
self.window._supervisor = mock.Mock()
self.window._supervisor.collect_exit_reports.return_value = []
def test_missing_child_keeps_retrying_without_fresh_exit_reports(self) -> None:
self.window._supervisor.pids.return_value = {"data_processor": 1, "data_preprocessor": 2}
for _ in range(3):
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.assertEqual(self.window._recover_pipeline_after_crash.call_count, 3)
def test_failed_start_with_no_children_keeps_retrying(self) -> None:
self.window._supervisor.pids.return_value = {}
for _ in range(3):
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.assertEqual(self.window._recover_pipeline_after_crash.call_count, 3)
def test_failed_restarts_retry_after_backoff_without_new_exit_reports(self) -> None:
self.window._supervisor.pids.return_value = {}
self.window._supervisor.start.side_effect = RuntimeError("device unavailable")
self.window._active_run_config = RunConfigModel()
self.window._active_run_config_path = Path("run_config.json")
self.window._pipeline_restart_count = 0
self.window._last_pipeline_restart_s = 0.0
self.window._RESTART_POLICY = RestartPolicy()
self.window._log_error = mock.Mock()
self.window._log_exception = mock.Mock()
self.window._recover_pipeline_after_crash = (
lambda: AppWindowPipelineMixin._recover_pipeline_after_crash(self.window)
)
with mock.patch(
"python_app.gui.controllers.app_window_pipeline_mixin.time.monotonic",
side_effect=[100.0, 101.0, 106.0],
):
for _ in range(3):
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.assertEqual(self.window._supervisor.start.call_count, 2)
self.assertEqual(self.window._pipeline_restart_count, 2)
self.assertTrue(self.window._pipeline_should_run)
def test_healthy_pipeline_does_not_restart(self) -> None:
self.window._supervisor.pids.return_value = {
"data_processor": 1, "data_preprocessor": 2, "sweep_orchestrator": 3,
}
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.window._recover_pipeline_after_crash.assert_not_called()
def test_explicit_stop_does_not_restart_missing_children(self) -> None:
self.window._pipeline_should_run = False
self.window._supervisor.pids.return_value = {}
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
self.window._recover_pipeline_after_crash.assert_not_called()
class ConfigWriterTest(unittest.TestCase):
def setUp(self) -> None:
self._dir = tempfile.TemporaryDirectory()
@@ -88,23 +149,32 @@ class RadarModelCommandTest(unittest.TestCase):
self.root = Path(self._dir.name)
self.supervisor = ProcessSupervisor(self.root)
def _config_with_model(self, model: str) -> Path:
def _config_with_model(self, model: str, driver_mode: str = "native") -> Path:
path = self.root / "cfg.json"
path.write_text(json.dumps({"radar": {"model": model}}))
path.write_text(json.dumps({"radar": {"model": model, "driver_mode": driver_mode}}))
return path
def test_read_radar_model(self) -> None:
self.assertEqual(ProcessSupervisor._read_radar_model(self._config_with_model("kamil_adc")), "kamil_adc")
def test_read_radar_model_defaults_on_malformed(self) -> None:
def test_malformed_root_falls_back_to_native_producer(self) -> None:
path = self.root / "bad.json"
path.write_text("[]") # not an object
self.assertEqual(ProcessSupervisor._read_radar_model(path), "librevna")
path.write_text("[]")
self.assertTrue(self.supervisor._acquisition_command(path)[0].endswith("sweep_orchestrator"))
def test_matrix_producer_for_multi_and_sn9000(self) -> None:
for model in ("librevna_multi", "sn9000"):
cmd = self.supervisor._acquisition_command(self._config_with_model(model))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_null_replay_directory_does_not_select_replay(self) -> None:
path = self._config_with_model("librevna")
path.write_text(json.dumps({"radar": {"replay": {"dataset_dir": None}}}))
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
[sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_replay_overrides_every_model(self) -> None:
for model in ("librevna", "librevna_multi", "kamil_adc"):
path = self._config_with_model(model)
path.write_text(json.dumps({"radar": {"model": model, "replay": {"dataset_dir": "capture"}}}))
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
[sys.executable, "-m", "python_app.scripts.replay_raw_producer"])
def test_matrix_producer_for_multi(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_kamil_producer_for_kamil_adc(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
@@ -114,6 +184,30 @@ class RadarModelCommandTest(unittest.TestCase):
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna"))
self.assertTrue(cmd[0].endswith("build/bin/sweep_orchestrator"))
def test_single_and_multi_mock_share_the_simulator(self) -> None:
for model in ("librevna", "librevna_multi"):
command = self.supervisor._acquisition_command(self._config_with_model(model, "mock"))
self.assertEqual(command[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_omitted_driver_mode_uses_the_default_simulator(self) -> None:
path = self._config_with_model("librevna")
path.write_text(json.dumps({"radar": {"model": "librevna"}}))
self.assertEqual(self.supervisor._acquisition_command(path)[2], "python_app.scripts.matrix_raw_producer")
def test_missing_or_null_radar_fields_keep_schema_defaults(self) -> None:
path = self.root / "defaults.json"
for payload in ({}, {"radar": None}, {"radar": {}}, {"radar": {"model": None}},
{"radar": {"driver_mode": None}}, {"radar": {"driver_mode": "mock"}}):
with self.subTest(payload=payload):
path.write_text(json.dumps(payload))
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
[sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_omitted_model_with_native_mode_uses_native_producer(self) -> None:
path = self.root / "native.json"
path.write_text(json.dumps({"radar": {"driver_mode": "native"}}))
self.assertTrue(self.supervisor._acquisition_command(path)[0].endswith("sweep_orchestrator"))
class ProcessExitReportTest(unittest.TestCase):
@staticmethod
@@ -192,6 +286,46 @@ class SupervisorLifecycleTest(unittest.TestCase):
self.assertFalse(self.supervisor._is_stale_pipeline_pid(2_000_000_000)) # no such pid
self.assertFalse(self.supervisor._is_stale_pipeline_pid(os.getpid())) # the test runner
def test_stale_pid_guard_matches_executable_or_module_arguments(self) -> None:
processor = str(self.supervisor._project_root / "build/bin/data_processor")
cases = (
(f"{processor}\x00--config\x00config.json\x00", True),
("python\x00-m\x00python_app.scripts.matrix_raw_producer\x00", True),
(f"bash\x00-c\x00echo {processor}\x00", False),
("python\x00-c\x00print('python_app.scripts.matrix_raw_producer')\x00", False),
("/other/project/build/bin/data_processor\x00", False),
)
for cmdline, expected in cases:
with (
self.subTest(cmdline=cmdline),
mock.patch.object(Path, "read_bytes", return_value=cmdline.encode()),
):
self.assertEqual(self.supervisor._is_stale_pipeline_pid(123), expected)
def test_signal_group_does_not_kill_a_non_leaders_group(self) -> None:
with (
mock.patch.object(os, "getpgid", return_value=456),
mock.patch.object(os, "killpg") as group_kill,
mock.patch.object(os, "kill") as kill,
):
self.supervisor._signal_group(123, signal.SIGTERM)
group_kill.assert_not_called()
kill.assert_called_once_with(123, signal.SIGTERM)
def test_collector_reaper_does_not_match_command_text(self) -> None:
entries = [Path("/proc/123"), Path("/proc/456")]
cmdlines = [
b"bash\x00-c\x00echo kamil_adc_collector\x00",
b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00",
]
with (
mock.patch.object(Path, "iterdir", return_value=entries),
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
):
self.supervisor._kill_kamil_collectors()
signal_group.assert_called_once_with(456, signal.SIGKILL)
if __name__ == "__main__":
unittest.main()