Add object calibration
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"""Optional selection, live sample input and real target-phase diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
from PyQt6.QtCore import QTimer # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication, QPushButton, QWidget # noqa: E402
|
||||
|
||||
from python_app.gui.controllers.object_calibration_controller import ObjectCalibrationController, _calculate # noqa: E402
|
||||
from python_app.gui.object_calibration_dialog import ObjectCalibrationDialog # noqa: E402
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog # noqa: E402
|
||||
from python_app.tests import test_mimo_calibration as numeric_fixture # noqa: E402
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
global app
|
||||
app = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class ObjectCalibrationSelectorTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.dialog = PreprocessDialog()
|
||||
self.addCleanup(self.dialog.close)
|
||||
|
||||
def test_optional_off_is_distinct_from_a_saved_set_named_off(self) -> None:
|
||||
self.dialog.set_available_sets({"s21_object_calibration": ["Off", "sphere"]})
|
||||
changes = mock.Mock()
|
||||
self.dialog.selection_changed.connect(changes)
|
||||
combo = self.dialog._set_combos["s21_object_calibration"]
|
||||
self.assertEqual(combo.currentText(), "Off")
|
||||
self.assertEqual(self.dialog.selection_snapshot()["s21_object_calibration"], "")
|
||||
combo.setCurrentIndex(combo.findData("Off"))
|
||||
self.assertEqual(self.dialog.selection_snapshot()["s21_object_calibration"], "Off")
|
||||
changes.assert_called_once()
|
||||
self.dialog.set_available_sets({"s21_object_calibration": ["Off", "sphere"]})
|
||||
self.assertEqual(self.dialog.selection_snapshot()["s21_object_calibration"], "Off")
|
||||
self.dialog.set_selected_sets({"s21_object_calibration": ""}, emit_signal=False)
|
||||
self.assertEqual(combo.currentText(), "Off")
|
||||
self.assertEqual(combo.currentData(), "")
|
||||
|
||||
def test_object_actions_do_not_start_a_sequential_hardware_capture(self) -> None:
|
||||
capture, imported, created = mock.Mock(), mock.Mock(), mock.Mock()
|
||||
self.dialog.start_sequence_requested.connect(capture)
|
||||
self.dialog.import_object_calibration_requested.connect(imported)
|
||||
self.dialog.create_object_calibration_requested.connect(created)
|
||||
self.dialog._import_object_calibration_button.click()
|
||||
self.dialog._create_object_calibration_button.click()
|
||||
imported.assert_called_once()
|
||||
created.assert_called_once()
|
||||
capture.assert_not_called()
|
||||
self.assertEqual([button.text() for button in self.dialog.findChildren(QPushButton)
|
||||
if button.text().startswith("Start ")],
|
||||
["Start S21 Calibration", "Start S21 Reference"])
|
||||
|
||||
|
||||
class ObjectCalibrationDialogTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.fixture = numeric_fixture.MimoCalibrationTest()
|
||||
self.fixture.setUp()
|
||||
self.dialog = ObjectCalibrationDialog("sphere")
|
||||
self.addCleanup(self.dialog.close)
|
||||
|
||||
def _capture_with_target(self) -> None:
|
||||
self.dialog.add_capture([self.fixture.collection()], self.fixture.config)
|
||||
for column, coordinate in enumerate(self.fixture.target, start=2):
|
||||
self.dialog._table.item(0, column).setText(str(coordinate))
|
||||
|
||||
def test_capture_is_frozen_and_xyz_must_be_entered_after_measurement(self) -> None:
|
||||
collection = self.fixture.collection()
|
||||
self.dialog.add_capture([collection], self.fixture.config)
|
||||
expected = collection.traces[0].s21.copy()
|
||||
collection.traces[0].s21.fill(0)
|
||||
with self.assertRaisesRegex(ValueError, "X, Y и Z"):
|
||||
self.dialog.calculation_inputs()
|
||||
for column, coordinate in enumerate(self.fixture.target, start=2):
|
||||
self.dialog._table.item(0, column).setText(str(coordinate))
|
||||
groups, config, settings = self.dialog.calculation_inputs()
|
||||
np.testing.assert_array_equal(groups[0]["collections"][0].traces[0].s21, expected)
|
||||
self.assertEqual(groups[0]["target_scene_m"], self.fixture.target)
|
||||
self.assertEqual(config, self.fixture.config)
|
||||
self.assertEqual(settings["input_idx"], (2, 7))
|
||||
self.assertEqual(settings["output_idx"], (1, 5))
|
||||
self.dialog._table.item(0, 2).setText("nan")
|
||||
with self.assertRaisesRegex(ValueError, "конечными"):
|
||||
self.dialog.calculation_inputs()
|
||||
|
||||
def test_changed_geometry_is_rejected_without_appending_a_capture(self) -> None:
|
||||
self._capture_with_target()
|
||||
changed = copy.deepcopy(self.fixture.config)
|
||||
changed["gpr"]["tx_geometry"][0]["x_m"] += 1
|
||||
with self.assertRaisesRegex(ValueError, "изменились"):
|
||||
self.dialog.add_capture([self.fixture.collection()], changed)
|
||||
self.assertEqual(self.dialog._table.rowCount(), 1)
|
||||
|
||||
def test_actual_numeric_output_draws_both_target_phase_curves(self) -> None:
|
||||
calibration = self.fixture.calibration()
|
||||
self.dialog.show_calibration(calibration)
|
||||
curves = self.dialog._phase_plot.listDataItems()
|
||||
self.assertEqual(len(curves), 2)
|
||||
diagnostic = calibration["meta"]["diagnostics"][0]
|
||||
phases = np.array([diagnostic["phase_before_deg"], diagnostic["phase_after_deg"]]).reshape(2, -1)
|
||||
expected = (phases - phases[0, 0] + 180) % 360 - 180
|
||||
for curve, phases in zip(curves, expected):
|
||||
np.testing.assert_allclose(curve.getData()[1], phases)
|
||||
self.assertLess(np.ptp(curves[1].getData()[1]), .5)
|
||||
self.assertIn(f"{diagnostic['coherence_before']:.3f}", self.dialog._quality_label.text())
|
||||
self.assertIn(f"{diagnostic['coherence_after']:.3f}", self.dialog._quality_label.text())
|
||||
|
||||
def test_phase_wrap_uses_the_same_first_pair_baseline_for_both_curves(self) -> None:
|
||||
calibration = self.fixture.calibration()
|
||||
diagnostic = calibration["meta"]["diagnostics"][0]
|
||||
diagnostic["phase_before_deg"] = [[179, -179], [178, -178]]
|
||||
diagnostic["phase_after_deg"] = [[-179, -179], [-179, -179]]
|
||||
self.dialog.show_calibration(calibration)
|
||||
before, after = self.dialog._phase_plot.listDataItems()
|
||||
np.testing.assert_allclose(before.getData()[1], [0, 2, -1, 3])
|
||||
np.testing.assert_allclose(after.getData()[1], [2, 2, 2, 2])
|
||||
|
||||
def test_capture_and_calculation_disable_edits_but_keep_cancel_available(self) -> None:
|
||||
self._capture_with_target()
|
||||
for setter in (self.dialog.set_capture_pending, self.dialog.set_calculating):
|
||||
setter(True)
|
||||
self.assertFalse(self.dialog._capture_button.isEnabled())
|
||||
self.assertFalse(self.dialog._table.isEnabled())
|
||||
self.assertTrue(self.dialog._cancel_button.isEnabled())
|
||||
setter(False)
|
||||
self.assertTrue(self.dialog._capture_button.isEnabled())
|
||||
self.assertTrue(self.dialog._table.isEnabled())
|
||||
|
||||
|
||||
class ObjectCalibrationWorkerTest(unittest.TestCase):
|
||||
def _controller_with_data(self):
|
||||
fixture = numeric_fixture.MimoCalibrationTest()
|
||||
fixture.setUp()
|
||||
owner = QWidget()
|
||||
self.addCleanup(owner.close)
|
||||
panel = SimpleNamespace(set_name=lambda: "sphere", set_set_name=mock.Mock(), set_status=mock.Mock())
|
||||
current_config = SimpleNamespace(to_dict=lambda: copy.deepcopy(fixture.config))
|
||||
owner._ensure_preprocess_dialog = mock.Mock(return_value=panel)
|
||||
owner._build_config = mock.Mock(return_value=current_config)
|
||||
owner._radar_key = mock.Mock(return_value="current-config-key")
|
||||
owner._store = mock.Mock()
|
||||
owner._selected_preprocess_sets = {}
|
||||
for name in ("_show_exception", "_show_error", "_refresh_sets", "_log"):
|
||||
setattr(owner, name, mock.Mock())
|
||||
controller = ObjectCalibrationController(owner)
|
||||
controller.open_creation()
|
||||
self.addCleanup(controller.shutdown)
|
||||
dialog = controller._dialog
|
||||
dialog.add_capture([fixture.collection()], fixture.config)
|
||||
for column, coordinate in enumerate(fixture.target, start=2):
|
||||
dialog._table.item(0, column).setText(str(coordinate))
|
||||
for key in ("sphere_radius_m", "antenna_height_m", "pitch_deg", "coarse_range_search_m"):
|
||||
dialog._settings[key].setValue(getattr(fixture.settings, key))
|
||||
for field in dialog._refine_axes.values():
|
||||
field.setChecked(False)
|
||||
return owner, controller, fixture, current_config
|
||||
|
||||
def _wait_for_completion(self, controller) -> None:
|
||||
deadline = time.monotonic() + 5
|
||||
while controller._worker is not None and time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(.005)
|
||||
self.assertIsNone(controller._worker, "Background calculation did not complete")
|
||||
|
||||
def test_actual_calculation_runs_off_gui_thread_and_saves_under_current_config(self) -> None:
|
||||
owner, controller, _, current_config = self._controller_with_data()
|
||||
from python_app.workflows.mimo_calibration import compute_from_collections
|
||||
|
||||
worker_threads, gui_ticks = [], []
|
||||
|
||||
def compute(*args, **kwargs):
|
||||
worker_threads.append(threading.get_ident())
|
||||
return compute_from_collections(*args, **kwargs)
|
||||
|
||||
timer = QTimer()
|
||||
timer.timeout.connect(lambda: gui_ticks.append(threading.get_ident()))
|
||||
timer.start(1)
|
||||
self.addCleanup(timer.stop)
|
||||
with mock.patch("python_app.workflows.mimo_calibration.compute_from_collections", side_effect=compute):
|
||||
controller._start_calculation()
|
||||
self.assertTrue(controller._dialog._calculating)
|
||||
self._wait_for_completion(controller)
|
||||
owner._show_exception.assert_not_called()
|
||||
self.assertEqual(len(worker_threads), 1)
|
||||
self.assertNotEqual(worker_threads[0], threading.get_ident())
|
||||
self.assertTrue(gui_ticks)
|
||||
self.assertEqual(set(gui_ticks), {threading.get_ident()})
|
||||
owner._radar_key.assert_called_once_with(current_config)
|
||||
key, name, calibration = owner._store.save_object_calibration.call_args.args
|
||||
self.assertEqual((key, name), ("current-config-key", "sphere"))
|
||||
self.assertEqual(owner._selected_preprocess_sets["s21_object_calibration"], name)
|
||||
self.assertEqual(len(controller._dialog._phase_plot.listDataItems()), 2)
|
||||
self.assertGreater(calibration["meta"]["diagnostics"][0]["coherence_after"], .999)
|
||||
|
||||
def test_cancelled_background_calculation_keeps_samples_and_creates_no_saved_set(self) -> None:
|
||||
owner, controller, _, _ = self._controller_with_data()
|
||||
entered = threading.Event()
|
||||
|
||||
def compute(*_args, cancelled, progress, **_kwargs):
|
||||
entered.set()
|
||||
progress("Safe cancellation boundary")
|
||||
while not cancelled():
|
||||
time.sleep(.005)
|
||||
raise InterruptedError
|
||||
|
||||
with mock.patch("python_app.workflows.mimo_calibration.compute_from_collections", side_effect=compute):
|
||||
controller._start_calculation()
|
||||
self.assertTrue(entered.wait(1))
|
||||
controller.cancel()
|
||||
self._wait_for_completion(controller)
|
||||
owner._store.save_object_calibration.assert_not_called()
|
||||
owner._show_exception.assert_not_called()
|
||||
self.assertEqual(controller._dialog._table.rowCount(), 1)
|
||||
self.assertTrue(controller._dialog._capture_button.isEnabled())
|
||||
self.assertIn("отменён", controller._dialog._status_label.text())
|
||||
|
||||
def test_thread_start_failure_restores_editor_without_saving(self) -> None:
|
||||
owner, controller, _, _ = self._controller_with_data()
|
||||
with mock.patch("python_app.gui.controllers.object_calibration_controller.threading.Thread.start",
|
||||
side_effect=RuntimeError("thread limit")):
|
||||
controller._start_calculation()
|
||||
self.assertIsNone(controller._worker)
|
||||
self.assertFalse(controller._dialog._calculating)
|
||||
self.assertTrue(controller._dialog._capture_button.isEnabled())
|
||||
owner._store.save_object_calibration.assert_not_called()
|
||||
owner._show_exception.assert_called_once()
|
||||
self.assertIn("thread limit", controller._dialog._status_label.text())
|
||||
|
||||
def test_capture_registration_failure_is_visible_and_does_not_leave_busy_dialog(self) -> None:
|
||||
owner, controller, _, _ = self._controller_with_data()
|
||||
with mock.patch("python_app.gui.controllers.object_calibration_capture.capture_object_sweeps",
|
||||
side_effect=RuntimeError("Capture already running")):
|
||||
controller._capture_data()
|
||||
self.assertIsNone(controller._capture)
|
||||
self.assertTrue(controller._dialog._capture_button.isEnabled())
|
||||
self.assertIn("Capture already running", controller._dialog._status_label.text())
|
||||
owner._show_exception.assert_called_once()
|
||||
|
||||
def test_import_waits_for_capture_or_calculation_before_opening_file_picker(self) -> None:
|
||||
owner, controller, _, _ = self._controller_with_data()
|
||||
for busy in ("capture", "calculation"):
|
||||
with self.subTest(busy=busy):
|
||||
owner._object_capture_job = object() if busy == "capture" else None
|
||||
controller._worker = mock.Mock() if busy == "calculation" else None
|
||||
with mock.patch("python_app.gui.controllers.object_calibration_controller.QFileDialog.getExistingDirectory") as picker:
|
||||
controller.import_calibration()
|
||||
picker.assert_not_called()
|
||||
owner._show_error.assert_called()
|
||||
controller._worker = None
|
||||
owner._object_capture_job = None
|
||||
|
||||
def test_worker_uses_only_data_and_honours_cancellation_without_subtracting_another_background(self) -> None:
|
||||
fixture = numeric_fixture.MimoCalibrationTest()
|
||||
fixture.setUp()
|
||||
cancelled, messages, result = threading.Event(), queue.Queue(), queue.Queue()
|
||||
cancelled.set()
|
||||
with mock.patch("python_app.workflows.mimo_calibration.compute_from_collections",
|
||||
side_effect=InterruptedError) as compute:
|
||||
_calculate([{"collections": [fixture.collection()], "target_scene_m": fixture.target}],
|
||||
fixture.config, {}, cancelled, messages, result)
|
||||
self.assertEqual(result.get_nowait(), ("cancelled", None))
|
||||
self.assertNotIn("empty_collections", compute.call_args.kwargs)
|
||||
|
||||
def test_calculate_rejects_invalid_xyz_before_starting_a_worker(self) -> None:
|
||||
owner = QWidget()
|
||||
self.addCleanup(owner.close)
|
||||
owner._ensure_preprocess_dialog = mock.Mock(return_value=SimpleNamespace(set_name=lambda: "sphere"))
|
||||
owner._show_exception = mock.Mock()
|
||||
controller = ObjectCalibrationController(owner)
|
||||
controller.open_creation()
|
||||
self.addCleanup(controller.shutdown)
|
||||
fixture = numeric_fixture.MimoCalibrationTest()
|
||||
fixture.setUp()
|
||||
controller._dialog.add_capture([fixture.collection()], fixture.config)
|
||||
with mock.patch("python_app.gui.controllers.object_calibration_controller.threading.Thread") as thread:
|
||||
controller._start_calculation()
|
||||
thread.assert_not_called()
|
||||
owner._show_exception.assert_called_once()
|
||||
self.assertFalse(controller._dialog._calculating)
|
||||
|
||||
def test_closing_cancels_capture_and_worker_without_waiting_for_a_thread(self) -> None:
|
||||
owner = QWidget()
|
||||
self.addCleanup(owner.close)
|
||||
controller = ObjectCalibrationController(owner)
|
||||
controller._dialog = ObjectCalibrationDialog("sphere", owner)
|
||||
controller._capture = mock.Mock()
|
||||
controller._worker = mock.Mock()
|
||||
controller.shutdown()
|
||||
self.assertTrue(controller._cancel.is_set())
|
||||
controller._capture.cancel.assert_called_once()
|
||||
controller._worker.join.assert_not_called()
|
||||
self.assertFalse(controller._poll.isActive())
|
||||
Reference in New Issue
Block a user