Files
radar_system/python_app/tests/test_object_calibration_runtime.py
T
2026-10-05 17:39:40 +03:00

320 lines
16 KiB
Python

"""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()