Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""PTY integration, source timing, fresh captures and owned collector lifecycle."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
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.tty_reader import RphRadarTtyReader
|
||||
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
|
||||
|
||||
|
||||
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 RphRadarTtyReaderTest(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.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.RphRadarTtyReader"):
|
||||
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()
|
||||
Reference in New Issue
Block a user