"""Object correction persistence, runtime export and capture lifecycle regressions.""" from __future__ import annotations from copy import deepcopy from dataclasses import asdict import os from pathlib import Path import tempfile from types import SimpleNamespace import unittest from unittest import mock import numpy as np os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6.QtWidgets import QApplication, QDialog # noqa: E402 from python_app.gui.controllers.object_calibration_capture import ObjectSweepCapture # noqa: E402 from python_app.gui.controllers.app_window_config.live_processing_mixin import AppWindowLiveProcessingMixin # noqa: E402 from python_app.gui.runtime.history import build_processor_run_signature, build_run_history_signature # noqa: E402 from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402 from python_app.models.run_config_model import ( # noqa: E402 ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel, ) from python_app.orchestration.config_writer import ConfigWriter # noqa: E402 from python_app.orchestration.preprocess_assets import runtime_preprocess_asset_keys # noqa: E402 from python_app.orchestration.shm.decoder import decode_trace_collection # noqa: E402 from python_app.storage.npz.store import NpzStore # noqa: E402 from python_app.storage.npz.serialize import RAW_MAGIC # noqa: E402 from python_app.workflows.mimo_calibration import C_LIGHT, calibration_on_grid # noqa: E402 def setUpModule() -> None: global _app _app = QApplication.instance() or QApplication([]) def _config() -> RunConfigModel: config = RunConfigModel() config.input_switch.positions = 3 config.output_switch.positions = 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" return config def _calibration(config: RunConfigModel) -> dict: frequency = np.array([1e8, 2e8, 3e8]) parameters = np.array([[.3, .02, 0], [-.8, -.01, 0], [.4, .03, 0], [-.1, -.02, 0]]) corrections = np.exp(-1j * (parameters[:, 0, None] - 2 * np.pi * frequency * parameters[:, 1, None] / C_LIGHT)) return dict( freq_hz=frequency, tx=corrections[:2], rx=corrections[2:], pair=corrections[:2, None] * corrections[None, 2:], valid=np.ones(3, bool), tx_output_pos=np.array([1, 3], np.int32), rx_input_pos=np.array([0, 2], np.int32), antenna_params=parameters, dir=None, meta={"geometry": { "tx_xyz_m": [[entry.x_m, entry.y_m, entry.z_m] for entry in config.gpr.tx_geometry], "rx_xyz_m": [[entry.x_m, entry.y_m, entry.z_m] for entry in config.gpr.rx_geometry], }}, ) class ObjectCalibrationStoreTest(unittest.TestCase): def setUp(self) -> None: temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) self.store = NpzStore(self.root / "sets") self.config = _config() self.calibration = _calibration(self.config) traces = [ TraceData(ComboKey(0, 1), np.array([1e8, 2e8, 3e8], np.float32), np.ones(3), np.ones(3)), TraceData(ComboKey(2, 3), np.array([1.2e8, 1.8e8, 2.6e8], np.float32), np.ones(3), np.ones(3)), ] for kind, name in (("s21_calibration", "through"), ("s21_reference", "empty")): self.store.save_set(kind, "radar", name, SweepCollection(1, 10, traces)) def test_default_disabled_and_legacy_config_round_trip(self) -> None: payload = self.config.to_dict() del payload["preprocess"]["s21"]["object_calibration"] config = RunConfigModel.from_dict(payload) self.assertEqual(config.preprocess.s21.object_calibration.set_name, "") self.assertNotIn("s21_object_calibration", runtime_preprocess_asset_keys(config)) original_run = build_run_history_signature(config) original_processor = build_processor_run_signature(config) config.preprocess.s21.object_calibration.set_name = "object" self.assertEqual(config.clone().preprocess.s21.object_calibration.set_name, "object") self.assertIn("s21_object_calibration", runtime_preprocess_asset_keys(config)) self.assertNotEqual(original_run, build_run_history_signature(config)) self.assertEqual(original_processor, build_processor_run_signature(config)) def test_saved_set_retains_coefficients_and_refuses_overwrite(self) -> None: self.store.save_object_calibration("radar", "object", self.calibration) self.assertEqual(self.store.list_sets("s21_object_calibration", "radar"), ["object"]) loaded = self.store.load_object_calibration("radar", "object") np.testing.assert_array_equal(loaded["pair"], self.calibration["pair"]) with self.assertRaises(FileExistsError): self.store.save_object_calibration("radar", "object", self.calibration) self.assertTrue(self.store.has_combo_coverage("s21_object_calibration", "radar", "object", [ComboKey(2, 3)])) self.assertFalse(self.store.has_combo_coverage("s21_object_calibration", "radar", "object", [ComboKey(1, 3)])) with self.assertRaises(ValueError): self.store.load_object_calibration("radar", "../object") def test_writer_exports_physical_pair_factors_on_each_runtime_grid(self) -> None: self.store.save_object_calibration("radar", "object", self.calibration) self.config.preprocess.s21.object_calibration.set_name = "object" ConfigWriter(self.root / "runtime").prepare_preprocess_bundles(self.store, "radar", self.config) bundle = Path(self.config.preprocess.s21.object_calibration.bundle_path) collection = decode_trace_collection(bundle.read_bytes(), RAW_MAGIC) self.assertEqual([trace.combo for trace in collection.traces], [ComboKey(0, 1), ComboKey(2, 3)]) for index, trace in enumerate(collection.traces): np.testing.assert_allclose(trace.s21, calibration_on_grid(self.calibration, trace.frequency_hz)[index, index], rtol=1e-6) np.testing.assert_array_equal(trace.s11, np.zeros(3)) self.config.preprocess.s21.object_calibration.set_name = "" ConfigWriter(self.root / "runtime").prepare_preprocess_bundles(self.store, "radar", self.config) self.assertEqual(self.config.preprocess.s21.object_calibration.bundle_path, "") def test_invalid_geometry_or_correction_keeps_previous_export(self) -> None: self.store.save_object_calibration("radar", "object", self.calibration) destination = self.root / "factors.bin" destination.write_bytes(b"previous bundle") self.config.gpr.tx_geometry[0].z_m = .1 with self.assertRaisesRegex(ValueError, "geometry"): self.store.export_object_calibration_bundle("radar", "object", destination, self.config) self.assertEqual(destination.read_bytes(), b"previous bundle") broken = deepcopy(self.calibration) broken["pair"][0, 0, 0] = np.nan with self.assertRaises(ValueError): self.store.save_object_calibration("radar", "bad", broken) self.assertEqual(self.store.list_sets("s21_object_calibration", "radar"), ["object"]) class ObjectCaptureLifecycleTest(unittest.TestCase): def setUp(self) -> None: self.dialog = mock.Mock() self.owner = SimpleNamespace( _stop_request_seq=0, _capture_session=None, _preprocess_capture_busy=False, _single_capture_active=False, _recording_active=False, _closing=False, _build_config=lambda: _config(), _pre_history=[], _SINGLE_CAPTURE_TIMEOUT_S=300, _ensure_preprocess_dialog=lambda: SimpleNamespace(median_sweep_count=lambda: 2), _supervisor=SimpleNamespace(is_running=lambda: self.running), ) self.running = False self.owner._pipeline_resume_allowed = lambda seq: not self.owner._closing and seq == self.owner._stop_request_seq self.owner._stop_run = mock.Mock(side_effect=self._stop) self.owner._start_run = mock.Mock(side_effect=self._start) def _stop(self) -> None: self.running = False self.owner._stop_request_seq += 1 def _start(self, **_kwargs) -> None: self.running = True def _capture(self) -> ObjectSweepCapture: with mock.patch("python_app.gui.controllers.object_calibration_capture.QTimer.singleShot"): view = QDialog() view.add_capture = self.dialog.add_capture view.set_status = self.dialog.set_status view.set_capture_pending = self.dialog.set_capture_pending self.addCleanup(view.close) self.view = view return ObjectSweepCapture(self.owner, view) def test_captures_fresh_preprocessed_matrix_without_changing_selected_object(self) -> None: config = _config() config.preprocess.s21.object_calibration.set_name = "existing" self.owner._build_config = lambda: config.clone() self.running = True capture = self._capture() capture._start() override = self.owner._start_run.call_args.kwargs["config_override"] self.assertEqual(override.preprocess.s21.object_calibration.set_name, "") self.assertEqual(len(override.combos), 4) self.assertEqual(config.preprocess.s21.object_calibration.set_name, "existing") stale = SweepCollection(1, capture._start_ns - 1) first, second = SweepCollection(2, capture._start_ns + 1), SweepCollection(3, capture._start_ns + 2) self.owner._pre_history = [stale, first, first, second] capture._poll() self.dialog.add_capture.assert_called_once() self.assertEqual([c.collection_id for c in self.dialog.add_capture.call_args.args[0]], [2, 3]) self.assertEqual(self.owner._start_run.call_count, 2) self.assertEqual(self.owner._start_run.call_args.kwargs, {}) capture.cancel() self.assertEqual(self.owner._stop_run.call_count, 2) def test_stop_cancels_capture_and_prevents_automatic_resume(self) -> None: self.running = True capture = self._capture() capture._start() self.owner._stop_run() capture._poll() self.dialog.add_capture.assert_not_called() self.assertEqual(self.owner._start_run.call_count, 1) self.assertFalse(self.running) def test_recording_is_not_interrupted(self) -> None: self.owner._recording_active = True capture = self._capture() capture._start() self.owner._start_run.assert_not_called() self.owner._stop_run.assert_not_called() self.dialog.add_capture.assert_not_called() self.dialog.set_capture_pending.assert_called_once_with(False) def test_stop_before_queued_start_never_opens_acquisition(self) -> None: capture = self._capture() self.owner._stop_run() capture._start() self.owner._start_run.assert_not_called() self.assertIsNone(self.owner._object_capture_job) def test_stale_capture_does_not_stop_a_new_run_after_explicit_stop(self) -> None: capture = self._capture() capture._start() self.owner._stop_run() self.owner._start_run() capture._poll() self.assertEqual(self.owner._stop_run.call_count, 1) self.assertTrue(self.running) self.dialog.add_capture.assert_not_called() def test_second_capture_is_rejected_without_mutating_first(self) -> None: first = self._capture() with self.assertRaisesRegex(RuntimeError, "already in progress"): self._capture() self.assertIs(self.owner._object_capture_job, first) first.cancel() def test_rejected_data_still_restores_original_running_pipeline(self) -> None: self.running = True self.dialog.add_capture.side_effect = ValueError("configuration changed") capture = self._capture() capture._start() self.owner._pre_history = [SweepCollection(2, capture._start_ns + 1), SweepCollection(3, capture._start_ns + 2)] capture._poll() self.assertTrue(self.running) self.assertEqual(self.owner._start_run.call_count, 2) self.assertIsNone(self.owner._object_capture_job) def test_capture_lock_is_held_while_stop_drains_events(self) -> None: capture = self._capture() capture._start() locks = [] original_stop = self.owner._stop_run.side_effect self.owner._stop_run.side_effect = lambda: (locks.append(self.owner._object_capture_job), original_stop()) capture.cancel() self.assertEqual(locks, [capture]) self.assertIsNone(self.owner._object_capture_job) def test_old_acquisition_with_new_preprocessing_timestamp_is_not_captured(self) -> None: capture = self._capture() capture._start() stale = SweepCollection(1, capture._start_ns + 100, capture_start_ns=capture._start_ns - 100) self.owner._pre_history = [stale] capture._poll() self.assertEqual(capture._collections, []) capture.cancel() def test_replay_is_rejected_before_acquisition(self) -> None: config = _config() config.radar.replay.dataset_dir = "saved_scene" self.owner._build_config = lambda: config capture = self._capture() capture._start() self.owner._start_run.assert_not_called() class ObjectGeometryLifecycleTest(unittest.TestCase): def setUp(self) -> None: config = _config() config.preprocess.s21.object_calibration.set_name = "object" self.tx, self.rx = [asdict(entry) for entry in config.gpr.tx_geometry], [asdict(entry) for entry in config.gpr.rx_geometry] self.owner = SimpleNamespace( _selected_preprocess_sets={"s21_object_calibration": "object"}, _selected_preprocess_radar_key="radar", _store=SimpleNamespace(load_object_calibration=mock.Mock(return_value=_calibration(config))), _gpr_tx_geometry_input=SimpleNamespace(records=lambda: self.tx), _gpr_rx_geometry_input=SimpleNamespace(records=lambda: self.rx), _supervisor=SimpleNamespace(is_running=lambda: True), _preprocess_dialog=mock.Mock(), _stop_request_seq=0, _refresh_preprocess_summary_labels=mock.Mock(), _start_run=mock.Mock(), _log_warning=mock.Mock(), _active_run_config=config, _radar_key=lambda _config: "radar", ) self.owner._pipeline_resume_allowed = lambda seq: seq == self.owner._stop_request_seq self.owner._stop_run = mock.Mock(side_effect=lambda: setattr(self.owner, "_stop_request_seq", self.owner._stop_request_seq + 1)) def test_same_geometry_retains_object_correction_without_restart(self) -> None: AppWindowLiveProcessingMixin._invalidate_object_calibration_after_geometry_change(self.owner) self.assertEqual(self.owner._selected_preprocess_sets["s21_object_calibration"], "object") self.owner._stop_run.assert_not_called() self.owner._start_run.assert_not_called() def test_changed_geometry_disables_correction_and_reloads_acquisition(self) -> None: self.tx[0]["z_m"] = .1 AppWindowLiveProcessingMixin._invalidate_object_calibration_after_geometry_change(self.owner) self.assertEqual(self.owner._selected_preprocess_sets["s21_object_calibration"], "") self.owner._stop_run.assert_called_once() self.owner._start_run.assert_called_once() self.owner._log_warning.assert_called_once() def test_explicit_stop_during_geometry_change_prevents_resume(self) -> None: self.tx[0]["z_m"] = .1 self.owner._stop_run.side_effect = lambda: setattr(self.owner, "_stop_request_seq", self.owner._stop_request_seq + 2) AppWindowLiveProcessingMixin._invalidate_object_calibration_after_geometry_change(self.owner) self.owner._start_run.assert_not_called() def test_pending_off_selection_still_reloads_an_incompatible_active_correction(self) -> None: self.owner._selected_preprocess_sets["s21_object_calibration"] = "" self.tx[0]["z_m"] = .1 AppWindowLiveProcessingMixin._invalidate_object_calibration_after_geometry_change(self.owner) self.owner._stop_run.assert_called_once() self.owner._start_run.assert_called_once() if __name__ == "__main__": unittest.main()