Rework radar system
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user