Rework radar system
This commit is contained in:
@@ -11,6 +11,10 @@ from __future__ import annotations
|
||||
import struct
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from collections.abc import Callable
|
||||
|
||||
from python_app.models.dataset_model import SweepCollection
|
||||
|
||||
from python_app.hardware_full.librevna_driver.enums import PacketType as NativePacketType
|
||||
from python_app.hardware_full.librevna_driver.models import Packet
|
||||
@@ -21,8 +25,8 @@ from python_app.hardware_full.librevna_multi_device_driver.cycle_collection impo
|
||||
_CrossDeviceCycleSynchronizer,
|
||||
_expected_sweep_cycle_seconds,
|
||||
_is_transient_collection_error,
|
||||
build_forward_s_parameter_names,
|
||||
calculate_last_cycle_s_parameters,
|
||||
calculate_s_parameters,
|
||||
collect_complete_running_sweep_cycle,
|
||||
)
|
||||
from python_app.hardware_full.librevna_multi_device_driver.exceptions import TransientCollectionError
|
||||
from python_app.hardware_full.librevna_multi_device_driver.models import (
|
||||
@@ -38,9 +42,8 @@ from python_app.hardware_full.librevna_multi_device_driver.protocol import (
|
||||
from python_app.hardware_full.multi_device_service import (
|
||||
_MAX_TRANSIENT_COLLECTION_RETRIES,
|
||||
MultiDeviceLibreVnaService,
|
||||
_build_s_parameter_layout,
|
||||
)
|
||||
from python_app.models.run_config_model import RadarSweepModel
|
||||
from python_app.models.run_config_schema import RadarSweepModel
|
||||
|
||||
|
||||
def _control_frame(packet_type: NativePacketType, payload: bytes) -> bytes:
|
||||
@@ -121,6 +124,42 @@ class FrameScannerResyncTest(unittest.TestCase):
|
||||
self.assertGreater(scanner.discarded_byte_count, 0)
|
||||
|
||||
|
||||
class CaptureWindowTest(unittest.TestCase):
|
||||
def test_native_capture_window_excludes_discarded_tail_and_queue_drain(self) -> None:
|
||||
for first_point in (1, 3):
|
||||
with self.subTest(first_point=first_point):
|
||||
clock = threading.local()
|
||||
configuration = SweepConfiguration(1_000_000, 2_000_000, 5, 1000, -10.0)
|
||||
|
||||
def connection(serial):
|
||||
sequence = iter(enumerate([*range(first_point, 5), *range(5)]))
|
||||
|
||||
def receive(*, timeout_seconds):
|
||||
offset, point = next(sequence)
|
||||
clock.ns = 1_000_000_000 + offset * 20_000_000
|
||||
values = [(0x13, 2 + 0j), (0x01, 1 + 0j), (0x02, 1 + 0j),
|
||||
(0x33, 2 + 0j), (0x21, 1 + 0j), (0x22, 1 + 0j)]
|
||||
return 27, _datapoint_frame(1_000_000 + point * 250_000, point, values)[4:-4]
|
||||
|
||||
return Mock(serial_number=serial, receive_packet=receive)
|
||||
|
||||
master, slave1, slave2 = [connection(serial) for serial in ("M", "A", "B")]
|
||||
service = MultiDeviceLibreVnaService("M", ["A", "B"])
|
||||
service.configure(RadarSweepModel(start_hz=1_000_000, stop_hz=2_000_000, points=5, if_bandwidth_hz=1000))
|
||||
service._controller = Mock(collect_running_sweep_cycle=lambda *_args, **_kwargs: collect_complete_running_sweep_cycle(
|
||||
master_device_connection=master, slave_device_connections=[slave1, slave2],
|
||||
active_sweep_configuration=configuration, master_stimulus_ports=(1, 2),
|
||||
))
|
||||
with patch("python_app.hardware_full.multi_device_service.time.monotonic_ns", side_effect=lambda: getattr(clock, "ns", 1)):
|
||||
collection = service.acquire_collection(7)
|
||||
start = 1_000_000_000 + (5 - first_point) * 20_000_000
|
||||
self.assertEqual((collection.capture_start_ns, collection.capture_end_ns), (start, start + 80_000_000))
|
||||
self.assertEqual(len(collection.traces), 8)
|
||||
for trace in collection.traces:
|
||||
self.assertEqual((trace.capture_start_ns, trace.capture_end_ns), (start, start + 80_000_000))
|
||||
np.testing.assert_array_equal(trace.s21, np.full(5, 0.5))
|
||||
|
||||
|
||||
class CrossDeviceCycleSynchronizerTest(unittest.TestCase):
|
||||
"""N1: cycle-0 anchors are accepted only when all devices saw the same wrap."""
|
||||
|
||||
@@ -193,6 +232,7 @@ class DatapointParseHardeningTest(unittest.TestCase):
|
||||
def test_valid_payload_parses(self) -> None:
|
||||
parsed = parse_vna_datapoint_payload(self._payload(7, [(0x00, 1 + 2j), (0x10, 3 + 4j)]))
|
||||
self.assertIsNotNone(parsed)
|
||||
assert parsed is not None
|
||||
self.assertEqual(parsed.point_index, 7)
|
||||
self.assertEqual(parsed.frequency_hz, 3_000_000_000)
|
||||
self.assertEqual(set(parsed.receiver_values_by_description_mask), {0x00, 0x10})
|
||||
@@ -235,83 +275,26 @@ class CycleAlignmentToleranceCalibrationTest(unittest.TestCase):
|
||||
self.assertAlmostEqual(dual, 2.0 * single, places=6)
|
||||
|
||||
|
||||
class LastCycleSParameterTest(unittest.TestCase):
|
||||
"""Reference/measurement division, gap zero-filling, and the over-loss guard.
|
||||
|
||||
A handful of dropped USB frames (points that never arrived, left as ``NaN``)
|
||||
are emitted as zero so the cycle is kept instead of stalling on recovery, while
|
||||
delivered points keep their true normalized value. Losing more than
|
||||
``_MAX_ZERO_FILLED_POINTS_FRACTION`` of a trace is treated as a genuine fault
|
||||
and rejected as transient. The tiny-trace cases below cross that fraction with a
|
||||
single missing point, so they still raise.
|
||||
"""
|
||||
class SParameterTest(unittest.TestCase):
|
||||
"""Normalize delivered samples, zero isolated losses, reject excessive loss."""
|
||||
|
||||
def test_divides_measurement_by_reference(self) -> None:
|
||||
reference = {1: np.array([[2 + 0j, 0 + 2j, 4 + 0j]], dtype=complex)}
|
||||
measurement = {"S31": np.array([[4 + 0j, 0 + 4j, 2 + 0j]], dtype=complex)}
|
||||
result = calculate_last_cycle_s_parameters(measurement, reference)
|
||||
np.testing.assert_allclose(result["s31"], np.array([2 + 0j, 2 + 0j, 0.5 + 0j]))
|
||||
result = calculate_s_parameters(
|
||||
{(0, 0): np.array([4, 4j, 2])}, {0: np.array([2, 2j, 4])})
|
||||
np.testing.assert_allclose(result[(0, 0)], [2, 2, .5])
|
||||
|
||||
def test_single_dropped_measurement_is_zero_filled(self) -> None:
|
||||
# One missing point in a realistically sized trace (< the loss fraction) is
|
||||
# emitted as zero; every delivered point keeps its real value, no extras.
|
||||
point_count = 200
|
||||
reference = {1: np.full((1, point_count), 2 + 0j, dtype=complex)}
|
||||
measurement = {"S31": np.full((1, point_count), 4 + 0j, dtype=complex)}
|
||||
measurement["S31"][0, 100] = np.nan + 1j * np.nan
|
||||
result = calculate_last_cycle_s_parameters(measurement, reference)
|
||||
self.assertEqual(result["s31"][100], 0)
|
||||
np.testing.assert_allclose(np.delete(result["s31"], 100), 2 + 0j)
|
||||
|
||||
def test_single_dropped_reference_is_zero_filled(self) -> None:
|
||||
# A missing master reference cannot normalize that point for any trace, so
|
||||
# the point is zeroed; the rest of the trace is untouched.
|
||||
point_count = 200
|
||||
reference = {1: np.full((1, point_count), 2 + 0j, dtype=complex)}
|
||||
reference[1][0, 50] = np.nan + 1j * np.nan
|
||||
measurement = {"S31": np.full((1, point_count), 4 + 0j, dtype=complex)}
|
||||
result = calculate_last_cycle_s_parameters(measurement, reference)
|
||||
self.assertEqual(result["s31"][50], 0)
|
||||
np.testing.assert_allclose(np.delete(result["s31"], 50), 2 + 0j)
|
||||
|
||||
def test_single_near_zero_reference_is_zero_filled(self) -> None:
|
||||
# A ~zero incident reference would explode the division into noise; one such
|
||||
# point is zeroed rather than aborting the whole cycle, and stays finite.
|
||||
point_count = 200
|
||||
reference = {1: np.full((1, point_count), 2 + 0j, dtype=complex)}
|
||||
reference[1][0, 75] = 0 + 0j
|
||||
measurement = {"S31": np.full((1, point_count), 4 + 0j, dtype=complex)}
|
||||
result = calculate_last_cycle_s_parameters(measurement, reference)
|
||||
self.assertEqual(result["s31"][75], 0)
|
||||
self.assertTrue(np.isfinite(result["s31"]).all())
|
||||
|
||||
def test_excessive_loss_is_transient(self) -> None:
|
||||
# Losing more than the tolerated fraction is a real fault, not a stray
|
||||
# dropped frame: reject as transient so the caller recovers properly.
|
||||
point_count = 200
|
||||
reference = {1: np.full((1, point_count), 2 + 0j, dtype=complex)}
|
||||
measurement = {"S31": np.full((1, point_count), 4 + 0j, dtype=complex)}
|
||||
measurement["S31"][0, :40] = np.nan + 1j * np.nan # 20% > 5% tolerance
|
||||
with self.assertRaises(TransientCollectionError):
|
||||
calculate_last_cycle_s_parameters(measurement, reference)
|
||||
|
||||
def test_nan_reference_is_transient(self) -> None:
|
||||
reference = {1: np.array([[2 + 0j, np.nan + 1j * np.nan, 4 + 0j]], dtype=complex)}
|
||||
measurement = {"S31": np.array([[4 + 0j, 4 + 0j, 2 + 0j]], dtype=complex)}
|
||||
with self.assertRaises(TransientCollectionError):
|
||||
calculate_last_cycle_s_parameters(measurement, reference)
|
||||
|
||||
def test_nan_measurement_is_transient(self) -> None:
|
||||
reference = {1: np.array([[2 + 0j, 2 + 0j, 4 + 0j]], dtype=complex)}
|
||||
measurement = {"S31": np.array([[4 + 0j, np.nan + 1j * np.nan, 2 + 0j]], dtype=complex)}
|
||||
with self.assertRaises(TransientCollectionError):
|
||||
calculate_last_cycle_s_parameters(measurement, reference)
|
||||
|
||||
def test_near_zero_reference_is_transient(self) -> None:
|
||||
reference = {1: np.array([[2 + 0j, 0 + 0j, 4 + 0j]], dtype=complex)}
|
||||
measurement = {"S31": np.array([[4 + 0j, 4 + 0j, 2 + 0j]], dtype=complex)}
|
||||
with self.assertRaises(TransientCollectionError):
|
||||
calculate_last_cycle_s_parameters(measurement, reference)
|
||||
def test_lost_measurement_or_unusable_reference(self) -> None:
|
||||
for source, value in (("measurement", np.nan), ("reference", np.nan), ("reference", 0)):
|
||||
for lost in (1, 40):
|
||||
with self.subTest(source=source, value=value, lost=lost):
|
||||
reference, measurement = np.full(200, 2 + 0j), np.full(200, 4 + 0j)
|
||||
(measurement if source == "measurement" else reference)[:lost] = value
|
||||
if lost == 40:
|
||||
with self.assertRaises(TransientCollectionError):
|
||||
calculate_s_parameters({(0, 0): measurement}, {0: reference})
|
||||
else:
|
||||
result = calculate_s_parameters({(0, 0): measurement}, {0: reference})[(0, 0)]
|
||||
np.testing.assert_array_equal(result, np.r_[0, np.full(199, 2)])
|
||||
|
||||
|
||||
class TransientErrorClassificationTest(unittest.TestCase):
|
||||
@@ -333,11 +316,15 @@ class TransientErrorClassificationTest(unittest.TestCase):
|
||||
class _StubService(MultiDeviceLibreVnaService):
|
||||
"""Non-slots subclass so a test can inject collection behaviour + count reopens."""
|
||||
|
||||
def _acquire_native_collection(self, collection_id: int, capture_start_ns: int): # type: ignore[override]
|
||||
self.collection_calls += 1
|
||||
return self.behavior(collection_id, capture_start_ns)
|
||||
behavior: Callable[[int], SweepCollection]
|
||||
collection_calls: int = 0
|
||||
reopen_calls: int = 0
|
||||
|
||||
def recover(self, **_kwargs) -> None: # type: ignore[override]
|
||||
def _acquire_native_collection(self, collection_id: int) -> SweepCollection:
|
||||
self.collection_calls += 1
|
||||
return self.behavior(collection_id)
|
||||
|
||||
def recover(self, **_kwargs) -> None:
|
||||
self.reopen_calls += 1
|
||||
|
||||
|
||||
@@ -345,41 +332,43 @@ class TransientRetryTierTest(unittest.TestCase):
|
||||
"""F1/F3: transient failures retry in place (no USB reopen); fatals escalate."""
|
||||
|
||||
@staticmethod
|
||||
def _stub(behavior) -> _StubService:
|
||||
service = _StubService(master_serial="M", slave_serials=["A", "B"], backend_mode="native")
|
||||
def _stub(behavior: Callable[[int], SweepCollection]) -> _StubService:
|
||||
service = _StubService(master_serial="M", slave_serials=["A", "B"])
|
||||
service.behavior = behavior
|
||||
service.collection_calls = 0
|
||||
service.reopen_calls = 0
|
||||
return service
|
||||
|
||||
def test_succeeds_after_transient_failures_without_reopen(self) -> None:
|
||||
def flaky(_collection_id: int, _capture_start_ns: int) -> str:
|
||||
collection = SweepCollection(1, 1, [])
|
||||
|
||||
def flaky(_collection_id: int) -> SweepCollection:
|
||||
if service.collection_calls <= _MAX_TRANSIENT_COLLECTION_RETRIES:
|
||||
raise TransientCollectionError("dropped datapoint")
|
||||
return "collection"
|
||||
return collection
|
||||
|
||||
service = self._stub(flaky)
|
||||
result = service._acquire_native_collection_with_transient_retries(1, 0)
|
||||
self.assertEqual(result, "collection")
|
||||
result = service._acquire_native_collection_with_transient_retries(1)
|
||||
self.assertIs(result, collection)
|
||||
self.assertEqual(service.collection_calls, _MAX_TRANSIENT_COLLECTION_RETRIES + 1)
|
||||
self.assertEqual(service.reopen_calls, 0) # never reopened USB for transient errors
|
||||
|
||||
def test_exhausted_transient_retries_raise_transient(self) -> None:
|
||||
def always_transient(_collection_id: int, _capture_start_ns: int) -> str:
|
||||
def always_transient(_collection_id: int) -> SweepCollection:
|
||||
raise TransientCollectionError("persistent drop")
|
||||
|
||||
service = self._stub(always_transient)
|
||||
with self.assertRaises(TransientCollectionError):
|
||||
service._acquire_native_collection_with_transient_retries(1, 0)
|
||||
service._acquire_native_collection_with_transient_retries(1)
|
||||
self.assertEqual(service.collection_calls, _MAX_TRANSIENT_COLLECTION_RETRIES + 1)
|
||||
|
||||
def test_fatal_error_is_not_retried_in_place(self) -> None:
|
||||
def fatal(_collection_id: int, _capture_start_ns: int) -> str:
|
||||
def fatal(_collection_id: int) -> SweepCollection:
|
||||
raise RuntimeError("USB transport failed")
|
||||
|
||||
service = self._stub(fatal)
|
||||
with self.assertRaises(RuntimeError):
|
||||
service._acquire_native_collection_with_transient_retries(1, 0)
|
||||
service._acquire_native_collection_with_transient_retries(1)
|
||||
self.assertEqual(service.collection_calls, 1) # fatal escalates immediately, no in-place retry
|
||||
|
||||
|
||||
@@ -435,35 +424,6 @@ class PerPointStimulusSwitchingTest(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
build_output_channels((1, 2), bad)
|
||||
|
||||
def test_trace_names_do_not_collide_and_stay_stable(self) -> None:
|
||||
two_output_names = build_forward_s_parameter_names(2, build_output_channels((1, 2), 1))
|
||||
self.assertEqual(
|
||||
two_output_names,
|
||||
["S11", "S31", "S41", "S51", "S61", "S22", "S32", "S42", "S52", "S62"],
|
||||
)
|
||||
four_output_names = build_forward_s_parameter_names(2, build_output_channels((1, 2), 2))
|
||||
# Receivers move to ports 5..8 so no forward term can be named like a
|
||||
# reflection (a fixed base of 3 would make receiver 3 <- output 3 collide
|
||||
# with the reflection of output 3).
|
||||
self.assertEqual(len(four_output_names), len(set(four_output_names)))
|
||||
self.assertEqual(len(four_output_names), 4 * 5)
|
||||
for reflection in ("S11", "S22", "S33", "S44"):
|
||||
self.assertIn(reflection, four_output_names)
|
||||
|
||||
def test_service_and_collector_agree_on_trace_names(self) -> None:
|
||||
# The service looks these up by name in whatever the collector produced, so a
|
||||
# silent divergence would surface only as a missing-trace failure on hardware.
|
||||
for substage_count in (1, 2):
|
||||
channels = build_output_channels((1, 2), substage_count)
|
||||
produced = {name.lower() for name in build_forward_s_parameter_names(2, channels)}
|
||||
forward_by_output, reflection_by_output = _build_s_parameter_layout(
|
||||
len(channels), slave_count=2
|
||||
)
|
||||
requested = set(reflection_by_output.values())
|
||||
for names in forward_by_output.values():
|
||||
requested.update(names)
|
||||
self.assertEqual(requested, produced, msg=f"substages={substage_count}")
|
||||
|
||||
def test_sweep_settings_carry_stages_and_substage_width(self) -> None:
|
||||
configuration = SweepConfiguration(
|
||||
start_hz=1_000_000, stop_hz=2_000_000, points=101, if_bandwidth=1000, power_dbm=-10.0
|
||||
@@ -517,7 +477,7 @@ class PerPointStimulusSwitchingTest(unittest.TestCase):
|
||||
|
||||
def test_service_reports_four_outputs_when_switching(self) -> None:
|
||||
service = MultiDeviceLibreVnaService(
|
||||
master_serial="M", slave_serials=["A", "B"], backend_mode="native", switch_substage_count=2
|
||||
master_serial="M", slave_serials=["A", "B"], switch_substage_count=2
|
||||
)
|
||||
self.assertEqual(service.output_position_count, 4)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user