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()
+384
View File
@@ -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()
+56 -3
View File
@@ -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(