Rename kamil_adc to rph_radar and fix its data path

This commit is contained in:
Ayzen
2026-10-06 18:15:18 +03:00
parent aef549ea76
commit a185e61495
94 changed files with 2851 additions and 9485 deletions
+243
View File
@@ -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()