Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -1,319 +1,294 @@
"""Object correction persistence, runtime export and capture lifecycle regressions."""
"""Object capture owns a pause; calculation saves against captured provenance."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import asdict
import os
from contextlib import ExitStack
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
import numpy as np
from PyQt6 import sip
from PyQt6.QtWidgets import QApplication, QWidget
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
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([])
global app
app = QApplication.instance() or QApplication([])
def _config() -> RunConfigModel:
config = RunConfigModel()
config.input_switch.positions = 3
config.output_switch.positions = 4
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 _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"])
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:
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),
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.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)
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) -> None:
self.running = False
self.owner._stop_request_seq += 1
def _stop(self, *, keep_processor: bool) -> None:
self.running, self.pipeline.should_run = False, False
def _start(self, **_kwargs) -> None:
self.running = True
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:
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()
self.controller.capture_data()
capture = self.controller.capture
assert capture is not None
capture._start()
override = self.owner._start_run.call_args.kwargs["config_override"]
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(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]
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.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, {})
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.owner._stop_run.call_count, 2)
self.assertEqual(self.stop.call_count, 2)
self.assertEqual(self.pipeline.stop_generation, 0)
def test_stop_cancels_capture_and_prevents_automatic_resume(self) -> None:
self.running = True
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()
capture._start()
self.owner._stop_run()
self.supervisor.pids.side_effect = None
self.supervisor.pids.return_value = {"data_preprocessor": 2}
capture._poll()
self.dialog.add_capture.assert_not_called()
self.assertEqual(self.owner._start_run.call_count, 1)
self.assertFalse(self.running)
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_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_cancel_during_start_keeps_lease_until_start_returns(self) -> None:
self.running = self.pipeline.should_run = True
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 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)
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.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.dialog.add_capture.assert_not_called()
self.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_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_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()
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()