Add object calibration
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Physical MIMO estimation, legacy archives and failure-preserving writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.workflows import mimo_calibration as mc
|
||||
|
||||
|
||||
class MimoCalibrationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.settings = mc.Settings(input_idx=(2, 7), output_idx=(1, 5), sphere_radius_m=0.,
|
||||
antenna_height_m=1., f_min=2e9, f_max=6e9,
|
||||
refine_axes=(), coarse_range_search_m=0.)
|
||||
self.freq = np.linspace(2e9, 6e9, 129)
|
||||
self.target = (.2, .25, 4.)
|
||||
self.tx = np.array([[-.6, 0., 0.], [.6, 0., 0.]])
|
||||
self.rx = np.array([[-.3, .1, 0.], [.3, .1, 0.]])
|
||||
self.config = {
|
||||
'radar': {'model': 'librevna_multi', 'serial': 'master', 'multi_device': {'slave_serials': ['slave']}},
|
||||
'preprocess': {'s21': {'calibration': {'set_name': 'through'}, 'reference': {'set_name': 'empty'}}},
|
||||
'gpr': {
|
||||
'tx_geometry': [dict(output_pos=port, x_m=xyz[0], y_m=xyz[1], z_m=xyz[2]) for port, xyz in zip((1, 5), self.tx)],
|
||||
'rx_geometry': [dict(input_pos=port, x_m=xyz[0], y_m=xyz[1], z_m=xyz[2]) for port, xyz in zip((2, 7), self.rx)],
|
||||
},
|
||||
'run': {'combos': [dict(input=input_, output=output) for output in (1, 5) for input_ in (2, 7)]},
|
||||
}
|
||||
|
||||
def signal(self, target=None, *, gains=False):
|
||||
target = self.target if target is None else target
|
||||
pitch = np.radians(self.settings.pitch_deg)
|
||||
y0 = target[1] - self.settings.antenna_height_m
|
||||
radar_target = np.array([target[0], y0 * np.cos(pitch) + target[2] * np.sin(pitch),
|
||||
-y0 * np.sin(pitch) + target[2] * np.cos(pitch)])
|
||||
vt, vr = self.tx - radar_target, self.rx - radar_target
|
||||
dt, dr = np.linalg.norm(vt, axis=1), np.linalg.norm(vr, axis=1)
|
||||
paths = dt[:, None] + dr[None, :]
|
||||
if self.settings.sphere_radius_m:
|
||||
cosine = (vt @ vr.T) / (dt[:, None] * dr[None, :])
|
||||
paths -= 2 * self.settings.sphere_radius_m * np.sqrt((1 + cosine) / 2)
|
||||
tx_phase = np.array([0., .7])[:, None] - 2 * np.pi * self.freq * np.array([0., .02])[:, None] / mc.C_LIGHT
|
||||
rx_phase = np.array([0., -.5])[:, None] - 2 * np.pi * self.freq * np.array([0., -.013])[:, None] / mc.C_LIGHT
|
||||
signal = np.exp(-2j * np.pi * self.freq * paths[:, :, None] / mc.C_LIGHT)
|
||||
signal *= np.exp(1j * (tx_phase[:, None, :] + rx_phase[None, :, :] + 1.1))
|
||||
if gains:
|
||||
signal *= np.array([1., 2.])[:, None, None] * np.array([1., .5])[None, :, None]
|
||||
return signal, paths
|
||||
|
||||
def collection(self, samples=None, *, freq=None, identity=1):
|
||||
samples = self.signal()[0] if samples is None else samples
|
||||
freq = self.freq if freq is None else freq
|
||||
traces = [TraceData(ComboKey(input_, output), freq.copy(), np.zeros(freq.size, np.complex64), samples[t, r].copy())
|
||||
for t, output in enumerate((1, 5)) for r, input_ in enumerate((2, 7))]
|
||||
return SweepCollection(identity, identity, traces)
|
||||
|
||||
def calibration(self):
|
||||
return mc.compute_from_collections([{'collections': [self.collection()], 'target_scene_m': self.target}],
|
||||
self.config, settings=self.settings)
|
||||
|
||||
def archive(self, root, collection, *, name='0000_id1_ns1'):
|
||||
snapshot = root / 'preprocessed' / name
|
||||
snapshot.mkdir(parents=True)
|
||||
(root / 'config_profile.json').write_text(json.dumps(self.config))
|
||||
(snapshot / 'meta.json').write_text('{}')
|
||||
for trace in collection.traces:
|
||||
suffix = f'i{trace.combo.input}_o{trace.combo.output}'
|
||||
np.save(snapshot / f'{suffix}_s21.npy', trace.s21)
|
||||
np.save(snapshot / f'{suffix}_freq.npy', trace.frequency_hz)
|
||||
np.save(snapshot / f'{suffix}_s11.npy', trace.s11)
|
||||
return snapshot
|
||||
|
||||
def test_point_and_sphere_recover_relative_antenna_phases(self):
|
||||
for radius in (0., .16):
|
||||
with self.subTest(radius=radius):
|
||||
self.settings.sphere_radius_m = radius
|
||||
signal, paths = self.signal()
|
||||
cal = self.calibration()
|
||||
corrected = mc.apply_calibration(signal, self.freq, cal)
|
||||
contributions = corrected * np.exp(2j * np.pi * self.freq * paths[:, :, None] / mc.C_LIGHT)
|
||||
phases = np.angle(contributions.mean(axis=2))
|
||||
np.testing.assert_allclose(phases, np.full((2, 2), 1.1), atol=.004)
|
||||
np.testing.assert_allclose(np.abs(cal['pair']), 1., atol=2e-7)
|
||||
self.assertGreater(cal['meta']['quality']['coherence_final'][0], .9999)
|
||||
self.assertLess(cal['meta']['quality']['coherence_before'][0], .98)
|
||||
diagnostic = cal['meta']['diagnostics'][0]
|
||||
self.assertEqual(np.shape(diagnostic['phase_before_deg']), (2, 2))
|
||||
np.testing.assert_allclose(diagnostic['phase_after_deg'], np.full((2, 2), np.degrees(1.1)), atol=.25)
|
||||
self.assertIsNone(cal['meta']['quality']['coherence_leave_one_out'][0])
|
||||
|
||||
def test_amplitude_equalization_is_explicit(self):
|
||||
self.settings.apply_amplitude = True
|
||||
signal, paths = self.signal(gains=True)
|
||||
cal = mc.compute_from_collections([{'collections': [self.collection(signal)], 'target_scene_m': self.target}],
|
||||
self.config, settings=self.settings)
|
||||
corrected = mc.apply_calibration(signal, self.freq, cal)
|
||||
# Compare amplitudes after removing the physical propagation phase.
|
||||
levels = np.abs((corrected * np.exp(2j * np.pi * self.freq * paths[:, :, None] / mc.C_LIGHT)).mean(axis=2))
|
||||
self.assertLess(levels.max() / levels.min(), 1.01)
|
||||
self.assertGreater(np.max(np.abs(cal['pair'])), 1.5)
|
||||
|
||||
def test_archive_and_live_estimation_share_the_same_math(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
signal = self.signal()[0]
|
||||
self.archive(root, self.collection(signal), name='0000_id1_ns1')
|
||||
self.archive(root, self.collection(signal * 2), name='0001_id2_ns2')
|
||||
archived = mc.compute_calibration([{'path': root / 'preprocessed', 'target_scene_m': self.target}], settings=self.settings)
|
||||
live = mc.compute_from_collections([{'collections': [self.collection(signal), self.collection(signal * 2)],
|
||||
'target_scene_m': self.target}], self.config, settings=self.settings)
|
||||
np.testing.assert_array_equal(archived['pair'], live['pair'])
|
||||
|
||||
def test_frequency_geometry_channel_and_signal_failures_are_explicit(self):
|
||||
cases = []
|
||||
incomplete = self.collection(); incomplete.traces.pop(); cases.append(incomplete)
|
||||
duplicate = self.collection(); duplicate.traces.append(duplicate.traces[0]); cases.append(duplicate)
|
||||
mismatched = self.collection(); mismatched.traces[0].frequency_hz += 10000; cases.append(mismatched)
|
||||
nonuniform = self.collection(); nonuniform.traces[0].frequency_hz[20] += 1e6; cases.append(nonuniform)
|
||||
zero = self.collection(np.zeros((2, 2, self.freq.size), complex)); cases.append(zero)
|
||||
invalid = self.collection(); invalid.traces[0].s21[0] = np.nan; cases.append(invalid)
|
||||
for index, collection in enumerate(cases):
|
||||
with self.subTest(collection=index):
|
||||
with self.assertRaises(ValueError):
|
||||
mc.compute_from_collections([{'collections': [collection], 'target_scene_m': self.target}], self.config, settings=self.settings)
|
||||
with self.assertRaisesRegex(ValueError, 'at least one'):
|
||||
mc.compute_from_collections([], self.config, settings=self.settings)
|
||||
|
||||
def test_config_checks_phase_base_and_geometry_but_allows_new_reference(self):
|
||||
cal = self.calibration()
|
||||
updated = copy.deepcopy(self.config)
|
||||
updated['preprocess']['s21']['reference']['set_name'] = 'new_background'
|
||||
mc.validate_for_config(cal, updated)
|
||||
for edit in ('geometry', 'through', 'combo', 'serial'):
|
||||
updated = copy.deepcopy(self.config)
|
||||
if edit == 'geometry':
|
||||
updated['gpr']['tx_geometry'][0]['x_m'] += .01
|
||||
elif edit == 'through':
|
||||
updated['preprocess']['s21']['calibration']['set_name'] = 'new_through'
|
||||
elif edit == 'serial':
|
||||
updated['radar']['serial'] = 'another_device'
|
||||
else:
|
||||
updated['run']['combos'].append({'input': 999, 'output': 1})
|
||||
with self.subTest(edit=edit), self.assertRaises(ValueError):
|
||||
mc.validate_for_config(cal, updated)
|
||||
|
||||
def test_object_corrected_sources_are_rejected_for_live_and_archives(self):
|
||||
for field in ('set_name', 'bundle_path'):
|
||||
self.config['preprocess']['s21']['object_calibration'] = {field: 'active'}
|
||||
with self.subTest(field=field), self.assertRaisesRegex(ValueError, 'already active'):
|
||||
self.calibration()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
self.archive(root, self.collection())
|
||||
with self.assertRaisesRegex(ValueError, 'already active'):
|
||||
mc.compute_calibration([{'path': root / 'preprocessed', 'target_scene_m': self.target}], settings=self.settings)
|
||||
|
||||
def test_cancel_is_checked_inside_refinement(self):
|
||||
self.settings.refine_axes = ('z',)
|
||||
state = {'estimating': False, 'checks': 0}
|
||||
|
||||
def progress(message):
|
||||
state['estimating'] |= message.startswith('Estimating')
|
||||
|
||||
def cancelled():
|
||||
if state['estimating']:
|
||||
state['checks'] += 1
|
||||
return state['checks'] >= 6
|
||||
|
||||
with self.assertRaises(InterruptedError):
|
||||
mc.compute_from_collections([{'collections': [self.collection()], 'target_scene_m': self.target}],
|
||||
self.config, settings=self.settings, progress=progress, cancelled=cancelled)
|
||||
self.assertEqual(state['checks'], 6)
|
||||
|
||||
def test_apply_rejects_broadcasting_and_corrupted_archives(self):
|
||||
cal = self.calibration()
|
||||
with self.assertRaisesRegex(ValueError, 'invalid shape'):
|
||||
mc.apply_calibration(np.ones(self.freq.size), self.freq, cal)
|
||||
cal['pair'][0, 0, 0] *= 2
|
||||
with self.assertRaisesRegex(ValueError, 'disagree'):
|
||||
mc.validate_calibration(cal)
|
||||
|
||||
def test_interpolation_is_continuous_at_nonlinear_archive_edges(self):
|
||||
self.settings.phase_model = 'raw'
|
||||
cal = self.calibration()
|
||||
# Keep a consistent separable archive with a deliberately nonlinear phase.
|
||||
phase = .4 * ((self.freq - self.freq.mean()) / 1e9) ** 3
|
||||
cal['tx'][1] *= np.exp(1j * phase)
|
||||
cal['pair'] = cal['tx'][:, None] * cal['rx'][None]
|
||||
edge = mc.calibration_on_grid(cal, [self.freq[-1], self.freq[-1] + 1])
|
||||
self.assertLess(abs(np.angle(edge[1, 0, 1] / edge[1, 0, 0])), 1e-6)
|
||||
shifted = mc.calibration_on_grid(cal, self.freq + 10000)
|
||||
self.assertIsNot(shifted, cal['pair'])
|
||||
|
||||
def test_save_roundtrip_and_write_failure_preserve_the_previous_archive(self):
|
||||
cal = self.calibration()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
destination = Path(directory) / 'cal'
|
||||
destination.mkdir()
|
||||
(destination / 'notes.txt').write_text('preserve user notes')
|
||||
mc.save_calibration(cal, destination)
|
||||
np.testing.assert_array_equal(mc.load_calibration(destination)['pair'], cal['pair'])
|
||||
original = {path.name: path.read_bytes() for path in destination.iterdir()}
|
||||
save = np.save
|
||||
|
||||
def fail_second(path, *args, **kwargs):
|
||||
if Path(path).name == 'cal_pair.npy':
|
||||
raise OSError('disk full')
|
||||
return save(path, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(mc.np, 'save', side_effect=fail_second), self.assertRaises(OSError):
|
||||
mc.save_calibration(cal, destination, overwrite=True)
|
||||
self.assertEqual(original, {path.name: path.read_bytes() for path in destination.iterdir()})
|
||||
self.assertEqual(list(Path(directory).glob('.cal.*')), [])
|
||||
with self.assertRaises(FileExistsError):
|
||||
mc.save_calibration(cal, destination)
|
||||
|
||||
def test_non_overwriting_save_preserves_archive_published_during_staging(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
destination = Path(directory) / 'cal'
|
||||
with self.assertRaises(FileExistsError):
|
||||
with mc._staged_directory(destination, False) as stage:
|
||||
(stage / 'cal_pair.npy').write_bytes(b'new correction')
|
||||
destination.mkdir()
|
||||
(destination / 'cal_pair.npy').write_bytes(b'concurrent correction')
|
||||
(destination / 'cal_meta.json').write_bytes(b'concurrent metadata')
|
||||
self.assertEqual({path.name: path.read_bytes() for path in destination.iterdir()}, {
|
||||
'cal_pair.npy': b'concurrent correction', 'cal_meta.json': b'concurrent metadata',
|
||||
})
|
||||
self.assertEqual(list(Path(directory).glob('.cal.*')), [])
|
||||
|
||||
def test_failed_commit_restores_old_archive_and_failed_rollback_keeps_backups(self):
|
||||
cal = self.calibration()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
destination = Path(directory) / 'cal'
|
||||
mc.save_calibration(cal, destination)
|
||||
original = (destination / 'cal_pair.npy').read_bytes()
|
||||
replace = Path.replace
|
||||
|
||||
def fail_commit(path, target):
|
||||
if path.name.startswith('.cal.new-'):
|
||||
raise OSError('rename failed')
|
||||
return replace(path, target)
|
||||
|
||||
with mock.patch.object(Path, 'replace', autospec=True, side_effect=fail_commit), self.assertRaises(OSError):
|
||||
mc.save_calibration(cal, destination, overwrite=True)
|
||||
self.assertEqual((destination / 'cal_pair.npy').read_bytes(), original)
|
||||
|
||||
def fail_rollback(path, target):
|
||||
if path.name.startswith(('.cal.new-', '.cal.old-')):
|
||||
raise OSError('filesystem unavailable')
|
||||
return replace(path, target)
|
||||
|
||||
with mock.patch.object(Path, 'replace', autospec=True, side_effect=fail_rollback), self.assertRaisesRegex(RuntimeError, 'originals retained'):
|
||||
mc.save_calibration(cal, destination, overwrite=True)
|
||||
backup = next(Path(directory).glob('.cal.old-*'))
|
||||
self.assertEqual((backup / 'cal_pair.npy').read_bytes(), original)
|
||||
|
||||
def test_offline_failure_preserves_previous_postprocessed_and_original_data(self):
|
||||
cal = self.calibration()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot = self.archive(root, self.collection())
|
||||
post = root / 'postprocessed'; post.mkdir()
|
||||
(post / 'calibration_applied.json').write_text('{}')
|
||||
(post / 'old_data.bin').write_bytes(b'old data')
|
||||
original = {path.name: path.read_bytes() for path in snapshot.iterdir()}
|
||||
with mock.patch.object(mc.np, 'save', side_effect=OSError('disk full')), self.assertRaises(OSError):
|
||||
mc.apply_to_measurement(root, cal, overwrite=True)
|
||||
self.assertEqual((post / 'old_data.bin').read_bytes(), b'old data')
|
||||
self.assertEqual(original, {path.name: path.read_bytes() for path in snapshot.iterdir()})
|
||||
self.assertEqual(list(root.glob('.postprocessed.*')), [])
|
||||
|
||||
def test_object_corrected_archive_is_rejected_even_when_source_profile_has_object_off(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot = self.archive(root, self.collection())
|
||||
post, *_ = mc.apply_to_measurement(root, self.calibration())
|
||||
for prefixes in (None, [snapshot.name[:4]]):
|
||||
with self.subTest(prefixes=prefixes), self.assertRaisesRegex(ValueError, 'already applied'):
|
||||
mc.compute_calibration([{'path': post, 'snapshots': prefixes, 'target_scene_m': self.target}],
|
||||
settings=self.settings)
|
||||
# A corrected sibling does not change the original preprocessing data.
|
||||
original = mc.compute_calibration([{'path': root / 'preprocessed', 'target_scene_m': self.target}],
|
||||
settings=self.settings)
|
||||
np.testing.assert_array_equal(original['pair'], self.calibration()['pair'])
|
||||
|
||||
def test_snapshot_local_correction_marker_is_checked_for_target_and_empty_scene(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot = self.archive(root, self.collection())
|
||||
(snapshot / 'calibration_applied.json').write_text('{}')
|
||||
targets = [{'path': root / 'preprocessed', 'snapshots': [snapshot.name[:4]],
|
||||
'target_scene_m': self.target}]
|
||||
with self.assertRaisesRegex(ValueError, 'already applied'):
|
||||
mc.compute_calibration(targets, settings=self.settings)
|
||||
untouched = root / 'untouched'
|
||||
self.archive(untouched, self.collection())
|
||||
targets = [{'path': untouched / 'preprocessed', 'target_scene_m': self.target}]
|
||||
with self.assertRaisesRegex(ValueError, 'already applied'):
|
||||
mc.compute_calibration(targets, root / 'preprocessed', self.settings)
|
||||
|
||||
def test_offline_grid_cache_uses_the_entire_axis_and_preserves_s11_and_dtype(self):
|
||||
cal = self.calibration()
|
||||
collection = self.collection(np.ones((2, 2, self.freq.size), np.complex64))
|
||||
alternate = self.freq.copy()
|
||||
alternate[64] += 1e7
|
||||
collection.traces[2].frequency_hz = alternate
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
snapshot = self.archive(root, collection)
|
||||
original_s11 = (snapshot / 'i2_o5_s11.npy').read_bytes()
|
||||
post, count, fixed, _ = mc.apply_to_measurement(root, cal)
|
||||
self.assertEqual((count, fixed), (1, 4))
|
||||
corrected = np.load(post / snapshot.name / 'i2_o5_s21.npy')
|
||||
self.assertEqual(corrected.dtype, np.complex64)
|
||||
expected = mc.calibration_on_grid(cal, alternate)[1, 0]
|
||||
np.testing.assert_allclose(corrected, expected, atol=1e-7)
|
||||
self.assertGreater(abs(corrected[64] - cal['pair'][1, 0, 64]), .001)
|
||||
self.assertEqual((post / snapshot.name / 'i2_o5_s11.npy').read_bytes(), original_s11)
|
||||
|
||||
def test_empty_scene_grids_and_geometry_are_checked_before_subtraction(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target_root, empty_root = root / 'target', root / 'empty'
|
||||
self.archive(target_root, self.collection())
|
||||
zero = np.zeros((2, 2, self.freq.size), complex)
|
||||
self.archive(empty_root, self.collection(zero))
|
||||
self.archive(empty_root, self.collection(zero, freq=self.freq + 10000), name='0001_id2_ns2')
|
||||
targets = [{'path': target_root / 'preprocessed', 'target_scene_m': self.target}]
|
||||
with self.assertRaisesRegex(ValueError, 'frequency grids differ'):
|
||||
mc.compute_calibration(targets, empty_root / 'preprocessed', self.settings)
|
||||
profile = copy.deepcopy(self.config)
|
||||
profile['gpr']['rx_geometry'][0]['z_m'] += .1
|
||||
(empty_root / 'config_profile.json').write_text(json.dumps(profile))
|
||||
with self.assertRaisesRegex(ValueError, 'antenna geometry differs'):
|
||||
mc.compute_calibration(targets, empty_root / 'preprocessed', self.settings)
|
||||
|
||||
def test_snapshot_prefixes_must_select_one_distinct_complete_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
self.archive(root, self.collection())
|
||||
self.archive(root, self.collection(), name='0001_id2_ns2')
|
||||
for prefixes in (['000'], ['0000', 'missing'], ['0000', '0000']):
|
||||
with self.subTest(prefixes=prefixes), self.assertRaises(ValueError):
|
||||
mc.list_snapshots(root / 'preprocessed', prefixes)
|
||||
|
||||
def test_multiple_snapshots_are_averaged_before_one_gated_fft(self):
|
||||
collections = [self.collection(identity=index + 1) for index in range(8)]
|
||||
with mock.patch.object(mc.np.fft, 'ifft', wraps=np.fft.ifft) as transform:
|
||||
mc.compute_from_collections([{'collections': collections, 'target_scene_m': self.target}],
|
||||
self.config, settings=self.settings)
|
||||
self.assertEqual(transform.call_count, 1)
|
||||
|
||||
def test_original_scipy_refinement_finishes_with_valid_diagnostics(self):
|
||||
self.settings.refine_axes = ('z',)
|
||||
self.settings.coarse_range_search_m = .1
|
||||
cal = self.calibration()
|
||||
self.assertGreater(cal['meta']['diagnostics'][0]['coherence_after'], .999)
|
||||
refined = cal['meta']['targets'][0]['refined_scene_m']
|
||||
self.assertTrue(np.all(np.isfinite(refined)))
|
||||
|
||||
def test_bad_settings_and_duplicate_geometry_fail_early(self):
|
||||
invalid_settings: tuple[dict[str, Any], ...] = (
|
||||
{'input_idx': (0, 0)}, {'output_idx': (True,)}, {'gate_taper_m': 0.},
|
||||
{'f_max': 0.}, {'refine_axes': ('z', 'z')}, {'phase_model': 'unknown'},
|
||||
)
|
||||
for fields in invalid_settings:
|
||||
with self.subTest(fields=fields), self.assertRaises(ValueError):
|
||||
mc.Settings(**fields)
|
||||
self.config['gpr']['tx_geometry'].append(self.config['gpr']['tx_geometry'][0])
|
||||
with self.assertRaisesRegex(ValueError, 'duplicate antenna geometry'):
|
||||
self.calibration()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Object correction persistence, runtime export and capture lifecycle regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
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
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
global _app
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _config() -> RunConfigModel:
|
||||
config = RunConfigModel()
|
||||
config.input_switch.positions = 3
|
||||
config.output_switch.positions = 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"
|
||||
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"])
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
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)
|
||||
|
||||
def _stop(self) -> None:
|
||||
self.running = False
|
||||
self.owner._stop_request_seq += 1
|
||||
|
||||
def _start(self, **_kwargs) -> None:
|
||||
self.running = 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()
|
||||
capture._start()
|
||||
override = self.owner._start_run.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]
|
||||
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, {})
|
||||
capture.cancel()
|
||||
self.assertEqual(self.owner._stop_run.call_count, 2)
|
||||
|
||||
def test_stop_cancels_capture_and_prevents_automatic_resume(self) -> None:
|
||||
self.running = True
|
||||
capture = self._capture()
|
||||
capture._start()
|
||||
self.owner._stop_run()
|
||||
capture._poll()
|
||||
self.dialog.add_capture.assert_not_called()
|
||||
self.assertEqual(self.owner._start_run.call_count, 1)
|
||||
self.assertFalse(self.running)
|
||||
|
||||
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_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 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.assertTrue(self.running)
|
||||
self.dialog.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_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()
|
||||
@@ -19,7 +19,9 @@ import numpy as np
|
||||
|
||||
from python_app.gui.controllers import app_window_recording_mixin as rec
|
||||
from python_app.gui.controllers.app_window_recording_mixin import AppWindowRecordingMixin
|
||||
from python_app.gui.controllers.app_window_config.profile_io_mixin import AppWindowConfigProfileIOMixin
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.gui_profile_model import GuiProfileModel
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
|
||||
|
||||
@@ -113,7 +115,7 @@ class _Harness(AppWindowRecordingMixin):
|
||||
def _snapshot_config_profile_path(self, directory):
|
||||
return Path(directory) / "config_profile.json"
|
||||
|
||||
def _write_gui_profile_to_path(self, path, allow_overwrite) -> None:
|
||||
def _write_gui_profile_to_path(self, path, allow_overwrite=True, dataset=False) -> None:
|
||||
self.profiles.append((path, allow_overwrite))
|
||||
|
||||
def _start_run(self) -> None:
|
||||
@@ -162,7 +164,7 @@ class RecordingArmTest(_RecordingTestBase):
|
||||
h._save_path_input = _Text(str(Path(temporary) / "datasets"))
|
||||
destination = Path(temporary) / "datasets/pref_run"
|
||||
|
||||
def write_profile(path, allow_overwrite):
|
||||
def write_profile(path, allow_overwrite=True, dataset=False):
|
||||
path.write_text("fresh profile", encoding="utf-8")
|
||||
|
||||
with mock.patch.object(h, "_write_gui_profile_to_path", side_effect=write_profile):
|
||||
@@ -210,6 +212,57 @@ class RecordingArmTest(_RecordingTestBase):
|
||||
self.assertFalse(h.started)
|
||||
self.assertTrue(h._recording_active)
|
||||
|
||||
def test_running_recording_profile_uses_applied_object_calibration(self) -> None:
|
||||
for applied, selected in (("", "next_run"), ("applied", "")):
|
||||
with self.subTest(applied=applied), tempfile.TemporaryDirectory() as temporary:
|
||||
h = self._make(count=3, running=True)
|
||||
h._store = NpzStore(Path(temporary) / "sets")
|
||||
h._save_path_input = _Text(str(Path(temporary) / "datasets"))
|
||||
pending = GuiProfileModel()
|
||||
pending.run_config.preprocess.s21.object_calibration.set_name = selected
|
||||
h._active_run_config = pending.run_config.clone()
|
||||
h._active_run_config.preprocess.s21.object_calibration.set_name = applied
|
||||
h._active_run_config.preprocess.s21.object_calibration.bundle_path = "/bundles/applied" if applied else ""
|
||||
h._build_gui_profile = pending.clone
|
||||
with mock.patch.object(h, "_write_gui_profile_to_path", side_effect=lambda *args, **kwargs:
|
||||
AppWindowConfigProfileIOMixin._write_gui_profile_to_path(h, *args, **kwargs)):
|
||||
h._start_run_with_recording()
|
||||
path = Path(temporary) / "datasets/pref_run/config_profile.json"
|
||||
recorded = GuiProfileModel.load_from_path(path)
|
||||
self.assertEqual(recorded.run_config.preprocess, h._active_run_config.preprocess)
|
||||
self.assertFalse(h.started)
|
||||
self.assertEqual(h.exceptions, [])
|
||||
|
||||
def test_new_recording_profile_uses_new_run_instead_of_previous_history(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
h = self._make(count=3, running=False)
|
||||
h._store = NpzStore(Path(temporary) / "sets")
|
||||
h._save_path_input = _Text(str(Path(temporary) / "datasets"))
|
||||
pending = GuiProfileModel()
|
||||
pending.run_config.preprocess.s21.object_calibration.set_name = "next_run"
|
||||
h._active_run_config = pending.run_config.clone()
|
||||
h._active_run_config.preprocess.s21.object_calibration.set_name = "previous_run"
|
||||
h._build_gui_profile = pending.clone
|
||||
path = Path(temporary) / "datasets/pref_run/config_profile.json"
|
||||
|
||||
def start_new_run():
|
||||
initial = GuiProfileModel.load_from_path(path)
|
||||
self.assertEqual(initial.run_config.preprocess, pending.run_config.preprocess)
|
||||
h._active_run_config = pending.run_config.clone()
|
||||
h._active_run_config.preprocess.s21.object_calibration.bundle_path = "/bundles/new_object"
|
||||
h._active_run_config.preprocess.s21.calibration.bundle_path = "/bundles/new_through"
|
||||
h._supervisor._running = True
|
||||
|
||||
with mock.patch.object(h, "_start_run", side_effect=start_new_run), mock.patch.object(
|
||||
h, "_write_gui_profile_to_path", side_effect=lambda *args, **kwargs:
|
||||
AppWindowConfigProfileIOMixin._write_gui_profile_to_path(h, *args, **kwargs),
|
||||
):
|
||||
h._start_run_with_recording()
|
||||
recorded = GuiProfileModel.load_from_path(path)
|
||||
self.assertEqual(recorded.run_config.preprocess, h._active_run_config.preprocess)
|
||||
self.assertEqual(h.exceptions, [])
|
||||
self.assertTrue(h._recording_active)
|
||||
|
||||
def test_arm_refuses_when_destination_exists(self) -> None:
|
||||
h = self._make(count=3, running=True, dest_exists=True)
|
||||
h._start_run_with_recording()
|
||||
@@ -248,7 +301,7 @@ class RecordingArmTest(_RecordingTestBase):
|
||||
traces=[TraceData(ComboKey(0, 0), np.array([1e9], dtype=np.float32), samples, samples)],
|
||||
))
|
||||
|
||||
def write_profile(path, allow_overwrite):
|
||||
def write_profile(path, allow_overwrite=True, dataset=False):
|
||||
path.write_text('retained configuration', encoding='utf-8')
|
||||
|
||||
with mock.patch.object(h, '_start_run', side_effect=failed_start), mock.patch.object(
|
||||
|
||||
Reference in New Issue
Block a user