Files
radar_system/python_app/tests/test_object_calibration_runtime.py
T
2026-10-06 12:32:26 +03:00

295 lines
15 KiB
Python

"""Object capture owns a pause; calculation saves against captured provenance."""
from __future__ import annotations
from contextlib import ExitStack
from pathlib import Path
import tempfile
import unittest
from unittest import mock
import numpy as np
from PyQt6 import sip
from PyQt6.QtWidgets import QApplication, QWidget
from python_app.gui.controllers.history_controller import HistoryController
from python_app.gui.controllers.object_calibration_capture import ObjectSweepCapture
from python_app.gui.controllers.pipeline_controller import PipelineController
from python_app.gui.controllers.plot_controller import PlotController
from python_app.gui.controllers.preprocess_controller import PreprocessController
from python_app.gui.controllers.recording_controller import RecordingController
from python_app.gui.runtime.pipeline_pause import PipelinePause
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.gui_profile_schema import GuiPreprocessDialogStateModel
from python_app.models.run_config_schema import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.storage.npz.store import NpzStore
from python_app.workflows.object_calibration import capture_provenance
from python_app.workflows.sequential_capture_workflow import CaptureBatch, SequentialCaptureSession, SequentialCaptureState
def setUpModule() -> None:
global app
app = QApplication.instance() or QApplication([])
def _config() -> RunConfigModel:
config = RunConfigModel()
config.input_switch.positions, config.output_switch.positions = 3, 4
config.combos = [ComboModel(0, 1), ComboModel(2, 3)]
config.gpr.tx_geometry = [GprTxGeometryModel(1, -.4), GprTxGeometryModel(3, .4)]
config.gpr.rx_geometry = [GprRxGeometryModel(0, -.2), GprRxGeometryModel(2, .2)]
config.preprocess.s21.calibration.set_name = "through"
config.preprocess.s21.reference.set_name = "empty"
config.rings.preprocessed_tap.name = "/object_capture_test_preprocessed_tap"
return config
def _through() -> SweepCollection:
return SweepCollection(1, 10, [
TraceData(ComboKey(i, o), np.array([1e8, 2e8, 3e8], np.float32), np.ones(3, np.complex64))
for o in (1, 3) for i in (0, 2)
])
class ObjectCaptureLifecycleTest(unittest.TestCase):
def setUp(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.config = _config()
self.store = NpzStore(self.root / "sets")
self.store.save_set("s21_calibration", self.config.radar_key(), "through", _through())
self.parent = QWidget()
self.addCleanup(self.parent.close)
self.running = False
self.supervisor = mock.create_autospec(ProcessSupervisor, instance=True)
self.supervisor.is_running.side_effect = lambda: self.running
self.supervisor.is_processor_running.return_value = False
self.supervisor.pids.side_effect = lambda: {"acquisition": 1, "data_preprocessor": 2} if self.running else {}
self.history = HistoryController()
self.plots = mock.create_autospec(PlotController, instance=True)
self.recording = RecordingController(self.store, {}, mock.Mock(), mock.Mock(), mock.Mock())
self.error = mock.Mock()
self.pipeline = PipelineController(
self.root, self.store, ConfigWriter(self.root / "runtime"), self.history, self.plots, self.recording,
build_config=lambda: self.config.clone(), live_config=ProcessingLiveConfig, publish_live=mock.Mock(),
capture_guard=lambda: self.preprocess.busy_reason(), refresh_radar_limits=lambda: True,
status=mock.Mock(), history_changed=mock.Mock(), poll_ui=mock.Mock(), show_exception=self.error,
deferred_close=mock.Mock(),
)
self.pipeline.supervisor = self.supervisor
self.start = cleanup.enter_context(mock.patch.object(self.pipeline, "start", side_effect=self._start))
self.stop = cleanup.enter_context(mock.patch.object(self.pipeline, "stop_pipeline", side_effect=self._stop))
self.preprocess = PreprocessController(
self.parent, self.config, GuiPreprocessDialogStateModel(median_sweep_count=2),
self.store, self.pipeline, self.plots, build_config=lambda: self.config.clone(),
current_radar_key=lambda: self.config.radar_key(), geometry=lambda: self.config.gpr,
summary=mock.Mock(), error=self.error,
)
self.addCleanup(self.preprocess.shutdown)
self.controller = self.preprocess.object_calibration
self.controller.open_creation("object")
assert self.controller.dialog is not None
self.dialog = self.controller.dialog
self.add_capture = cleanup.enter_context(mock.patch.object(self.dialog, "add_capture", wraps=self.dialog.add_capture))
cleanup.enter_context(mock.patch("python_app.gui.controllers.object_calibration_capture.QTimer.singleShot"))
def _stop(self, *, keep_processor: bool) -> None:
self.running, self.pipeline.should_run = False, False
def _start(self, *, config_override: RunConfigModel | None = None, pause: PipelinePause | None = None) -> bool:
if pause is not None:
if not pause.valid:
return False
elif self.preprocess.busy_reason() is not None:
raise RuntimeError("busy job was not cleared before resume")
self.running, self.pipeline.should_run = True, pause is None
return True
def _capture(self) -> ObjectSweepCapture:
self.controller.capture_data()
capture = self.controller.capture
assert capture is not None
capture._start()
return capture
def test_missing_tap_and_replay_report_error_before_pausing(self) -> None:
for invalid in ("tap", "replay"):
with self.subTest(invalid=invalid):
self.config = _config()
if invalid == "tap":
self.config.rings.preprocessed_tap.name = ""
else:
self.config.radar.replay.dataset_dir = "/recorded"
self.controller.capture_data()
self.assertIsNone(self.controller.capture)
self.assertFalse(self.dialog._capture_pending)
self.stop.assert_not_called()
self.start.assert_not_called()
self.assertEqual(self.error.call_count, 2)
def test_preprocess_refresh_signals_report_invalid_pending_config(self) -> None:
dialog = self.preprocess.ensure_dialog()
with mock.patch.object(self.preprocess, "_current_radar_key", side_effect=ValueError("incomplete sweep")):
dialog.refresh_requested.emit()
dialog.radar_config_dir_changed.emit()
dialog.multi_radar_option_changed.emit()
self.assertEqual(self.error.call_count, 3)
self.assertEqual(self.error.call_args.args[0], "Failed to refresh preprocessing sets")
def test_queued_capture_update_ignores_dialog_deleted_after_close(self) -> None:
dialog = self.preprocess.ensure_dialog()
session = mock.create_autospec(SequentialCaptureSession, instance=True)
session.is_complete.return_value = False
session.state.return_value = SequentialCaptureState("s21_reference", "reference", 1, 2, ComboModel(0, 1), True, False)
session.kind = "s21_reference"
session.capture_current_combo.return_value = CaptureBatch(ComboModel(0, 0), (_through().traces[0],))
self.preprocess.capture_session = session
with mock.patch.object(self.preprocess, "_record_capture"), \
mock.patch("python_app.gui.controllers.preprocess_controller.QTimer.singleShot") as scheduled:
self.preprocess.capture_next()
self.assertEqual(scheduled.call_count, 1)
callback = scheduled.call_args.args[1]
self.pipeline.begin_close()
self.preprocess.shutdown()
sip.delete(dialog)
try:
self.assertTrue(sip.isdeleted(dialog))
callback()
finally:
self.preprocess.dialog = None
def test_captures_fresh_exact_keys_and_clears_busy_job_before_resume(self) -> None:
self.config.preprocess.s21.object_calibration.set_name = "existing"
self.running = self.pipeline.should_run = True
capture = self._capture()
override: RunConfigModel = self.start.call_args.kwargs["config_override"]
self.assertEqual(override.preprocess.s21.object_calibration.set_name, "")
self.assertEqual(len(override.combos), 4)
self.assertEqual(self.config.preprocess.s21.object_calibration.set_name, "existing")
stale, first, second = [SweepCollection(1, capture._start_ns + 100,
capture_start_ns=capture._start_ns + offset, capture_end_ns=capture._start_ns + offset + 1)
for offset in (-1, 1, 2)]
self.history.preprocessed.extend([stale, first, first, second])
capture._poll()
self.add_capture.assert_called_once()
self.assertEqual(self.add_capture.call_args.args[0], [first, second])
self.assertEqual(self.add_capture.call_args.args[2],
capture_provenance(override.to_dict(), override.radar_key(), _through()))
self.assertEqual(self.start.call_count, 2)
self.assertIsNone(self.preprocess.object_capture)
self.assertFalse(self.pipeline.paused)
capture.cancel()
self.assertEqual(self.stop.call_count, 2)
self.assertEqual(self.pipeline.stop_generation, 0)
def test_stop_cancels_restart_intent_but_capture_cancel_restores_backoff(self) -> None:
for backoff, stop in ((False, True), (True, True), (True, False)):
with self.subTest(backoff=backoff, stop=stop):
self.running, self.pipeline.should_run = not backoff, backoff
self.start.reset_mock()
capture = self._capture()
self.assertTrue(self.pipeline.paused)
self.assertFalse(self.pipeline.should_run)
capture._poll()
self.assertEqual(self.start.call_count, 1)
if stop:
self.pipeline.stop()
capture._poll()
else:
capture.cancel()
self.add_capture.assert_not_called()
self.assertEqual(self.start.call_count, 1 if stop else 2)
self.assertEqual(self.running, not stop)
self.assertEqual(self.pipeline.should_run, not stop)
self.assertFalse(self.pipeline.paused)
self.assertIsNone(self.preprocess.object_capture)
def test_missing_producer_finishes_without_waiting_for_timeout(self) -> None:
capture = self._capture()
self.supervisor.pids.side_effect = None
self.supervisor.pids.return_value = {"data_preprocessor": 2}
capture._poll()
self.assertIsNone(self.preprocess.object_capture)
self.assertFalse(self.pipeline.paused)
self.assertIn("stopped before all sweeps", self.dialog._status_label.text())
self.add_capture.assert_not_called()
def test_cancel_during_start_keeps_lease_until_start_returns(self) -> None:
self.running = self.pipeline.should_run = True
def start(*, config_override: RunConfigModel | None = None, pause: PipelinePause | None = None) -> bool:
if pause is not None:
capture = self.preprocess.object_capture
assert capture is not None
capture.cancel()
self.assertIs(self.preprocess.object_capture, capture)
self.assertTrue(pause.valid)
return self._start(config_override=config_override, pause=pause)
self.start.side_effect = start
self._capture()
self.assertEqual(self.start.call_count, 2)
self.assertEqual(self.stop.call_count, 2)
self.assertIsNone(self.preprocess.object_capture)
self.assertFalse(self.pipeline.paused)
self.assertTrue(self.running)
self.add_capture.assert_not_called()
def test_stop_or_close_before_deferred_capture_start_does_not_touch_pipeline(self) -> None:
for closing in (False, True):
with self.subTest(closing=closing):
self.controller.capture_data()
capture = self.controller.capture
assert capture is not None
if closing:
self.pipeline.begin_close()
else:
self.pipeline.stop_generation += 1
capture._start()
self.start.assert_not_called()
self.stop.assert_not_called()
self.assertIsNone(self.preprocess.object_capture)
def test_calculation_preserves_captures_and_saves_only_success_with_original_provenance(self) -> None:
captured = _config()
provenance = capture_provenance(captured.to_dict(), captured.radar_key(), _through())
metadata = {"provenance": provenance, "targets": [], "diagnostics": [],
"tx_output_pos": [1, 3], "rx_input_pos": [0, 2]}
self.dialog.add_capture([_through()], captured.to_dict(), provenance)
for name, error in (("matching", None), ("changed", None),
("cancelled", InterruptedError("cancelled")), ("failed", ValueError("bad input"))):
with self.subTest(outcome=name):
self.config = captured.clone()
if name == "changed":
self.config.radar.sweep.points += 1
self.dialog._set_name_input.setText(name)
with mock.patch.object(self.dialog, "calculation_inputs", return_value=([], captured.to_dict(), {}, provenance)), \
mock.patch("python_app.workflows.mimo_calibration.compute_from_collections",
return_value=(_through(), metadata), side_effect=error):
self.controller._start_calculation()
worker = self.controller._worker
assert worker is not None
worker.join(timeout=2)
self.assertFalse(worker.is_alive())
self.controller._poll_calculation()
self.assertEqual(self.dialog._table.rowCount(), 1)
self.assertFalse(self.dialog._calculating)
self.assertEqual(self.preprocess.selected_sets["s21_object_calibration"], "matching")
if error is not None:
self.assertNotIn(name, self.store.list_sets("s21_object_calibration", captured.radar_key()))
else:
saved, saved_meta = self.store.load_set_with_metadata("s21_object_calibration", captured.radar_key(), name)
self.assertEqual(saved_meta["provenance"], provenance)
self.assertEqual(len(saved.traces), 4)
expected = {"matching": "Applies on the next Start", "changed": "Not selected",
"cancelled": "cancelled", "failed": "failed"}
self.assertIn(expected[name], self.dialog._status_label.text())
self.error.assert_called_once()