Add RFG UDP source to rph_radar
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user