Rework radar system
This commit is contained in:
@@ -2,8 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
from typing import cast
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -13,10 +16,9 @@ from python_app.models.dataset_model import (
|
||||
ComboKey, ResultBlock, ResultCollection, ResultPayload, SweepCollection, TraceData,
|
||||
)
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
from python_app.storage.npz.snapshot_numpy import select_aligned_histories
|
||||
from python_app.storage.npz.vna_history_json import build_vna_history_payload
|
||||
from python_app.scripts.replay_raw_producer import _load_dataset
|
||||
from python_app.scripts.convert_snapshot_to_vna_history import _collection_dirs, _load_stage_records, _build_sweep_history
|
||||
from python_app.storage.npz.vna_history_json import collection_directories, load_vna_history_snapshot
|
||||
from python_app.workflows.sequential_capture_workflow import combine_collections_via_median, combine_traces_via_median
|
||||
|
||||
|
||||
@@ -25,7 +27,6 @@ def _sweep(cid: int = 1, ns: int = 20, *, start: int = 10, end: int = 15) -> Swe
|
||||
collection_id=cid, monotonic_ns=ns, capture_start_ns=start, capture_end_ns=end,
|
||||
traces=[TraceData(
|
||||
combo=ComboKey(0, 0), frequency_hz=np.array([1e9, 2e9], dtype=np.float32),
|
||||
s11=np.array([1 + 2j, 3 - 4j], dtype=np.complex64),
|
||||
s21=np.array([5 - 6j, 7 + 8j], dtype=np.complex64),
|
||||
capture_start_ns=start, capture_end_ns=end,
|
||||
)],
|
||||
@@ -52,7 +53,7 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
self.output = self.root / 'output'
|
||||
|
||||
def test_all_calibration_and_reference_kinds_preserve_arrays_and_timing(self) -> None:
|
||||
for kind in ('s21_calibration', 's21_reference', 's11_open', 's11_short', 's11_load', 's11_reference'):
|
||||
for kind in ('s21_calibration', 's21_reference'):
|
||||
with self.subTest(kind=kind):
|
||||
original = _sweep(7, 100)
|
||||
second = _sweep().traces[0]
|
||||
@@ -67,22 +68,10 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
for before, after in zip(original.traces, loaded.traces, strict=True):
|
||||
self.assertEqual(before.combo, after.combo)
|
||||
self.assertEqual((after.capture_start_ns, after.capture_end_ns), (10, 15))
|
||||
for field in ('frequency_hz', 's11', 's21'):
|
||||
for field in ('frequency_hz', 's21'):
|
||||
np.testing.assert_array_equal(getattr(before, field), getattr(after, field))
|
||||
|
||||
def test_new_set_remains_loadable_without_json_sidecar(self) -> None:
|
||||
self.store.save_set('s21_reference', 'radar', 'full', _sweep())
|
||||
(self.root / 'sets/s21_reference/radar/full.json').unlink()
|
||||
loaded = self.store.load_set('s21_reference', 'radar', 'full')
|
||||
np.testing.assert_array_equal(loaded.traces[0].s21, _sweep().traces[0].s21)
|
||||
|
||||
def test_legacy_npz_json_pair_is_still_loadable(self) -> None:
|
||||
self.store.save_set('s21_reference', 'radar', 'full', _sweep())
|
||||
archive = self.root / 'sets/s21_reference/radar/full.npz'
|
||||
with np.load(archive, allow_pickle=False) as arrays:
|
||||
legacy = {key: arrays[key] for key in arrays.files if key != 'metadata_json'}
|
||||
np.savez(archive, **legacy)
|
||||
self.assertEqual(self.store.load_set('s21_reference', 'radar', 'full').monotonic_ns, 20)
|
||||
|
||||
def test_failed_set_commit_preserves_previous_data_and_sidecar(self) -> None:
|
||||
self.store.save_set('s21_reference', 'radar', 'full', _sweep(1, 20))
|
||||
@@ -130,13 +119,59 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
self.assertEqual(self.store.load_set('s21_reference', 'radar', 'full').collection_id, 1)
|
||||
self.assertEqual(list(self.root.rglob('*.tmp')), [])
|
||||
|
||||
def test_set_files_are_synced_before_commit_and_directory_afterwards(self) -> None:
|
||||
events = []
|
||||
fsync, replace = os.fsync, Path.replace
|
||||
|
||||
def sync_file(descriptor):
|
||||
events.append('directory' if stat.S_ISDIR(os.fstat(descriptor).st_mode) else 'file')
|
||||
fsync(descriptor)
|
||||
|
||||
def commit(path, destination):
|
||||
events.append('replace')
|
||||
return replace(path, destination)
|
||||
|
||||
with mock.patch('os.fsync', side_effect=sync_file), mock.patch.object(Path, 'replace', commit):
|
||||
self.store.save_set('s21_calibration', 'radar', 'full', _sweep())
|
||||
first_file = events.index('file')
|
||||
self.assertEqual(events[:first_file], ['directory', 'directory'])
|
||||
self.assertEqual(events[first_file:], ['file', 'file', 'replace', 'replace', 'directory'])
|
||||
|
||||
def test_set_sync_failure_does_not_replace_previous_measurement(self) -> None:
|
||||
self.store.save_set('s21_calibration', 'radar', 'full', _sweep())
|
||||
archive = self.root / 'sets/s21_calibration/radar/full.npz'
|
||||
original = archive.read_bytes()
|
||||
for failed_sync in (1, 2):
|
||||
with self.subTest(failed_sync=failed_sync), mock.patch('python_app.storage.npz.store.logger.exception'):
|
||||
with mock.patch('os.fsync', side_effect=[None] * (failed_sync - 1) + [OSError('sync failed')]):
|
||||
with self.assertRaises(OSError):
|
||||
self.store.save_set('s21_calibration', 'radar', 'full', _sweep(2, 30))
|
||||
self.assertEqual(archive.read_bytes(), original)
|
||||
self.assertEqual(list(archive.parent.glob('*.tmp')), [])
|
||||
|
||||
def test_sets_and_snapshots_preserve_permissions_and_respect_umask(self) -> None:
|
||||
previous = os.umask(0o027)
|
||||
try:
|
||||
self.store.save_set('s21_calibration', 'radar', 'full', _sweep())
|
||||
directory = self.store.save_runtime_snapshot_numpy(self.output, 'full', [_sweep()], [], [])
|
||||
finally:
|
||||
os.umask(previous)
|
||||
for path in (self.root / 'sets/s21_calibration/radar').iterdir():
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
|
||||
path.chmod(0o664)
|
||||
for path in [directory, *directory.rglob('*')]:
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o750 if path.is_dir() else 0o640)
|
||||
self.store.save_set('s21_calibration', 'radar', 'full', _sweep(2, 30))
|
||||
for path in (self.root / 'sets/s21_calibration/radar').iterdir():
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o664)
|
||||
|
||||
def test_invalid_sets_are_rejected_before_overwriting_valid_data(self) -> None:
|
||||
self.store.save_set('s21_calibration', 'radar', 'full', _sweep())
|
||||
invalid = [_sweep(), _sweep(), _sweep(), _sweep()]
|
||||
invalid[0].traces.append(invalid[0].traces[0])
|
||||
invalid[1].traces[0].s21 = np.array([1], dtype=np.complex64)
|
||||
invalid[2].traces[0].frequency_hz = np.array([[1, 2]], dtype=np.float32)
|
||||
invalid[3].traces[0].s11[0] = complex(float('nan'), 0)
|
||||
invalid[3].traces[0].s21[0] = complex(float('nan'), 0)
|
||||
for collection in invalid:
|
||||
with self.subTest(collection=collection), self.assertRaises(ValueError):
|
||||
self.store.save_set('s21_calibration', 'radar', 'full', collection)
|
||||
@@ -150,7 +185,7 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
def test_invalid_channel_identifiers_are_rejected_without_truncation(self) -> None:
|
||||
for position in (0.5, True, -1, 2 ** 32):
|
||||
sweep = _sweep()
|
||||
sweep.traces[0].combo = ComboKey(position, 0)
|
||||
sweep.traces[0].combo = ComboKey(cast(int, position), 0)
|
||||
with self.subTest(position=position), self.assertRaises(ValueError):
|
||||
self.store.save_set('s21_reference', 'radar', 'invalid', sweep)
|
||||
self.assertEqual(self.store.list_sets('s21_reference', 'radar'), [])
|
||||
@@ -173,10 +208,9 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
raw = [_sweep(1, 10), _sweep(2, 30)]
|
||||
pre = [_sweep(1, 20), _sweep(3, 40)]
|
||||
result = [ResultCollection(1, 20)]
|
||||
directory, summary = self.store.save_runtime_snapshot_numpy(
|
||||
self.output, 'full', raw, pre, result, 1000, include_unaligned=True,
|
||||
directory = self.store.save_runtime_snapshot_numpy(
|
||||
self.output, 'full', raw, pre, result,
|
||||
)
|
||||
self.assertEqual((summary['raw_count'], summary['preprocessed_count'], summary['result_count']), (2, 2, 1))
|
||||
for stage, ids in [('raw', [1, 2]), ('preprocessed', [1, 3]), ('results', [1])]:
|
||||
metadata = [json.loads(path.read_text()) for path in sorted((directory / stage).glob('*/meta.json'))]
|
||||
self.assertEqual([item['collection_id'] for item in metadata], ids)
|
||||
@@ -184,11 +218,15 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
def test_snapshot_result_payloads_preserve_every_kind_at_both_scopes(self) -> None:
|
||||
payloads = _payloads()
|
||||
result = ResultCollection(1, 20, processing_duration_ns=12345,
|
||||
settings_revision=2**63 + 7, capture_start_ns=2**60 + 3,
|
||||
capture_end_ns=2**60 + 9, speed_m_s=1.25,
|
||||
collection_payloads=payloads, blocks=[ResultBlock(ComboKey(0, 0), payloads)])
|
||||
directory, _ = self.store.save_runtime_snapshot_numpy(self.output, 'full', [], [], [result], 1000)
|
||||
directory = self.store.save_runtime_snapshot_numpy(self.output, 'full', [], [], [result])
|
||||
collection_dir = next((directory / 'results').iterdir())
|
||||
meta = json.loads((collection_dir / 'meta.json').read_text())
|
||||
self.assertEqual(meta['processing_duration_ns'], 12345)
|
||||
self.assertEqual((meta['settings_revision'], meta['capture_start_ns'], meta['capture_end_ns'], meta['speed_m_s']),
|
||||
(2**63 + 7, 2**60 + 3, 2**60 + 9, 1.25))
|
||||
for base, entries in [(collection_dir, meta['collection_payloads']),
|
||||
(collection_dir / meta['blocks'][0]['dir'], meta['blocks'][0]['payloads'])]:
|
||||
self.assertEqual([entry['kind'] for entry in entries], [1, 2, 3, 4])
|
||||
@@ -204,18 +242,18 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
for payload in (ResultPayload('unknown', 9), ResultPayload('bad_table', 4, table=np.array([1, 2]))):
|
||||
with self.subTest(kind=payload.kind), self.assertRaises(ValueError):
|
||||
self.store.save_runtime_snapshot_numpy(
|
||||
self.output, 'failed', [_sweep()], [], [ResultCollection(1, 20, collection_payloads=[payload])], 1000,
|
||||
self.output, 'failed', [_sweep()], [], [ResultCollection(1, 20, collection_payloads=[payload])],
|
||||
)
|
||||
self.assertFalse((self.output / 'failed').exists())
|
||||
self.assertEqual(list(self.output.iterdir()), [])
|
||||
|
||||
def test_snapshot_write_failure_can_be_retried_with_same_name(self) -> None:
|
||||
with mock.patch('numpy.save', side_effect=OSError('disk full')), self.assertRaises(OSError):
|
||||
self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [], 1000)
|
||||
directory, _ = self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [], 1000)
|
||||
self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [])
|
||||
directory = self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [])
|
||||
self.assertTrue(directory.exists())
|
||||
with self.assertRaises(FileExistsError):
|
||||
self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [], 1000)
|
||||
self.store.save_runtime_snapshot_numpy(self.output, 'retry', [_sweep()], [], [])
|
||||
|
||||
def test_stream_failure_keeps_prior_chunks_and_retries_without_partial_data(self) -> None:
|
||||
writer = self.store.create_snapshot_stream(self.output, 'stream')
|
||||
@@ -229,6 +267,44 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
self.assertEqual(writer.result_count, 2)
|
||||
self.assertEqual([path.name.split('_')[0] for path in sorted((writer.directory / 'raw').iterdir())], ['0000', '0001'])
|
||||
|
||||
def test_stream_failure_preserves_collections_already_published_in_current_chunk(self) -> None:
|
||||
writer = self.store.create_snapshot_stream(self.output, 'partial')
|
||||
good = ResultCollection(1, 20)
|
||||
bad = ResultCollection(2, 40, collection_payloads=[ResultPayload('invalid', 9)])
|
||||
with self.assertRaises(ValueError):
|
||||
writer.append([_sweep(1, 10), _sweep(2, 30)], [], [good, bad])
|
||||
self.assertEqual(writer.result_count, 1)
|
||||
self.assertEqual(len(list((writer.directory / 'raw').iterdir())), 2)
|
||||
self.assertEqual(len(list((writer.directory / 'results').iterdir())), 1)
|
||||
writer.append([], [], [ResultCollection(2, 40)])
|
||||
self.assertEqual(writer.result_count, 2)
|
||||
self.assertEqual([path.name.split('_')[0] for path in sorted((writer.directory / 'results').iterdir())], ['0000', '0001'])
|
||||
|
||||
def test_processing_revisions_are_saved_and_cannot_be_reassigned(self) -> None:
|
||||
settings = {4: {'settings_revision': 4, 'gain': 2}}
|
||||
directory = self.store.save_runtime_snapshot_numpy(
|
||||
self.output, 'snapshot', [], [], [], processing_settings=settings,
|
||||
)
|
||||
self.assertEqual(json.loads((directory / 'processing_settings.json').read_text()), {'4': settings[4]})
|
||||
writer = self.store.create_snapshot_stream(self.output, 'stream')
|
||||
writer.append([], [], [ResultCollection(1, 10)], processing_settings=settings)
|
||||
later = {5: {'settings_revision': 5, 'gain': 3}}
|
||||
writer.append([], [], [ResultCollection(2, 20)], processing_settings=later)
|
||||
path = writer.directory / 'processing_settings.json'
|
||||
self.assertEqual(json.loads(path.read_text()), {'4': settings[4], '5': later[5]})
|
||||
with self.assertRaisesRegex(ValueError, 'already has different settings'):
|
||||
writer.append([], [], [ResultCollection(3, 30)], processing_settings={4: {'gain': 9}})
|
||||
self.assertEqual(writer.result_count, 2)
|
||||
self.assertEqual(json.loads(path.read_text()), {'4': settings[4], '5': later[5]})
|
||||
|
||||
def test_failed_processing_settings_write_does_not_publish_results(self) -> None:
|
||||
writer = self.store.create_snapshot_stream(self.output, 'stream')
|
||||
with mock.patch('python_app.storage.npz.snapshot_numpy.atomic_write_json', side_effect=OSError('disk full')):
|
||||
with self.assertRaises(OSError):
|
||||
writer.append([], [], [ResultCollection(1, 10)], processing_settings={1: {'gain': 2}})
|
||||
self.assertEqual(writer.result_count, 0)
|
||||
self.assertEqual(list(writer.directory.iterdir()), [])
|
||||
|
||||
def test_json_export_preserves_unmatched_raw_sweeps_and_matches_reused_ids_by_timing(self) -> None:
|
||||
raw_old = _sweep(1, 10, start=1, end=5)
|
||||
raw_new = _sweep(1, 100, start=90, end=95)
|
||||
@@ -244,33 +320,37 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
second = _sweep().traces[0]
|
||||
second.combo = ComboKey(0, 1)
|
||||
sweep.traces.append(second)
|
||||
write_text = Path.write_text
|
||||
dump = json.dump
|
||||
writes = 0
|
||||
|
||||
def fail_second_file(path, *args, **kwargs):
|
||||
def fail_second_file(*args, **kwargs):
|
||||
nonlocal writes
|
||||
writes += 1
|
||||
if writes == 2:
|
||||
raise OSError('disk full')
|
||||
return write_text(path, *args, **kwargs)
|
||||
return dump(*args, **kwargs)
|
||||
|
||||
with mock.patch.object(Path, 'write_text', fail_second_file), self.assertRaises(OSError):
|
||||
self.store.save_runtime_vna_history_json_batch(self.output, 'export', [sweep], [], [], 1000)
|
||||
with mock.patch('python_app.storage.npz.store.json.dump', fail_second_file), self.assertRaises(OSError):
|
||||
self.store.save_runtime_vna_history_json_batch(self.output, 'export', [sweep], [], [])
|
||||
self.assertEqual(list(self.output.iterdir()), [])
|
||||
paths, summary = self.store.save_runtime_vna_history_json_batch(self.output, 'export', [sweep], [], [], 1000, channel='s11')
|
||||
self.assertEqual(summary['combo_count'], 2)
|
||||
paths = self.store.save_runtime_vna_history_json_batch(self.output, 'export', [sweep], [], [])
|
||||
self.assertEqual(len(paths), 2)
|
||||
self.assertEqual(len(paths), 2)
|
||||
for path in paths:
|
||||
self.assertEqual(json.loads(path.read_text())['channel'], 's11')
|
||||
self.assertEqual(json.loads(path.read_text())['channel'], 's21')
|
||||
|
||||
def test_saved_dataset_replay_preserves_s11_and_accepts_legacy_s21_only(self) -> None:
|
||||
directory, _ = self.store.save_runtime_snapshot_numpy(self.output, 'replay', [_sweep()], [], [], 1000)
|
||||
dataset = _load_dataset(directory / 'raw', [ComboKey(0, 0)], 2)
|
||||
np.testing.assert_array_equal(dataset.snapshots[0][0].s11, _sweep().traces[0].s11)
|
||||
self.assertEqual(dataset.snapshots[0][0].capture_end_ns, 15)
|
||||
next((directory / 'raw').glob('*/i0_o0_s11.npy')).unlink()
|
||||
legacy = _load_dataset(directory / 'raw', [ComboKey(0, 0)], 2)
|
||||
np.testing.assert_array_equal(legacy.snapshots[0][0].s11, np.zeros(2, dtype=np.complex64))
|
||||
def test_json_export_can_follow_numpy_without_overwriting_either_format(self) -> None:
|
||||
directory = self.store.save_runtime_snapshot_numpy(self.output, 'both', [_sweep()], [], [])
|
||||
directory.chmod(0o775)
|
||||
before = {path: path.read_bytes() for path in directory.rglob('*') if path.is_file()}
|
||||
paths = self.store.save_runtime_vna_history_json_batch(self.output, 'both', [_sweep()], [], [])
|
||||
self.assertEqual(stat.S_IMODE(directory.stat().st_mode), 0o775)
|
||||
self.assertEqual({path: path.read_bytes() for path in before}, before)
|
||||
exported = paths[0].read_bytes()
|
||||
with self.assertRaises(FileExistsError):
|
||||
self.store.save_runtime_vna_history_json_batch(self.output, 'both', [_sweep(2, 30)], [], [])
|
||||
self.assertEqual(paths[0].read_bytes(), exported)
|
||||
self.assertEqual({path: path.read_bytes() for path in before}, before)
|
||||
|
||||
def test_long_recording_replay_and_converter_use_numeric_directory_order(self) -> None:
|
||||
writer = self.store.create_snapshot_stream(self.output, 'long')
|
||||
@@ -280,49 +360,20 @@ class PersistenceIntegrityTest(unittest.TestCase):
|
||||
folder.rename(folder.with_name(f'{index}_{folder.name.split("_", 1)[1]}'))
|
||||
dataset = _load_dataset(writer.directory / 'raw', [ComboKey(0, 0)], 2)
|
||||
self.assertEqual(dataset.timestamps_ns, [10, 20, 30])
|
||||
self.assertEqual([int(path.name.split('_')[0]) for path in _collection_dirs(writer.directory / 'raw')], [9998, 9999, 10000])
|
||||
self.assertEqual([int(path.name.split('_')[0]) for path in collection_directories(writer.directory / 'raw')], [9998, 9999, 10000])
|
||||
|
||||
def test_offline_and_runtime_json_exports_share_matching_and_preserve_orphan_sweeps(self) -> None:
|
||||
raw = [_sweep(1, 10, start=1, end=5), _sweep(1, 100, start=90, end=95)]
|
||||
pre = [_sweep(1, 110, start=90, end=95)]
|
||||
directory, _ = self.store.save_runtime_snapshot_numpy(
|
||||
self.output, 'convert', raw, pre, [], 1000, include_unaligned=True,
|
||||
directory = self.store.save_runtime_snapshot_numpy(
|
||||
self.output, 'convert', raw, pre, [],
|
||||
)
|
||||
records = {
|
||||
stage: _load_stage_records(directory, stage, input_index=0, output_index=0, channel='s21')
|
||||
for stage in ('raw', 'preprocessed')
|
||||
}
|
||||
offline = _build_sweep_history(records['raw'], records['preprocessed'], channel='s21', primary_stage='preprocessed')
|
||||
offline = load_vna_history_snapshot(directory, input_index=0, output_index=0)["sweep_history"]
|
||||
runtime = build_vna_history_payload(raw, pre, [], input_index=0, output_index=0)
|
||||
self.assertEqual(offline, runtime['sweep_history'])
|
||||
self.assertEqual(len(offline), 2)
|
||||
|
||||
|
||||
class SnapshotAlignmentTest(unittest.TestCase):
|
||||
def test_reused_ids_and_missing_frames_do_not_cross_align_runs(self) -> None:
|
||||
raw_old = _sweep(1, 10, start=1, end=5)
|
||||
pre_old = _sweep(1, 20, start=1, end=5)
|
||||
pre_new = _sweep(1, 200, start=150, end=175)
|
||||
selected_raw, selected_pre, results, summary = select_aligned_histories(
|
||||
[raw_old], [pre_old, pre_new], [ResultCollection(1, 200)], 1,
|
||||
)
|
||||
self.assertEqual(selected_raw, [])
|
||||
self.assertEqual(selected_pre, [pre_new])
|
||||
self.assertEqual(summary['raw_missing_count'], 1)
|
||||
self.assertEqual(results[0].monotonic_ns, 200)
|
||||
|
||||
def test_result_does_not_pair_with_wrong_preprocessed_timestamp(self) -> None:
|
||||
_, pre, _, summary = select_aligned_histories([], [_sweep(1, 20)], [ResultCollection(1, 200)], 1000)
|
||||
self.assertEqual(pre, [])
|
||||
self.assertEqual(summary['preprocessed_missing_count'], 1)
|
||||
|
||||
def test_independent_stage_limit_is_exactly_requested_tail(self) -> None:
|
||||
raw = [_sweep(cid=index, ns=index) for index in range(1005)]
|
||||
selected_raw, _, _, _ = select_aligned_histories(raw, [], [], 1000, include_unaligned=True)
|
||||
self.assertEqual(len(selected_raw), 1000)
|
||||
self.assertEqual((selected_raw[0].collection_id, selected_raw[-1].collection_id), (5, 1004))
|
||||
|
||||
|
||||
class CalibrationMedianIntegrityTest(unittest.TestCase):
|
||||
def test_rejects_frequency_axes_that_changed_between_captures(self) -> None:
|
||||
first, second = _sweep(), _sweep()
|
||||
|
||||
Reference in New Issue
Block a user