Rework radar system
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user