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
+85 -22
View File
@@ -11,24 +11,26 @@ import threading
import tty
import unittest
from dataclasses import dataclass
from contextlib import ExitStack
from unittest.mock import Mock, patch
import numpy as np
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.hardware_full.librevna_backends import NativeLibreVnaBackend
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.librevna_driver.api.vna import VNAController
from python_app.hardware_full.librevna_driver.enums import PacketType
from python_app.hardware_full.librevna_driver.exceptions import DeviceDisconnectedError
from python_app.hardware_full.librevna_driver.models import Packet
from python_app.hardware_full.librevna_driver.exceptions import DeviceDisconnectedError, TimeoutError as DeviceTimeout
from python_app.hardware_full.librevna_driver.models import Packet, VNADatapointPacket
from python_app.hardware_full.librevna_driver.session import LibreVNASession
from python_app.hardware_full.librevna_driver.transport.usb import USBTransport
from python_app.hardware_full.switch_drivers.h7992_driver import H7992Driver
from python_app.hardware_full.switch_drivers.hmc349a_driver import HMC349ADriver
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
from python_app.models.run_config_model import RunConfigModel
from python_app.models.dataset_model import ComboKey, TraceData
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, replay_raw_producer
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.orchestration.shm.decoder import RAW_MAGIC, decode_trace_collection
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, replay_raw_producer, producer_common
from python_app.workflows.radar_config_variants import scan_radar_config_variants
@@ -52,11 +54,13 @@ class HardwareCleanupTest(unittest.TestCase):
resource.close.assert_called_once()
def test_native_open_failure_allows_fresh_retry(self) -> None:
backend = NativeLibreVnaBackend(None, 14)
cleanup = ExitStack()
self.addCleanup(cleanup.close)
backend = LibreVnaService()
failed, working = Mock(), Mock()
failed.connect.side_effect = OSError("USB unavailable")
failed.disconnect.side_effect = OSError("disconnect failed")
backend._libre_vna_device = Mock(side_effect=[failed, working])
cleanup.enter_context(patch("python_app.hardware_full.librevna_service.LibreVNADevice", side_effect=[failed, working]))
with self.assertRaisesRegex(OSError, "USB unavailable"):
backend.open()
self.assertFalse(backend.is_open)
@@ -68,16 +72,14 @@ class HardwareCleanupTest(unittest.TestCase):
self.assertFalse(backend.is_open)
def test_gpio_initialization_failure_releases_lines(self) -> None:
for driver_type, module in ((H7992Driver, "h7992_driver"), (HMC349ADriver, "hmc349a_driver")):
with self.subTest(driver=driver_type.__name__):
lines = Mock(set_values=Mock(side_effect=OSError("GPIO write failed")))
driver = driver_type("test")
with patch(f"python_app.hardware_full.switch_drivers.{module}.GpioOutputLines", return_value=lines):
with self.assertRaisesRegex(OSError, "GPIO write failed"):
driver.open()
lines.close.assert_called_once()
self.assertFalse(driver._is_open)
self.assertIsNone(driver._lines)
lines = Mock(set_values=Mock(side_effect=OSError("GPIO write failed")))
driver = H7992Driver("test")
with patch("python_app.hardware_full.switch_drivers.h7992_driver.GpioOutputLines", return_value=lines):
with self.assertRaisesRegex(OSError, "GPIO write failed"):
driver.open()
lines.close.assert_called_once()
self.assertFalse(driver._is_open)
self.assertIsNone(driver._lines)
def test_serial_connect_is_idempotent_and_failed_close_clears_state(self) -> None:
connection = Mock(is_open=True)
@@ -93,6 +95,37 @@ class HardwareCleanupTest(unittest.TestCase):
class SessionLifecycleTest(unittest.TestCase):
def test_slow_sweep_completes_but_lost_progress_times_out_promptly(self) -> None:
for ifbw, dwell_us in ((10, 0), (1000, 100_000)):
with self.subTest(ifbw=ifbw, dwell_us=dwell_us):
session = LibreVNASession()
backend = LibreVnaService()
backend._device = Mock(vna=VNAController(session))
elapsed, index, stalled = 0.0, 0, False
def receive(_packet_type, *, timeout_s):
nonlocal elapsed, index
if stalled or timeout_s < 0.1:
elapsed += timeout_s
raise DeviceTimeout("No datapoint")
elapsed += 0.1
point = VNADatapointPacket(
1_000_000 + index, 0, index,
np.array([2.0, 1.0]), np.zeros(2), np.array([0x13, 0x02]),
)
index += 1
return Packet(PacketType.VNA_DATAPOINT, point)
with patch.object(session, "send"), patch.object(session, "get_device_info", return_value=Mock(num_ports=2)), patch.object(session, "wait_for_packet", side_effect=receive), patch("python_app.hardware_full.librevna_driver.session.time.monotonic", side_effect=lambda: elapsed):
backend.configure(RadarSweepModel(points=301, if_bandwidth_hz=ifbw, dwell_us=dwell_us))
result = backend.acquire()
self.assertEqual(len(result.x), 301)
np.testing.assert_array_equal(result.trace("s21"), np.full(301, 0.5))
elapsed, stalled = 0.0, True
with self.assertRaises(DeviceTimeout):
backend.acquire()
self.assertLess(elapsed, 5.0)
def test_acquisition_discards_tail_before_first_point_zero(self) -> None:
self._assert_complete_cycle([(1, "old"), (2, "old"), (0, "new"), (1, "new"), (2, "new")])
@@ -269,13 +302,14 @@ class ProducerCleanupTest(unittest.TestCase):
config = RunConfigModel()
config.radar.replay.dataset_dir = "/test/data"
config.rings.raw.slot_size_bytes = 1
config.rings.raw_tap.name = "/test_raw_tap"
trace = TraceData(
ComboKey(0, 0), np.array([1, 2], dtype=np.float32),
np.array([0, 0], dtype=np.complex64), np.array([1, 2], dtype=np.complex64),
np.array([1, 2], dtype=np.complex64),
)
dataset = module.ReplayDataset([[trace]], [20])
raw, tap = Mock(), Mock()
with patch.object(module.RunConfigModel, "load_from_path", return_value=config), patch.object(module.signal, "signal"), patch.object(module, "_load_dataset", return_value=dataset), patch.object(module, "ShmRingWriter", side_effect=[raw, tap]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
with patch.object(producer_common.RunConfigModel, "load_from_path", return_value=config), patch.object(producer_common.signal, "signal"), patch.object(module, "_load_dataset", return_value=dataset), patch.object(producer_common, "ShmRingWriter", side_effect=[raw, tap]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
with self.assertRaisesRegex(ValueError, "Replay requires .* bytes per slot"):
module.main()
raw.push.assert_not_called()
@@ -289,8 +323,9 @@ class ProducerCleanupTest(unittest.TestCase):
config = RunConfigModel()
config.radar.model = model
config.radar.replay.dataset_dir = "/test/data"
config.rings.raw_tap.name = "/test_raw_tap"
writer = Mock()
with patch.object(module.RunConfigModel, "load_from_path", return_value=config), patch.object(module.signal, "signal"), patch.object(module, "ShmRingWriter", side_effect=[writer, OSError("second ring failed")]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
with patch.object(producer_common.RunConfigModel, "load_from_path", return_value=config), patch.object(producer_common.signal, "signal"), patch.object(producer_common, "ShmRingWriter", side_effect=[writer, OSError("second ring failed")]), patch("sys.argv", [module.__name__, "--config", "/test/config.json"]):
if module is replay_raw_producer:
with patch.object(module, "_load_dataset", return_value=Mock(median_gap_ns=1, __len__=Mock(return_value=1))):
with self.assertRaisesRegex(OSError, "second ring failed"):
@@ -300,6 +335,34 @@ class ProducerCleanupTest(unittest.TestCase):
module.main()
writer.close.assert_called_once()
def test_disabled_tap_opens_and_publishes_only_the_required_raw_ring(self) -> None:
config = RunConfigModel()
config.rings.raw_tap.name = ""
collection = SweepCollection(1, 50, capture_start_ns=10, capture_end_ns=20)
raw = Mock()
with patch.object(producer_common, "ShmRingWriter", return_value=raw) as factory, ExitStack() as cleanup:
publisher = producer_common.RawPublisher(config.rings, cleanup)
self.assertTrue(publisher.publish(collection))
factory.assert_called_once_with(config.rings.raw.name, config.rings.raw.capacity, config.rings.raw.slot_size_bytes)
raw.close.assert_called_once()
decoded = decode_trace_collection(raw.push.call_args.args[0], RAW_MAGIC)
self.assertEqual((decoded.collection_id, decoded.capture_start_ns, decoded.capture_end_ns), (1, 10, 20))
def test_rejected_raw_is_not_mirrored_but_tap_rejection_does_not_reject_raw(self) -> None:
raw, tap = Mock(push=Mock(return_value=False), slot_size_bytes=64), Mock(push=Mock(return_value=False))
config = RunConfigModel()
config.rings.raw_tap.name = "/test_raw_tap"
collection = SweepCollection(1, 50, capture_start_ns=10, capture_end_ns=20)
with patch.object(producer_common, "ShmRingWriter", side_effect=[raw, tap]), ExitStack() as cleanup:
publisher = producer_common.RawPublisher(config.rings, cleanup)
self.assertFalse(publisher.publish(collection))
tap.push.assert_not_called()
raw.push.return_value = True
self.assertTrue(publisher.publish(collection))
self.assertEqual(raw.push.call_args, tap.push.call_args)
raw.close.assert_called_once()
tap.close.assert_called_once()
class RadarVariantValidationTest(unittest.TestCase):
def test_scan_rejects_malformed_sweeps_and_keeps_valid_overlay(self) -> None: