Add RFG UDP source to rph_radar

This commit is contained in:
Ayzen
2026-10-06 19:36:00 +03:00
parent a185e61495
commit cfe81f782b
29 changed files with 817 additions and 279 deletions
+3 -3
View File
@@ -16,7 +16,7 @@ from unittest.mock import Mock, patch
import numpy as np
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
from python_app.hardware_full.rph_radar.stream_reader import RphRadarStreamReader
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.librevna_driver.api.vna import VNAController
@@ -252,7 +252,7 @@ class TtyLifecycleTest(unittest.TestCase):
with self.subTest(combination=combination):
master, slave = pty.openpty()
tty.setraw(slave)
reader = RphRadarTtyReader(os.ttyname(slave))
reader = RphRadarStreamReader(os.ttyname(slave))
errors = []
started = threading.Event()
reader.open()
@@ -280,7 +280,7 @@ class TtyLifecycleTest(unittest.TestCase):
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
read_fd, write_fd = os.pipe()
reader = RphRadarTtyReader("/dev/test")
reader = RphRadarStreamReader("/dev/test")
try:
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
with self.assertRaises(RuntimeError):
@@ -10,13 +10,15 @@ from unittest.mock import Mock, patch
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
from python_app.hardware_full.laser_control.exceptions import CommunicationError, ValidationError
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.hardware_full.laser_control.conversions import variation_step_count
from python_app.hardware_full.rph_radar.laser import apply_rph_radar_laser_control
from python_app.models.run_config_schema import RunConfigModel
DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc2c163f"
# Generated by the lab GUI encoder (RadioPhotonic_PCB_PC_software device_commands) for these inputs.
DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acd2c173f"
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
"7777ff3701003d2acd2c10008813ffa5cd2c18ab0a00000a8000000a8000b600"
)
@@ -82,6 +84,9 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
)
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
def test_sweep_step_count_follows_the_firmware_loop(self) -> None:
self.assertEqual(variation_step_count(97.3, 107, 0.05), 199) # 199 steps seen on the RFG stand
def test_start_sequence_matches_device_main_order(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
@@ -198,7 +203,7 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
apply_rph_radar_laser_control(config)
def test_state_failure_cannot_report_command_success(self) -> None:
controller = LaserController("/dev/ttyLASER")
controller = LaserController("/dev/ttyLASER", pi_coeff1_p=1280, pi_coeff1_i=25, pi_coeff2_p=1280, pi_coeff2_i=179)
for reply in (b"", b"\x00", b"\x04\x00", b"\x03\x00"):
with self.subTest(reply=reply), patch.object(controller, "_protocol") as protocol:
protocol.receive_raw.return_value = reply
@@ -208,9 +213,9 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
def test_invalid_settings_are_rejected_before_connect_or_reset(self) -> None:
for mode, field, value in (("manual", "temp1", 100.0),
("variation", "static_current2", 100.0),
("variation", "static_current2", 150.0),
("variation", "step", 0.0),
("variation", "step", 0.002),
("variation", "max_value", 97.3001), # same DAC code as min
("variation", "variation_type", "CHANGE_TEMPERATURE_LD1"),
("pi", "pi_coeff1_p", 65536)):
config = self._rph_config({"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual" if mode == "pi" else mode})
+76 -1
View File
@@ -5,7 +5,7 @@ import unittest
import numpy as np
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
from python_app.hardware_full.rph_radar.protocol import RfgSweepAssembler, RphRadarStreamParser
def packet(*, source_id=1, cycle_id=1, start=100, end=200, combo=(1, 2), dirty=0, points=None):
@@ -65,5 +65,80 @@ class RphRadarStreamParserTest(unittest.TestCase):
self.assertTrue(sweep.dirty)
def rfg_datagram(sweep_id, *steps, values=(3, 1, 5, 4, 2, 6), q_shift=27):
"""RFG SWEEP_AVG datagram; ``values`` are in units of 2**(q_shift - 7)."""
return struct.pack("<HH", 6, q_shift) + b"".join(
struct.pack("<I6i", (sweep_id << 24) | step, *[value << (q_shift - 7) for value in values]) for step in steps)
class RfgSweepAssemblerTest(unittest.TestCase):
STEP_NS = 50_000
PERIOD_NS = 20_000_000
def feed(self, assembler, sweep_id, steps, start_ns, delay_ns=7_000):
sweeps = []
for step in steps:
sweeps += assembler.feed(rfg_datagram(sweep_id, step), start_ns + (step + 1) * self.STEP_NS + delay_ns)
return sweeps
def test_complete_sweep_maps_sync_detector_and_reference_and_its_true_window(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
self.feed(assembler, 9, [0, 1, 2], 1_000_000_000)
(sweep,) = assembler.feed(rfg_datagram(10, 0), 1_020_000_000)
self.assertFalse(sweep.dirty)
self.assertEqual(sweep.steps.tolist(), [0, 1, 2])
np.testing.assert_allclose(sweep.main, [(3 - 1 + 1j * (4 - 2)) / 128] * 3) # ch0 - ch1
np.testing.assert_allclose(sweep.reference, [(5 + 6j) / 128] * 3) # ch2
self.assertEqual(sweep.capture_start_ns, 1_000_007_000)
self.assertEqual(sweep.capture_end_ns - sweep.capture_start_ns, 3 * self.STEP_NS)
def test_window_takes_the_least_delayed_point_when_the_board_batches(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
start = 2_000_000_000
assembler.feed(rfg_datagram(1, 0, 1), start + 2 * self.STEP_NS + 10_000) # steps 0-1 batched
assembler.feed(rfg_datagram(1, 2), start + 3 * self.STEP_NS + 10_000)
(sweep,) = assembler.feed(rfg_datagram(2, 0), start + 30_000_000)
self.assertEqual(sweep.capture_start_ns, start + 10_000)
def test_points_of_older_sweeps_are_ignored_until_the_id_space_could_have_wrapped(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
sweeps = []
for sweep_id, start in ((1, 0), (3, 20_000_000), (2, 40_000_000), (4, 60_000_000), (3, 80_000_000)):
sweeps += self.feed(assembler, sweep_id, [0, 1, 2], start)
self.assertEqual([sweep.source_id for sweep in sweeps], [1, 2]) # late 2 and replayed 3 are dropped
sweeps = self.feed(assembler, 2, [0, 1, 2], 80_000_000 + 128 * self.PERIOD_NS) # 4 closes, 2 is new
sweeps += self.feed(assembler, 3, [0], 100_000_000 + 128 * self.PERIOD_NS)
self.assertEqual([(sweep.source_id, sweep.dirty) for sweep in sweeps], [(3, False), (4, False)])
def test_reset_turns_the_rest_of_the_sweep_in_progress_dirty(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
self.feed(assembler, 4, [0], 0)
assembler.reset()
self.feed(assembler, 4, [1, 2], 0)
(sweep,) = self.feed(assembler, 5, [0], 20_000_000)
self.assertTrue(sweep.dirty)
def test_partial_gapped_shortened_or_reordered_sweeps_are_dirty(self):
for steps in ([1, 2], [0, 2], [0, 1], [0, 2, 1], [0, 1, 1, 2]):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
self.feed(assembler, 255, steps, 0)
with self.subTest(steps=steps):
(sweep,) = assembler.feed(rfg_datagram(0, 0), 10_000_000) # 255 -> 0 wraps normally
self.assertTrue(sweep.dirty)
def test_a_reused_id_after_an_outage_does_not_splice_two_sweeps(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
self.feed(assembler, 5, [0], 0)
self.feed(assembler, 5, [1, 2], 4_920_000_000) # 256 sweep periods later
(sweep,) = self.feed(assembler, 6, [0], 4_940_000_000)
self.assertTrue(sweep.dirty)
def test_unexpected_layout_fails_fast(self):
for datagram in (b"\x06", struct.pack("<HH", 5, 27), struct.pack("<HH", 6, 31) + rfg_datagram(1, 0)[4:],
rfg_datagram(1, 0)[:-1]):
with self.subTest(datagram=datagram), self.assertRaisesRegex(ValueError, "protocol"):
RfgSweepAssembler(steps=3, step_ns=1, period_ns=1).feed(datagram, 1)
if __name__ == "__main__":
unittest.main()
+57 -5
View File
@@ -4,6 +4,7 @@ import json
import os
from pathlib import Path
import pty
import socket
import subprocess
import sys
import tempfile
@@ -16,14 +17,15 @@ from unittest import mock
import numpy as np
from python_app.hardware_full.rph_radar.service import RphRadarService
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
from python_app.hardware_full.rph_radar import stream_reader
from python_app.hardware_full.rph_radar.stream_reader import RphRadarStreamReader
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.dataset_model import ComboKey
from python_app.models.run_config_schema import RunConfigModel
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.scripts import rph_radar_raw_producer as producer
from python_app.tests.test_rph_radar_protocol import packet
from python_app.tests.test_rph_radar_protocol import packet, rfg_datagram
def config():
@@ -35,11 +37,61 @@ def config():
})
class RphRadarTtyReaderTest(unittest.TestCase):
class RfgStreamServiceTest(unittest.TestCase):
def test_udp_stream_delivers_complete_sweeps_of_the_laser_task_length(self):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
rfg = RunConfigModel.from_dict({
"radar": {"model": "rph_radar", "driver_mode": "native",
"rph_radar": {"source": "rfg_udp", "udp_bind": f"127.0.0.1:{port}"},
"laser_control": {"mode": "variation", "variation": {
"min_value": 30.0, "max_value": 30.15, "step": 0.05, "time_step": 50}}},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
"run": {"continuous": False, "combos": [{"input": 0, "output": 0}]},
})
service = RphRadarService(rfg)
service.open()
self.addCleanup(service.close)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as board:
for datagram in (rfg_datagram(7, 2, 3), rfg_datagram(8, 0, 1, 2, 3), rfg_datagram(9, 0)):
board.sendto(datagram, ("127.0.0.1", port))
raw = service.read_raw_sweep()
self.assertEqual(raw.steps.tolist(), [0, 1, 2, 3]) # ceil((9879 - 9830) / 16) DAC codes
self.assertFalse(raw.dirty)
self.assertEqual(raw.capture_end_ns - raw.capture_start_ns, 4 * 50_000)
class RfgClockJumpTest(unittest.TestCase):
def test_a_realtime_step_drops_queued_datagrams_and_the_sweep_in_progress(self):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
assembler = mock.Mock(feed=mock.Mock(return_value=[]))
reader = RphRadarStreamReader(f"127.0.0.1:{port}", assembler)
real_offset, step = stream_reader._clock_offset_ns, [0]
with mock.patch.object(stream_reader, "_clock_offset_ns", lambda: real_offset() + step[0]):
reader.open()
self.addCleanup(reader.close)
step[0] = 2_000_000 # REALTIME stepped 2 ms after bind (stamps stay real, so forward)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as board:
board.sendto(rfg_datagram(1, 0), ("127.0.0.1", port))
deadline = time.monotonic() + 1
while not assembler.reset.called and time.monotonic() < deadline:
time.sleep(.005)
assembler.feed.assert_not_called()
board.sendto(rfg_datagram(1, 1), ("127.0.0.1", port))
while not assembler.feed.called and time.monotonic() < deadline:
time.sleep(.005)
assembler.reset.assert_called_once()
assembler.feed.assert_called_once()
class RphRadarStreamReaderTest(unittest.TestCase):
def test_pty_delivers_latest_complete_clean_sweeps_by_pair(self):
master, slave = pty.openpty()
tty.setraw(slave)
reader = RphRadarTtyReader(os.ttyname(slave))
reader = RphRadarStreamReader(os.ttyname(slave))
reader.open()
try:
os.write(master, packet(source_id=1, combo=(0, 0))
@@ -216,7 +268,7 @@ class RphRadarConfigTest(unittest.TestCase):
with mock.patch("python_app.hardware_full.rph_radar.service._prepare_tty_path_for_collector") as unlink, \
mock.patch.object(RphRadarService, "_start_process") as start, \
mock.patch.object(RphRadarService, "_wait_for_tty"), \
mock.patch("python_app.hardware_full.rph_radar.service.RphRadarTtyReader"):
mock.patch("python_app.hardware_full.rph_radar.service.RphRadarStreamReader"):
with self.assertRaisesRegex(RuntimeError, "did not stop"):
service.open()
unlink.assert_not_called()
+32
View File
@@ -183,6 +183,38 @@ class AcquisitionValidationTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "native"):
validate_acquisition_config(self.config("rph_radar"))
def test_rfg_source_needs_a_known_laser_sweep_one_pair_and_a_bind_address(self) -> None:
config = self.config("rph_radar")
config.radar.driver_mode = "native"
config.radar.rph_radar.source = "rfg_udp"
config.radar.laser_control.mode = "variation"
config.input_switch.positions = config.output_switch.positions = 1
config.combos = config.combos[:1]
validate_acquisition_config(config)
restored = RunConfigModel.from_dict(config.to_dict())
self.assertEqual((restored.radar.rph_radar.source, restored.radar.rph_radar.udp_bind),
("rfg_udp", "0.0.0.0:5000"))
e502 = RunConfigModel.from_dict(config.to_dict())
e502.radar.rph_radar.source = "e502"
self.assertNotEqual(config.radar_key(), e502.radar_key())
for field, value, message in (("source", "pcie", "source"), ("udp_bind", "5000", "udp_bind"),
("udp_bind", "192.168.2.1:70000", "udp_bind"),
("udp_bind", "192.168.2.l:5000", "udp_bind")):
broken = RunConfigModel.from_dict(config.to_dict())
setattr(broken.radar.rph_radar, field, value)
with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, message):
validate_acquisition_config(broken)
for change, message in (
(lambda c: setattr(c.radar.laser_control, "mode", "manual"), "variation"),
(lambda c: setattr(c.input_switch, "positions", 2), "one antenna pair"),
(lambda c: setattr(c.radar.laser_control.variation, "time_step", 0), "time_step"),
(lambda c: setattr(c.radar.laser_control.variation, "max_value", 97.32), "two steps"),
):
broken = RunConfigModel.from_dict(config.to_dict())
change(broken)
with self.subTest(message=message), self.assertRaisesRegex(ValueError, message):
validate_acquisition_config(broken)
def test_adc_phase_band_and_timeouts_fail_preflight(self) -> None:
for section, field, value in (
("phase_calibration", "phase1_rad", 0.0),