296 lines
16 KiB
Python
296 lines
16 KiB
Python
"""PTY integration, source timing, fresh captures and owned collector lifecycle."""
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import pty
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import tty
|
|
import unittest
|
|
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 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, rfg_datagram
|
|
|
|
|
|
def config():
|
|
return RunConfigModel.from_dict({
|
|
"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
|
|
"executable_path": "/bin/sh", "tty_path": "/tmp/rph-test"}},
|
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
|
"run": {"continuous": False, "combos": [{"input": 0, "output": 0}]},
|
|
})
|
|
|
|
|
|
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 = RphRadarStreamReader(os.ttyname(slave))
|
|
reader.open()
|
|
try:
|
|
os.write(master, packet(source_id=1, combo=(0, 0))
|
|
+ packet(source_id=2, combo=(0, 1), dirty=1)
|
|
+ packet(source_id=3, combo=(0, 1)))
|
|
deadline = time.monotonic() + 1
|
|
while reader.published_count < 3 and time.monotonic() < deadline:
|
|
time.sleep(.005)
|
|
self.assertEqual(reader.read_sweep(timeout_s=1).source_id, 3)
|
|
self.assertEqual(reader.read_sweep(combo=(0, 0), timeout_s=1).source_id, 1)
|
|
self.assertEqual(reader.read_sweep(combo=(0, 1), timeout_s=1).source_id, 3)
|
|
with self.assertRaises(TimeoutError):
|
|
reader.read_sweep(combo=(0, 1), timeout_s=.01)
|
|
os.write(master, packet(start=0))
|
|
with self.assertRaisesRegex(ValueError, "protocol"):
|
|
reader.read_sweep(timeout_s=1)
|
|
finally:
|
|
reader.close()
|
|
os.close(master)
|
|
os.close(slave)
|
|
|
|
def test_new_source_session_rejects_backlog_despite_adc_clock_drift(self):
|
|
for settling_ms in (0, 2):
|
|
with self.subTest(settling_ms=settling_ms):
|
|
cfg = config()
|
|
cfg.runtime.settling_ms = settling_ms
|
|
service = RphRadarService(cfg)
|
|
# Physically this packet crossed the switch; a +50 ppm ADC clock
|
|
# after 1000 s puts its timestamp 44 ms past the host switch.
|
|
stale, fresh = RphRadarStreamParser().feed(
|
|
packet(source_id=9, start=1_044_000_000, end=1_064_000_000)
|
|
+ packet(source_id=1, cycle_id=1, start=1_100_000_000, end=1_120_000_000,
|
|
points=[(0xA, 1, 20, 0), (0xA8, 1, 100, 0)]))
|
|
old_reader = mock.Mock(read_sweep=mock.Mock(return_value=stale))
|
|
new_reader = mock.Mock(read_sweep=mock.Mock(return_value=fresh))
|
|
service._reader = old_reader
|
|
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
|
service._stop_event = threading.Event()
|
|
processor = mock.Mock(grid_hz=np.array([1.]), _previous_anchor_rad=3.3,
|
|
process=mock.Mock(side_effect=lambda main, reference: main))
|
|
service._processor = processor
|
|
|
|
def reopen(_service, *, stop_event):
|
|
old_reader.close.assert_called_once()
|
|
self.assertIs(stop_event, service._stop_event)
|
|
service._reader = new_reader
|
|
service._process = mock.Mock(poll=mock.Mock(return_value=None))
|
|
|
|
with mock.patch.object(RphRadarService, "open", autospec=True, side_effect=reopen) as opened, \
|
|
mock.patch("python_app.hardware_full.rph_radar.service.time.monotonic_ns", return_value=1_000_000_000), \
|
|
mock.patch("python_app.hardware_full.rph_radar.service.time.sleep") as sleep:
|
|
with mock.patch.object(RphRadarService, "_stop_process", side_effect=lambda: setattr(service._process, "poll", mock.Mock(return_value=0))):
|
|
service.prepare_combo(ComboKey(0, 0))
|
|
result = service.acquire()
|
|
np.testing.assert_array_equal(result.trace("s21"), [20+0j])
|
|
service.prepare_combo(ComboKey(0, 0))
|
|
opened.assert_called_once()
|
|
self.assertEqual(sleep.call_count, int(settling_ms == 0))
|
|
self.assertEqual((result.capture_start_ns, result.capture_end_ns), (1_100_000_000, 1_120_000_000))
|
|
self.assertIs(service._processor, processor)
|
|
self.assertEqual(processor._previous_anchor_rad, 3.3)
|
|
old_reader.read_sweep.assert_not_called()
|
|
|
|
def test_collector_output_is_captured_in_existing_log(self):
|
|
import io
|
|
service = RphRadarService(config())
|
|
process = mock.Mock(stdout=io.BytesIO(b"ADC disconnected\n"), poll=mock.Mock(return_value=1))
|
|
with mock.patch("python_app.hardware_full.rph_radar.service.subprocess.Popen", return_value=process) as launch:
|
|
with self.assertLogs("python_app.hardware_full.rph_radar.service", "INFO") as logs:
|
|
service._start_process()
|
|
service.close()
|
|
self.assertIn("ADC disconnected", " ".join(logs.output))
|
|
self.assertEqual(launch.call_args.kwargs["stdout"], subprocess.PIPE)
|
|
|
|
def test_producer_drains_switch_and_never_mixes_source_cycles(self):
|
|
for native in (False, True):
|
|
with self.subTest(collector_driven=native):
|
|
cfg = config()
|
|
cfg.input_switch.driver_mode = "native"
|
|
cfg.output_switch.driver_mode = "native" if native else "mock"
|
|
cfg.combos = cfg.build_full_combos(1, 2) if native else cfg.combos
|
|
radar, publisher = mock.Mock(), mock.Mock()
|
|
windows = iter(((1, 1, 5), (2, 6, 10), (3, 11, 15), (3, 16, 20)) if native
|
|
else ((1, 11, 20),))
|
|
|
|
def acquire(**_kwargs):
|
|
radar.last_cycle_id, start, end = next(windows)
|
|
return SweepResult(np.array([1., 2.]), {"s21": np.ones(2)},
|
|
capture_start_ns=start, capture_end_ns=end)
|
|
|
|
radar.acquire.side_effect = acquire
|
|
with mock.patch.object(producer, "load_producer_config", return_value=(Path("unused"), cfg, threading.Event())), \
|
|
mock.patch.object(producer, "RphRadarService", return_value=radar), \
|
|
mock.patch.object(producer, "RawPublisher", return_value=publisher), \
|
|
mock.patch.object(producer.SwitchService, "from_model"), \
|
|
mock.patch.object(producer, "_open_radar_with_retry", return_value=True), \
|
|
mock.patch.object(producer, "select_and_settle", return_value=True):
|
|
self.assertEqual(producer.main(), 0)
|
|
if not native:
|
|
calls = radar.mock_calls
|
|
self.assertLess(calls.index(mock.call.prepare_combo(ComboKey(0, 0))), calls.index(mock.call.acquire()))
|
|
publisher.publish.assert_called_once()
|
|
collection = publisher.publish.call_args.args[0]
|
|
self.assertEqual(collection.collection_id, 3 if native else 1)
|
|
self.assertEqual((collection.capture_start_ns, collection.capture_end_ns), (11, 20))
|
|
self.assertEqual(collection.traces[0].capture_start_ns, 11)
|
|
|
|
|
|
class RphRadarConfigTest(unittest.TestCase):
|
|
def test_collector_uses_current_settings_without_rereading_profile(self):
|
|
cfg = config()
|
|
cfg.runtime.settling_ms = 7
|
|
service = RphRadarService(cfg, collector_driven=True)
|
|
payload = next(arg.removeprefix("switching:") for arg in service.command if arg.startswith("switching:"))
|
|
switching = json.loads(payload)
|
|
self.assertEqual(switching["settling_ms"], 7)
|
|
self.assertEqual(switching["combos"], [[0, 0]])
|
|
self.assertEqual(switching["input"]["gpio_chip"], cfg.input_switch.gpio_chip)
|
|
self.assertFalse(any(arg.startswith("config:") for arg in service.command))
|
|
|
|
def test_config_round_trip_preserves_rph_sections(self) -> None:
|
|
payload = {
|
|
"radar": {
|
|
"model": "rph_radar",
|
|
"serial": "rph_radar",
|
|
"driver_mode": "native",
|
|
"rph_radar": {
|
|
"project_dir": "",
|
|
"executable_path": "build/bin/rph_radar_collector",
|
|
"tty_path": "/tmp/ttyADC_data",
|
|
"args": ["profile:phase", "do8_freq_ref"],
|
|
"env": {"ADC_ENV": "1"},
|
|
"startup_timeout_s": 7.0,
|
|
"sweep_timeout_s": 8.0,
|
|
"stop_timeout_s": 3.0,
|
|
"phase_calibration": {
|
|
"phase0_rad": 1.5,
|
|
"freq0_hz": 2_046_000_000.0,
|
|
"phase1_rad": 301.0,
|
|
"freq1_hz": 5_612_000_000.0,
|
|
},
|
|
"band": {
|
|
"start_hz": 2_100_000_000.0,
|
|
"stop_hz": 5_500_000_000.0,
|
|
"points": 1024,
|
|
},
|
|
},
|
|
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
|
|
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
|
|
},
|
|
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
|
|
}
|
|
|
|
encoded = RunConfigModel.from_dict(payload).to_dict()
|
|
rph = encoded["radar"]["rph_radar"]
|
|
|
|
self.assertEqual(encoded["radar"]["model"], "rph_radar")
|
|
self.assertEqual(rph["executable_path"], "build/bin/rph_radar_collector")
|
|
self.assertEqual(rph["args"], ["profile:phase", "do8_freq_ref"])
|
|
self.assertEqual(rph["phase_calibration"]["phase0_rad"], 1.5)
|
|
self.assertEqual(rph["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
|
|
self.assertEqual(rph["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
|
|
|
|
def test_unreaped_collector_blocks_prepare_and_retry_before_touching_tty(self):
|
|
service = RphRadarService(config())
|
|
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
|
|
process.poll.return_value = None
|
|
process.wait.side_effect = subprocess.TimeoutExpired("rph_radar_collector", timeout=.5)
|
|
service._process = process
|
|
with mock.patch("python_app.hardware_full.rph_radar.service.os.killpg"):
|
|
with self.assertRaisesRegex(RuntimeError, "did not stop"):
|
|
service.prepare_combo(ComboKey(0, 0))
|
|
service.close() # Producer retry closes again; still exception-safe.
|
|
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.RphRadarStreamReader"):
|
|
with self.assertRaisesRegex(RuntimeError, "did not stop"):
|
|
service.open()
|
|
unlink.assert_not_called()
|
|
start.assert_not_called()
|
|
self.assertIs(service._process, process)
|
|
process.poll.return_value = 0
|
|
service.open() # Recovery becomes possible only after actual exit.
|
|
start.assert_called_once()
|
|
service.close()
|
|
|
|
def test_supervisor_selects_rph_radar_producer(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
config_path = Path(tmp_dir) / "run_config.json"
|
|
config_path.write_text(json.dumps({"radar": {"model": "rph_radar"}}), encoding="utf-8")
|
|
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
|
|
|
|
self.assertEqual(
|
|
command,
|
|
[sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer", "--config", str(config_path)],
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|