Rework radar system
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user