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
+36 -65
View File
@@ -21,8 +21,8 @@ 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.models.run_config_schema import RunConfigModel
from python_app.tests.test_reader_recovery import pipeline_fixture
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
@@ -52,66 +52,48 @@ class RestartPolicyTest(unittest.TestCase):
self.assertTrue(policy.should_restart_now(now_s=112.0, last_restart_s=100.0, consecutive_failures=2))
def test_first_restart_is_immediate(self) -> None:
# last_restart defaults to 0.0 in the mixin, so the very first crash restarts now.
# last_restart defaults to 0.0 in PipelineController, so the very first crash restarts now.
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 = []
self.pipeline, self.supervisor = pipeline_fixture(self)
self.pipeline.should_run = True
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_missing_children_keep_retrying_without_fresh_exit_reports(self) -> None:
with mock.patch.object(self.pipeline, "_recover_pipeline_after_crash") as recover:
for pids in ({"data_processor": 1, "data_preprocessor": 2}, {}):
with self.subTest(pids=pids):
recover.reset_mock()
self.supervisor.pids.return_value = pids
for _ in range(3):
self.pipeline.poll()
self.assertEqual(recover.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],
):
self.supervisor.start.side_effect = RuntimeError("device unavailable")
self.pipeline.active_config = RunConfigModel()
self.pipeline.active_config_path = Path("run_config.json")
with mock.patch("python_app.gui.controllers.pipeline_controller.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)
self.pipeline.poll()
self.assertEqual(self.supervisor.start.call_count, 2)
self.assertEqual(self.pipeline._restart_count, 2)
self.assertTrue(self.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()
self.supervisor.pids.return_value = {"data_processor": 1, "data_preprocessor": 2, "acquisition": 3}
self.pipeline.poll()
self.supervisor.start.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()
self.pipeline.stop()
self.pipeline.poll()
self.supervisor.start.assert_not_called()
self.assertFalse(self.pipeline.should_run)
self.assertEqual(self.pipeline.stop_generation, 1)
class ConfigWriterTest(unittest.TestCase):
@@ -154,10 +136,6 @@ class RadarModelCommandTest(unittest.TestCase):
path.write_text(json.dumps({"radar": {"model": model, "driver_mode": driver_mode}}))
return path
def test_malformed_root_falls_back_to_native_producer(self) -> None:
path = self.root / "bad.json"
path.write_text("[]")
self.assertTrue(self.supervisor._acquisition_command(path)[0].endswith("sweep_orchestrator"))
def test_null_replay_directory_does_not_select_replay(self) -> None:
path = self._config_with_model("librevna")
@@ -180,14 +158,12 @@ class RadarModelCommandTest(unittest.TestCase):
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
def test_native_orchestrator_for_librevna(self) -> None:
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:
def test_single_and_multi_use_python_in_both_modes(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"])
for mode in ("native", "mock"):
command = self.supervisor._acquisition_command(self._config_with_model(model, mode))
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")
@@ -203,11 +179,6 @@ class RadarModelCommandTest(unittest.TestCase):
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
@@ -273,7 +244,7 @@ class SupervisorLifecycleTest(unittest.TestCase):
self.assertEqual(reports[0].level, "ERROR")
def test_clean_exit_reported_as_info(self) -> None:
self.supervisor._spawn("sweep_orchestrator",
self.supervisor._spawn("acquisition",
[sys.executable, "-c", "import sys; sys.exit(0)"],
allow_clean_exit=True)
reports = self._await_reports()