Add object calibration
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user