295 lines
16 KiB
Python
295 lines
16 KiB
Python
"""Object capture owns a pause; calculation saves against captured provenance."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack
|
|
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import numpy as np
|
|
from PyQt6 import sip
|
|
from PyQt6.QtWidgets import QApplication, QWidget
|
|
|
|
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([])
|
|
|
|
|
|
def _config() -> RunConfigModel:
|
|
config = RunConfigModel()
|
|
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 _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:
|
|
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.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, *, keep_processor: bool) -> None:
|
|
self.running, self.pipeline.should_run = False, False
|
|
|
|
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:
|
|
self.controller.capture_data()
|
|
capture = self.controller.capture
|
|
assert capture is not None
|
|
capture._start()
|
|
return capture
|
|
|
|
def test_missing_tap_reports_error_before_pausing(self) -> None:
|
|
self.config = _config()
|
|
self.config.rings.preprocessed_tap.name = ""
|
|
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.error.assert_called_once()
|
|
|
|
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(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.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.stop.call_count, 2)
|
|
self.assertEqual(self.pipeline.stop_generation, 0)
|
|
|
|
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()
|
|
self.supervisor.pids.side_effect = None
|
|
self.supervisor.pids.return_value = {"data_preprocessor": 2}
|
|
capture._poll()
|
|
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_cancel_during_start_keeps_lease_until_start_returns(self) -> None:
|
|
self.running = self.pipeline.should_run = True
|
|
|
|
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)
|
|
|
|
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.add_capture.assert_not_called()
|
|
|
|
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_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), ("unvalidated", 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
|
|
if name == "unvalidated": # Same radar key, but the selected through set is gone.
|
|
self.config.preprocess.s21.calibration.set_name = "absent"
|
|
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",
|
|
"unvalidated": "Not selected", "cancelled": "cancelled", "failed": "failed"}
|
|
self.assertIn(expected[name], self.dialog._status_label.text())
|
|
if name in ("matching", "unvalidated"): # Listed at once, without reopening the dialog.
|
|
combo = self.preprocess.ensure_dialog()._set_combos["s21_object_calibration"]
|
|
self.assertGreaterEqual(combo.findData(name), 0)
|
|
self.error.assert_called_once()
|