Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+189 -275
View File
@@ -1,19 +1,19 @@
"""Physical MIMO estimation, legacy archives and failure-preserving writes."""
"""Notebook mathematics and acquisition provenance of live object corrections."""
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.storage.npz.store import NpzStore
from python_app.workflows import mimo_calibration as mc
from python_app.workflows import object_calibration as oc
class MimoCalibrationTest(unittest.TestCase):
@@ -35,6 +35,7 @@ class MimoCalibrationTest(unittest.TestCase):
'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)
@@ -55,68 +56,176 @@ class MimoCalibrationTest(unittest.TestCase):
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())
traces = [TraceData(ComboKey(input_, output), freq.copy(), 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 calibration(self, samples=None):
through = self.collection(np.ones((2, 2, self.freq.size), complex))
provenance = oc.capture_provenance(self.config, 'captured-radar', through)
return mc.compute_from_collections(
[{'collections': [self.collection(samples)], 'target_scene_m': self.target}],
self.config, settings=self.settings, provenance=provenance,
)
def object_set(self):
collection, metadata = self.calibration()
return collection, metadata, self.collection(np.ones((2, 2, self.freq.size), complex))
def test_partial_geometry_calibrates_subset_and_preserves_full_capture(self):
from PyQt6.QtWidgets import QApplication, QDoubleSpinBox
from python_app.gui.object_calibration_dialog import ObjectCalibrationDialog
app = QApplication.instance() or QApplication([])
self.assertIsNotNone(app)
capture = self.collection()
known = {trace.combo for trace in capture.traces}
full = [ComboKey(i, o) for o in (1, 5, 6) for i in (2, 7, 8)]
capture.traces.extend(TraceData(combo, self.freq.copy(), np.ones(self.freq.size, complex))
for combo in full if combo not in known)
self.config['run']['combos'] = [dict(input=combo.input, output=combo.output) for combo in full]
through = copy.deepcopy(capture)
for trace in through.traces:
trace.s21.fill(1)
provenance = oc.capture_provenance(self.config, 'captured-radar', through)
dialog = ObjectCalibrationDialog('partial')
self.addCleanup(dialog.close)
dialog.add_capture([capture], self.config, provenance)
for column, value in enumerate(self.target, 2):
item = dialog._table.item(0, column)
assert item is not None
item.setText(str(value))
for name in ('sphere_radius_m', 'antenna_height_m', 'coarse_range_search_m'):
field = dialog._settings[name]
assert isinstance(field, QDoubleSpinBox)
field.setValue(getattr(self.settings, name))
for field in dialog._refine_axes.values():
field.setChecked(False)
groups, config, settings, provenance = dialog.calculation_inputs()
correction, metadata = mc.compute_from_collections(
groups, config, settings=mc.Settings(**settings), provenance=provenance,
)
self.assertEqual(metadata['rx_input_pos'], [2, 7])
self.assertEqual(metadata['tx_output_pos'], [1, 5])
self.assertEqual(len(groups[0]['collections'][0].traces), 9)
self.assertEqual({trace.combo for trace in correction.traces}, set(full))
self.assertEqual(metadata['unchanged_combos'],
[dict(input=combo.input, output=combo.output) for combo in full if combo not in known])
for trace in correction.traces:
if trace.combo not in known:
np.testing.assert_array_equal(trace.s21, np.ones(self.freq.size))
self.assertGreater(metadata['diagnostics'][0]['coherence_after'], .999)
oc.validate_object_set(correction, metadata, config, 'captured-radar', through)
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])
collection, metadata = self.calibration()
pair = np.array([trace.s21 for trace in collection.traces]).reshape(signal.shape)
contributions = signal * pair * np.exp(2j * np.pi * self.freq * paths[:, :, None] / mc.C_LIGHT)
np.testing.assert_allclose(np.angle(contributions.mean(axis=2)), np.full((2, 2), 1.1), atol=.004)
np.testing.assert_allclose(np.abs(pair), 1., atol=2e-7)
diagnostic = metadata['diagnostics'][0]
self.assertGreater(diagnostic['coherence_after'], .9999)
self.assertLess(diagnostic['coherence_before'], .98)
np.testing.assert_allclose(diagnostic['phase_after_deg'], np.zeros((2, 2)), atol=.25)
def test_amplitude_equalization_is_explicit(self):
def test_phase_maps_match_notebook_cell5_with_separate_sum_references(self):
before = np.array([[1j, 3], [-1j, 1]])
after = np.array([[-1, -2j], [-1j, -3]])
with mock.patch.object(mc._Estimator, 'broadband', side_effect=[before, after]):
diagnostic = self.calibration()[1]['diagnostics'][0]
np.testing.assert_allclose(diagnostic['phase_before_deg'], [[90, 0], [-90, 0]], atol=1e-10)
np.testing.assert_allclose(diagnostic['phase_after_deg'],
[[-36.869897645844, 53.130102354156],
[53.130102354156, -36.869897645844]], atol=1e-10)
self.assertAlmostEqual(diagnostic['phase_std_before_deg'], np.sqrt(4050))
self.assertAlmostEqual(diagnostic['phase_std_after_deg'], 45)
def test_overflow_in_inverted_pair_correction_is_rejected(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)
with mock.patch.object(mc._Estimator, 'to_full_grid', return_value=np.full(self.freq.size, 1e-25)), \
np.errstate(over='ignore', invalid='ignore'), \
self.assertRaisesRegex(ValueError, 'pair corrections.*non-finite'):
self.calibration()
def test_through_hash_tracks_only_canonical_pair_frequency_and_s21_content(self):
through = self.collection()
fingerprint = oc.through_fingerprint(through)
reordered = copy.deepcopy(through)
reordered.traces.reverse()
reordered.collection_id += 1
reordered.monotonic_ns += 1000
for trace in reordered.traces:
trace.frequency_hz = trace.frequency_hz.astype('>f4')
trace.s21 = trace.s21.astype('>c8')
trace.capture_start_ns = 99
self.assertEqual(oc.through_fingerprint(reordered), fingerprint)
for field in ('frequency_hz', 's21'):
changed = copy.deepcopy(through)
getattr(changed.traces[0], field)[0] += 1024 if field == 'frequency_hz' else .1j
with self.subTest(field=field):
self.assertNotEqual(oc.through_fingerprint(changed), fingerprint)
changed = copy.deepcopy(through)
changed.traces[0].combo = ComboKey(999, 1)
self.assertNotEqual(oc.through_fingerprint(changed), fingerprint)
def test_object_set_accepts_combo_subset_and_reordered_geometry_with_new_reference(self):
collection, metadata, through = self.object_set()
config = copy.deepcopy(self.config)
config['run']['combos'] = config['run']['combos'][1:2]
config['gpr']['tx_geometry'].reverse()
config['gpr']['rx_geometry'].reverse()
config['preprocess']['s21']['reference']['set_name'] = 'new empty scene'
oc.validate_object_set(collection, metadata, config, 'captured-radar', through)
def test_object_set_rejects_changed_capture_dependencies_and_missing_provenance(self):
collection, metadata, through = self.object_set()
for dependency in ('radar', 'geometry', 'through_name', 'through_content', 'provenance'):
config = copy.deepcopy(self.config)
active_through = copy.deepcopy(through)
active_metadata = copy.deepcopy(metadata)
key = 'captured-radar'
if dependency == 'radar':
key = 'another-radar'
elif dependency == 'geometry':
config['gpr']['tx_geometry'][0]['z_m'] += .01
elif dependency == 'through_name':
config['preprocess']['s21']['calibration']['set_name'] = 'another-through'
elif dependency == 'through_content':
active_through.traces[0].s21[0] += .1
else:
active_metadata.clear()
with self.subTest(dependency=dependency), self.assertRaisesRegex(ValueError, 'Object calibration'):
oc.validate_object_set(collection, active_metadata, config, key, active_through)
def test_object_set_rejects_missing_pairs_shifted_grids_and_invalid_factors(self):
collection, metadata, through = self.object_set()
for defect in ('missing', 'duplicate', 'grid', 'zero', 'nan'):
corrupted = copy.deepcopy(collection)
if defect == 'missing':
corrupted.traces.pop()
elif defect == 'duplicate':
corrupted.traces.append(corrupted.traces[0])
elif defect == 'grid':
corrupted.traces[0].frequency_hz = corrupted.traces[0].frequency_hz + 1024
else:
corrupted.traces[0].s21[0] = 0 if defect == 'zero' else np.nan
with self.subTest(defect=defect), self.assertRaises(ValueError):
oc.validate_object_set(corrupted, metadata, self.config, 'captured-radar', through)
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 = []
@@ -129,38 +238,10 @@ class MimoCalibrationTest(unittest.TestCase):
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)
mc.compute_from_collections([{'collections': [collection], 'target_scene_m': self.target}], self.config, settings=self.settings, provenance={})
with self.assertRaisesRegex(ValueError, 'at least one'):
mc.compute_from_collections([], self.config, settings=self.settings)
mc.compute_from_collections([], self.config, settings=self.settings, provenance={})
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',)
@@ -176,209 +257,42 @@ class MimoCalibrationTest(unittest.TestCase):
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.config, settings=self.settings, provenance={}, 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']
_, metadata = self.calibration()
self.assertGreater(metadata['diagnostics'][0]['coherence_after'], .999)
refined = metadata['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()
def test_common_phase_invariance_and_explicit_amplitude_equalization(self):
signal, paths = self.signal(gains=True)
_, original = self.calibration(signal)
_, rotated = self.calibration(signal * np.exp(2.8j))
for key in ('phase_before_deg', 'phase_after_deg'):
np.testing.assert_allclose(rotated['diagnostics'][0][key], original['diagnostics'][0][key], atol=1e-5)
self.settings.apply_amplitude = True
collection, _ = self.calibration(signal)
pair = np.array([trace.s21 for trace in collection.traces]).reshape(signal.shape)
levels = np.abs((signal * pair * 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(pair)), 1.5)
if __name__ == '__main__':
unittest.main()
def test_estimated_npz_roundtrip_keeps_physical_pairs_full_grid_and_provenance(self):
collection, metadata, through = self.object_set()
with tempfile.TemporaryDirectory() as directory:
store = NpzStore(Path(directory))
store.save_set('s21_object_calibration', 'captured-radar', 'sphere', collection, metadata=metadata)
loaded, stored = store.load_set_with_metadata('s21_object_calibration', 'captured-radar', 'sphere')
self.assertEqual(stored['provenance'], metadata['provenance'])
self.assertEqual(stored['targets'][0]['captures'][0]['monotonic_ns'], 1)
for before, after in zip(collection.traces, loaded.traces, strict=True):
self.assertEqual(before.combo, after.combo)
np.testing.assert_array_equal(after.frequency_hz, self.freq.astype(np.float32))
np.testing.assert_array_equal(after.s21, before.s21)
oc.validate_object_set(loaded, stored, self.config, 'captured-radar', through)