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
+82 -122
View File
@@ -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)