Add object calibration

This commit is contained in:
Ayzen
2026-10-05 17:39:40 +03:00
parent 8f097dfa2e
commit 552cdc688a
33 changed files with 3141 additions and 517 deletions
+199 -2
View File
@@ -17,12 +17,14 @@ from PyQt6.QtCore import QTimer # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.gui.controllers.app_window_config.profile_io_mixin import AppWindowConfigProfileIOMixin # noqa: E402
from python_app.gui.controllers.app_window_config.live_processing_mixin import AppWindowLiveProcessingMixin # noqa: E402
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin # noqa: E402
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin # noqa: E402
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin # noqa: E402
from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin # noqa: E402
from python_app.models.dataset_model import ResultCollection, SweepCollection # noqa: E402
from python_app.models.gui_profile_model import GuiProfileModel # noqa: E402
from python_app.models.run_config_model import RunConfigModel # noqa: E402
from python_app.models.gui_profile_model import GuiProfileModel, GuiStateModel # noqa: E402
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel # noqa: E402
from python_app.storage.npz_store import NpzStore # noqa: E402
from python_app.workflows.kamil_adc_neutral_preprocess import build_neutral_s21_sets # noqa: E402
@@ -264,6 +266,111 @@ class CaptureBatchControlTest(unittest.TestCase):
class GuiProfileWriteTest(unittest.TestCase):
def test_dataset_profile_uses_actual_preprocessing_and_preserves_current_gui(self) -> None:
for applied, selected in (("", "next_run"), ("applied", "")):
with self.subTest(applied=applied), tempfile.TemporaryDirectory() as folder:
pending = GuiProfileModel(gui=GuiStateModel())
pending.gui.processing.bscan.max_depth_m = 7.5
pending.run_config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=.4, y_m=.2, z_m=.1)]
pending.run_config.preprocess.s21.object_calibration.set_name = selected
active = pending.run_config.clone()
active.gpr.tx_geometry[0].x_m = -.4
active.radar.serial = "acquired_A"
active.radar.sweep.points = 129
active.input_switch.positions = 2
active.output_switch.positions = 2
active.combos = [ComboModel(input=1, output=1)]
pending.run_config.radar.serial = "pending_B"
pending.run_config.radar.sweep.points = 257
active.preprocess.s21.object_calibration.set_name = applied
active.preprocess.s21.object_calibration.bundle_path = "/bundles/object" if applied else ""
active.preprocess.s21.calibration.set_name = "actual_through"
active.preprocess.s21.calibration.bundle_path = "/bundles/through"
active.preprocess.s21.reference.set_name = "actual_reference"
active.preprocess.s21.reference.bundle_path = "/bundles/reference"
active.preprocess.s11.calibration.open.set_name = "actual_open"
active.preprocess.s11.calibration.open.bundle_path = "/bundles/open"
active.preprocess.s11.reference.set_name = "actual_s11_reference"
active.preprocess.notch.enabled = True
active.preprocess.notch.bands_hz = [(2e9, 2.1e9)]
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_profile = pending.clone
writer._active_run_config = active
dataset_path, settings_path = Path(folder) / "dataset.json", Path(folder) / "settings.json"
saved = writer._write_gui_profile_to_path(dataset_path, dataset=True)
writer._write_gui_profile_to_path(settings_path)
dataset = GuiProfileModel.load_from_path(dataset_path).to_dict()
expected = pending.to_dict()
acquired = active.to_dict()
for key in ("radar", "switches", "run", "preprocess"):
expected[key] = acquired[key]
expected["gpr"]["tx_geometry"] = acquired["gpr"]["tx_geometry"]
expected["gpr"]["rx_geometry"] = acquired["gpr"]["rx_geometry"]
self.assertEqual(dataset, expected)
self.assertEqual(GuiProfileModel.load_from_path(settings_path).to_dict(), pending.to_dict())
saved.run_config.preprocess.s21.calibration.set_name = "mutated_return_value"
self.assertEqual(active.preprocess.s21.calibration.set_name, "actual_through")
def test_dataset_profile_retains_original_device_and_geometry_after_loading_another_profile(self) -> None:
acquired = GuiProfileModel()
acquired.run_config.ensure_combos()
acquired.run_config.radar.serial = "device_A"
acquired.run_config.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=-.4)]
acquired.run_config.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=.4)]
pending = acquired.clone()
pending.run_config.radar.model = "librevna_multi"
pending.run_config.radar.serial = "device_B"
pending.run_config.gpr.tx_geometry[0].x_m = 2.
pending.run_config.gpr.rx_geometry[0].x_m = -2.
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_profile = pending.clone
writer._active_run_config = acquired.run_config
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dataset.json"
writer._write_gui_profile_to_path(path, dataset=True)
saved = GuiProfileModel.load_from_path(path)
self.assertEqual(saved.run_config.radar, acquired.run_config.radar)
self.assertEqual(saved.run_config.gpr, acquired.run_config.gpr)
self.assertEqual(saved.run_config.combos, acquired.run_config.combos)
def test_applied_geometry_tracks_successful_live_writes_only_while_acquiring(self) -> None:
for running, failed_write in ((True, False), (False, False), (True, True)):
with self.subTest(running=running, failed_write=failed_write):
active = RunConfigModel()
active.gpr.tx_geometry = [GprTxGeometryModel(output_pos=0, x_m=-.4)]
active.gpr.rx_geometry = [GprRxGeometryModel(input_pos=0, x_m=.4)]
original = active.clone()
writer = SimpleNamespace(
_active_run_config=active,
_supervisor=SimpleNamespace(is_running=lambda: running),
_live_config_writer=SimpleNamespace(write=mock.Mock(side_effect=OSError("disk full") if failed_write else None)),
_live_processing_config=mock.Mock(return_value=object()),
_can_reprocess_history=lambda: False,
_gpr_tx_geometry_input=SimpleNamespace(records=lambda: [dict(output_pos=0, x_m=1., y_m=2., z_m=3.)]),
_gpr_rx_geometry_input=SimpleNamespace(records=lambda: [dict(input_pos=0, x_m=-1., y_m=-2., z_m=-3.)]),
)
if failed_write:
with self.assertRaises(OSError):
AppWindowLiveProcessingMixin._write_live_processing_config(writer)
else:
AppWindowLiveProcessingMixin._write_live_processing_config(writer)
if running and not failed_write:
self.assertEqual(active.gpr.tx_geometry, [GprTxGeometryModel(0, 1., 2., 3.)])
self.assertEqual(active.gpr.rx_geometry, [GprRxGeometryModel(0, -1., -2., -3.)])
else:
self.assertEqual(active.gpr, original.gpr)
def test_dataset_profile_without_active_run_preserves_ui_selection(self) -> None:
writer = AppWindowConfigProfileIOMixin()
pending = GuiProfileModel()
pending.run_config.preprocess.s21.object_calibration.set_name = "selected"
writer._build_gui_profile = pending.clone
writer._active_run_config = None
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "dataset.json"
writer._write_gui_profile_to_path(path, dataset=True)
self.assertEqual(GuiProfileModel.load_from_path(path).to_dict(), pending.to_dict())
def test_failed_profile_replace_preserves_existing_profile_and_cleans_temporary(self) -> None:
writer = AppWindowConfigProfileIOMixin()
writer._build_gui_profile = lambda: GuiProfileModel()
@@ -293,5 +400,95 @@ class HistoryCommandRestoreTest(unittest.TestCase):
self.assertEqual(AppWindow._load_history_command_seq(path), 9876543210)
class PreprocessHistoryRestartTest(unittest.TestCase):
def _runtime(self):
w = _Runtime()
config = RunConfigModel.from_dict({"radar": {"model": "librevna", "driver_mode": "mock"}})
config.input_switch.driver = config.output_switch.driver = "h7992"
config.input_switch.radar_port, config.output_switch.radar_port = 1, 2
config.preprocess.s21.calibration.set_name = "through"
config.preprocess.s21.reference.set_name = "empty"
w._build_config.return_value = config
w._processor_run_signature = w._build_processor_run_signature(config)
w._history_run_signature = w._build_run_history_signature(config)
w._project_root = Path("/tmp/object-calibration-test")
w._store = mock.Mock()
w._store.has_combo_coverage.return_value = True
w._config_writer = mock.Mock()
w._config_writer.write.return_value = w._project_root / "runtime.json"
w._radar_key = mock.Mock(return_value="radar")
w._supervisor.pids.return_value = {}
w._bscan_reprocess_timer = mock.Mock()
for name in ("_validate_processing_mode_constraints", "_prepare_radar_for_native_acquisition",
"_close_replay_ring_writer", "_discard_bscan_replay_results", "_close_readers",
"_drop_pending_ring_payloads", "_reset_runtime_history", "_stop_all_processes",
"_write_live_processing_config"):
setattr(w, name, mock.Mock())
return w, config
def test_changed_preprocess_clears_native_history_before_both_start_modes(self) -> None:
for single_capture in (False, True):
with self.subTest(single_capture=single_capture):
w, config = self._runtime()
config.preprocess.s21.object_calibration.set_name = "sphere"
events = []
w._write_live_processing_config.side_effect = lambda **_kwargs: events.append("clear")
w._supervisor.start.side_effect = lambda *_args, **_kwargs: events.append("start")
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"):
w._start_run(single_capture=single_capture)
w._show_exception.assert_not_called()
w._write_live_processing_config.assert_called_once_with(
history_command="clear_all", bump_history_seq=True)
self.assertEqual(events, ["clear", "start"])
w._reset_runtime_history.assert_called_once()
self.assertEqual(w._history_run_signature, w._build_run_history_signature(config))
def test_unchanged_preprocess_retains_history_and_existing_processor(self) -> None:
w, config = self._runtime()
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"):
w._start_run(single_capture=True)
w._show_exception.assert_not_called()
w._write_live_processing_config.assert_not_called()
w._reset_runtime_history.assert_not_called()
w._stop_all_processes.assert_not_called()
def test_capture_override_is_cloned_and_skips_display_mode_constraints(self) -> None:
w, config = self._runtime()
w._object_capture_job = object()
with mock.patch("python_app.gui.controllers.app_window_pipeline_mixin.ShmRingReader"):
w._start_run(config_override=config)
w._show_exception.assert_not_called()
w._build_config.assert_not_called()
w._validate_processing_mode_constraints.assert_not_called()
exported = w._config_writer.prepare_preprocess_bundles.call_args.args[2]
self.assertIsNot(exported, config)
self.assertFalse(config.runtime.continuous)
self.assertTrue(exported.runtime.continuous)
def test_regular_start_and_other_hardware_actions_wait_for_object_capture(self) -> None:
for action, args in (("_start_run", ()), ("_capture_tmp_reference", ()),
("_create_neutral_sets", ()), ("_start_capture_sequence", ("s21_reference",)),
("_apply_radar_settings", ()), ("_save_snapshot", ()),
("_save_vna_history_json", ()), ("_remove_last_runtime_history", ()),
("_clear_all_runtime_history", ())):
with self.subTest(action=action):
w, _ = self._runtime()
w._object_capture_job = object()
getattr(w, action)(*args)
w._build_config.assert_not_called()
w._show_error.assert_called_once()
def test_profile_load_and_recording_wait_for_object_capture_even_before_pipeline_start(self) -> None:
for action in (AppWindowConfigProfileIOMixin._ensure_ready_to_load_config,
AppWindowRecordingMixin._start_run_with_recording):
with self.subTest(action=action.__name__):
w, _ = self._runtime()
w._object_capture_job = object()
self.assertFalse(action(w))
w._build_config.assert_not_called()
w._supervisor.is_running.assert_not_called()
w._show_error.assert_called_once()
if __name__ == "__main__":
unittest.main()