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
+1 -1
View File
@@ -53,7 +53,7 @@ class ComboNavigationTest(unittest.TestCase):
self.w._plots.draw(result)
def test_next_button_draws_cached_combo_without_changing_acquisition(self) -> None:
for model in ("librevna", "librevna_multi", "kamil_adc"):
for model in ("librevna", "librevna_multi", "rph_radar"):
with self.subTest(model=model):
self.w._profile.state.config.radar.model = model
self._seed_bscan()
+1 -1
View File
@@ -63,4 +63,4 @@ printf 'updated environment' > "$(dirname -- "$0")/../state"
self.assertTrue((target / 'run_config_examples/device.json.new').exists())
self.assertEqual((target / 'python_app/runtime/gui_session_state.json').read_text(), 'session')
self.assertFalse((target / 'python_app/runtime/logs').exists())
self.assertIn('kamil_adc_collector', log.read_text().splitlines())
self.assertIn('rph_radar_collector', log.read_text().splitlines())
@@ -303,10 +303,10 @@ class ShutdownReentryTest(unittest.TestCase):
_mock(pipeline._status).assert_called_with("Status: error")
self.assertFalse(pipeline.can_resume(expected_stop_seq))
def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None:
def test_unsupported_rph_radar_mock_fails_before_starting_any_process(self) -> None:
pipeline = _pipeline(self)
config = RunConfigModel()
config.radar.model = "kamil_adc"
config.radar.model = "rph_radar"
config.apply_device_model_constraints()
_mock(pipeline._build_config).return_value = config
pipeline.stop_all = mock.Mock()
+6 -9
View File
@@ -16,7 +16,7 @@ from unittest.mock import Mock, patch
import numpy as np
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
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
@@ -30,7 +30,7 @@ from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatri
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
from python_app.models.dataset_model import SweepCollection
from python_app.orchestration.shm.decoder import RAW_MAGIC, decode_trace_collection
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, producer_common
from python_app.scripts import rph_radar_raw_producer, matrix_raw_producer, producer_common
from python_app.workflows.radar_config_variants import scan_radar_config_variants
@@ -252,7 +252,7 @@ class TtyLifecycleTest(unittest.TestCase):
with self.subTest(combination=combination):
master, slave = pty.openpty()
tty.setraw(slave)
reader = KamilAdcTtyReader(os.ttyname(slave))
reader = RphRadarTtyReader(os.ttyname(slave))
errors = []
started = threading.Event()
reader.open()
@@ -260,10 +260,7 @@ class TtyLifecycleTest(unittest.TestCase):
def wait():
started.set()
try:
if combination is None:
reader.read_sweep(timeout_s=20)
else:
reader.read_sweep_for(combination, timeout_s=20)
reader.read_sweep(timeout_s=20, combo=combination)
except Exception as exc:
errors.append(exc)
@@ -283,7 +280,7 @@ class TtyLifecycleTest(unittest.TestCase):
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
read_fd, write_fd = os.pipe()
reader = KamilAdcTtyReader("/dev/test")
reader = RphRadarTtyReader("/dev/test")
try:
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
with self.assertRaises(RuntimeError):
@@ -298,7 +295,7 @@ class TtyLifecycleTest(unittest.TestCase):
class ProducerCleanupTest(unittest.TestCase):
def test_partial_ring_initialization_closes_first_writer(self) -> None:
for module, model in ((matrix_raw_producer, "librevna_multi"), (kamil_adc_raw_producer, "kamil_adc")):
for module, model in ((matrix_raw_producer, "librevna_multi"), (rph_radar_raw_producer, "rph_radar")):
with self.subTest(producer=module.__name__):
config = RunConfigModel()
config.radar.model = model
-191
View File
@@ -1,191 +0,0 @@
"""Tests for the Kamil ADC TTY wire-protocol parser."""
from __future__ import annotations
import struct
import unittest
import numpy as np
from python_app.hardware_full.kamil_adc.protocol import (
COMBO_MARKER,
MAIN_MARKER,
REFERENCE_MARKER,
KamilAdcStreamParser,
)
def _boundary() -> bytes:
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
def _main(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
def _reference(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
class KamilAdcStreamParserTest(unittest.TestCase):
def test_pairs_main_and_reference_by_step(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, -1) + _reference(1, 100, 5)
+ _main(2, 20, -2) + _reference(2, 200, 6)
+ _boundary()
)
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 1)
sweep = sweeps[0]
self.assertEqual(sweep.steps.tolist(), [1, 2])
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(20, -2)])
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
def test_keeps_only_steps_present_in_both_channels(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, 0) # main only -> dropped
+ _main(2, 20, 0) + _reference(2, 200, 0) # both -> kept
+ _reference(3, 300, 0) # reference only -> dropped
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [2])
self.assertEqual(sweep.main.tolist(), [complex(20, 0)])
self.assertEqual(sweep.reference.tolist(), [complex(200, 0)])
def test_orders_by_ascending_step_regardless_of_arrival(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(3, 3, 0) + _reference(3, 30, 0)
+ _main(1, 1, 0) + _reference(1, 10, 0)
+ _main(2, 2, 0) + _reference(2, 20, 0)
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [1, 2, 3])
self.assertEqual(sweep.main.real.tolist(), [1, 2, 3])
def test_discards_preroll_before_first_boundary(self) -> None:
parser = KamilAdcStreamParser()
# Garbage + a partial point before the first real boundary must be skipped.
stream = (
_main(7, 7, 7) # pre-roll point (no preceding boundary) -> ignored
+ _boundary()
+ _main(1, 11, 0) + _reference(1, 1, 0)
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [1])
def test_handles_chunk_splits_across_frames(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, 0) + _reference(1, 100, 0)
+ _main(2, 20, 0) + _reference(2, 200, 0)
+ _boundary()
)
sweeps: list = []
# Feed one byte at a time to exercise reassembly across feed() calls.
for byte in stream:
sweeps.extend(parser.feed(bytes([byte])))
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].steps.tolist(), [1, 2])
def test_multiple_sweeps_in_one_feed(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 1, 0) + _reference(1, 10, 0)
+ _boundary()
+ _main(1, 2, 0) + _reference(1, 20, 0)
+ _boundary()
)
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 2)
self.assertEqual(sweeps[0].main.real.tolist(), [1])
self.assertEqual(sweeps[1].main.real.tolist(), [2])
def test_empty_sweep_between_boundaries_is_skipped(self) -> None:
parser = KamilAdcStreamParser()
stream = _boundary() + _boundary() + _main(1, 5, 0) + _reference(1, 1, 0) + _boundary()
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].steps.tolist(), [1])
def test_corrupt_marker_raises(self) -> None:
parser = KamilAdcStreamParser()
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
with self.assertRaisesRegex(ValueError, "protocol violation"):
parser.feed(_boundary() + _main(1, 1, 0) + corrupt + _boundary())
def test_partial_trailing_frame_is_buffered(self) -> None:
parser = KamilAdcStreamParser()
self.assertEqual(parser.feed(_boundary() + _main(1, 1, 0)[:5]), [])
# Supply the rest of the frame plus its reference and the closing boundary.
rest = _main(1, 1, 0)[5:]
(sweep,) = parser.feed(rest + _reference(1, 9, 0) + _boundary())
self.assertEqual(sweep.steps.tolist(), [1])
def test_reset_clears_state(self) -> None:
parser = KamilAdcStreamParser()
parser.feed(_boundary() + _main(1, 1, 0))
parser.reset()
# After reset we must re-align on a fresh boundary before collecting.
sweeps = parser.feed(_main(9, 9, 0) + _boundary() + _main(1, 2, 0) + _reference(1, 2, 0) + _boundary())
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].main.real.tolist(), [2])
def test_untagged_sweep_has_no_combo(self) -> None:
parser = KamilAdcStreamParser()
(sweep,) = parser.feed(_boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
self.assertIsNone(sweep.combo)
self.assertFalse(sweep.dirty)
def test_combo_tag_labels_following_sweep(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary() + _combo(1, 2)
+ _main(1, 10, 0) + _reference(1, 100, 0)
+ _boundary() + _combo(3, 0, dirty=1)
+ _main(1, 20, 0) + _reference(1, 200, 0)
+ _boundary()
)
first, second = parser.feed(stream)
self.assertEqual(first.combo, (1, 2))
self.assertFalse(first.dirty)
self.assertEqual(second.combo, (3, 0))
self.assertTrue(second.dirty)
def test_combo_not_carried_into_untagged_sweep(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary() + _combo(1, 1)
+ _main(1, 1, 0) + _reference(1, 1, 0)
+ _boundary() # next sweep has no combo frame
+ _main(2, 2, 0) + _reference(2, 2, 0)
+ _boundary()
)
first, second = parser.feed(stream)
self.assertEqual(first.combo, (1, 1))
self.assertIsNone(second.combo)
def test_dtypes(self) -> None:
parser = KamilAdcStreamParser()
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
self.assertEqual(sweep.main.dtype, np.complex64)
self.assertEqual(sweep.reference.dtype, np.complex64)
self.assertEqual(sweep.steps.dtype, np.int32)
if __name__ == "__main__":
unittest.main()
-312
View File
@@ -1,312 +0,0 @@
"""Tests for the Kamil ADC TTY reader, config round-trip, and producer wiring."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pty
import struct
import subprocess
import sys
import tempfile
import time
import tty
import unittest
from unittest import mock
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
from python_app.hardware_full.kamil_adc.protocol import (
COMBO_MARKER,
MAIN_MARKER,
REFERENCE_MARKER,
)
from python_app.models.run_config_schema import RunConfigModel
from python_app.orchestration.process_supervisor import ProcessSupervisor
def _boundary() -> bytes:
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
def _main(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
def _reference(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
class KamilAdcTtyReaderTest(unittest.TestCase):
"""End-to-end tests over a PTY exercising the background reader thread."""
def _open_pty_reader(self) -> tuple[int, int, KamilAdcTtyReader]:
master_fd, slave_fd = pty.openpty()
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
return master_fd, slave_fd, reader
@staticmethod
def _close(master_fd: int, slave_fd: int, reader: KamilAdcTtyReader) -> None:
try:
reader.close()
finally:
os.close(master_fd)
os.close(slave_fd)
def test_publishes_sweep_with_aligned_main_and_reference(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_boundary()
+ _main(1, 10, -1) + _reference(1, 100, 5)
+ _main(2, -20, 2) + _reference(2, 200, 6)
+ _boundary(),
)
sweep = reader.read_sweep(timeout_s=1.0)
self.assertEqual(sweep.steps.tolist(), [1, 2])
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(-20, 2)])
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
finally:
self._close(master_fd, slave_fd, reader)
def test_variable_length_sweeps_are_allowed(self) -> None:
"""Unlike the old format, sweep length may vary — no locking, just resample later."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
# One complete sweep per write, read between, so delivery is deterministic
# (the reader publishes only the latest, overwriting unread sweeps).
os.write(master_fd, _boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
first = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.steps.tolist(), [1])
os.write(
master_fd,
_main(1, 2, 0) + _reference(1, 8, 0)
+ _main(2, 3, 0) + _reference(2, 7, 0)
+ _boundary(),
)
second = reader.read_sweep(timeout_s=1.0)
self.assertEqual(second.steps.tolist(), [1, 2])
finally:
self._close(master_fd, slave_fd, reader)
def test_only_latest_sweep_is_published(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
payload = (
_boundary() + _main(1, 1, 0) + _reference(1, 1, 0)
+ _boundary() + _main(1, 2, 0) + _reference(1, 2, 0)
+ _boundary() + _main(1, 3, 0) + _reference(1, 3, 0)
+ _boundary()
)
os.write(master_fd, payload)
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline and reader.published_count < 3:
time.sleep(0.005)
self.assertGreaterEqual(reader.published_count, 3)
sweep = reader.read_sweep(timeout_s=1.0)
self.assertEqual(sweep.main.tolist(), [complex(3, 0)])
finally:
self._close(master_fd, slave_fd, reader)
def test_corrupt_frame_fails_fast(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
os.write(master_fd, _boundary() + _main(1, 10, -1) + corrupt + _boundary())
with self.assertRaises((ValueError, RuntimeError)):
reader.read_sweep(timeout_s=1.0)
finally:
self._close(master_fd, slave_fd, reader)
def test_no_completed_sweep_times_out(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(master_fd, _boundary() + _main(1, 10, -1) + _reference(1, 1, 0))
with self.assertRaisesRegex(TimeoutError, "Timed out waiting for Kamil ADC sweep"):
reader.read_sweep(timeout_s=0.1)
finally:
self._close(master_fd, slave_fd, reader)
def test_read_sweep_for_demuxes_by_combo(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_boundary() + _combo(0, 0) + _main(1, 11, 0) + _reference(1, 1, 0)
+ _boundary() + _combo(0, 1) + _main(1, 22, 0) + _reference(1, 1, 0)
+ _boundary(),
)
# Each combination is served from its own slot, regardless of order.
second = reader.read_sweep_for((0, 1), timeout_s=1.0)
self.assertEqual(second.main.real.tolist(), [22])
self.assertEqual(second.combo, (0, 1))
first = reader.read_sweep_for((0, 0), timeout_s=1.0)
self.assertEqual(first.main.real.tolist(), [11])
finally:
self._close(master_fd, slave_fd, reader)
def test_read_sweep_for_drops_dirty_and_takes_retake(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
# A dirty combo (0,1) sweep, then its clean re-take of the same combo.
_boundary() + _combo(0, 1, dirty=1) + _main(1, 99, 0) + _reference(1, 1, 0)
+ _boundary() + _combo(0, 1) + _main(1, 42, 0) + _reference(1, 1, 0)
+ _boundary(),
)
sweep = reader.read_sweep_for((0, 1), timeout_s=1.0)
# The dirty sweep (99) is dropped; only the clean re-take (42) is served.
self.assertEqual(sweep.main.real.tolist(), [42])
self.assertFalse(sweep.dirty)
finally:
self._close(master_fd, slave_fd, reader)
class KamilAdcConfigTest(unittest.TestCase):
def test_config_round_trip_preserves_kamil_sections(self) -> None:
payload = {
"radar": {
"model": "kamil_adc",
"serial": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": "",
"executable_path": "build/bin/kamil_adc_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()
kamil = encoded["radar"]["kamil_adc"]
self.assertEqual(encoded["radar"]["model"], "kamil_adc")
self.assertEqual(kamil["executable_path"], "build/bin/kamil_adc_collector")
self.assertEqual(kamil["args"], ["profile:phase", "do8_freq_ref"])
self.assertEqual(kamil["phase_calibration"]["phase0_rad"], 1.5)
self.assertEqual(kamil["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
self.assertEqual(kamil["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
def test_close_never_raises_when_collector_refuses_to_die(self) -> None:
"""close() must stay exception-safe even if a SIGKILL'd collector is not
reaped within the grace window (e.g. wedged in USB D-state)."""
with tempfile.TemporaryDirectory() as tmp_dir:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": tmp_dir,
"executable_path": "/bin/sh", # any real executable
"tty_path": "/tmp/ttyADC_test",
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
)
service = KamilAdcService(config)
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
process.poll.return_value = None # always "alive"
process.wait.side_effect = subprocess.TimeoutExpired("kamil_adc_collector", timeout=3.0)
service._process = process
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
service.close() # must not raise
def test_drain_after_switch_waits_for_fresh_sweeps(self) -> None:
"""After a switch change, drain must skip the configured number of freshly
published sweeps before returning, so the next capture is post-switch."""
from python_app.hardware_full.kamil_adc import service as service_module
with tempfile.TemporaryDirectory() as tmp_dir:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": tmp_dir,
"executable_path": "/bin/sh",
"tty_path": "/tmp/ttyADC_test",
"sweep_timeout_s": 5.0,
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
)
service = KamilAdcService(config)
reader = mock.Mock(spec=KamilAdcTtyReader, published_count=10)
service._reader = reader
service._process = mock.Mock(poll=mock.Mock(return_value=None))
# Each poll-sleep advances the published count, as the reader thread would.
def _advance(_seconds: float) -> None:
reader.published_count += 1
with mock.patch.object(service_module.time, "sleep", _advance):
service.drain_after_switch(sweeps=3)
# Started at 10, must have waited for at least 3 more sweeps.
self.assertGreaterEqual(reader.published_count, 13)
def test_drain_after_switch_is_noop_when_not_open(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": tmp_dir,
"executable_path": "/bin/sh",
"tty_path": "/tmp/ttyADC_test",
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
)
KamilAdcService(config).drain_after_switch() # no reader → must not raise
def test_supervisor_selects_kamil_adc_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": "kamil_adc"}}), encoding="utf-8")
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
self.assertEqual(
command,
[sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer", "--config", str(config_path)],
)
if __name__ == "__main__":
unittest.main()
+44 -84
View File
@@ -8,9 +8,9 @@ import unittest
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.models import VariationType
from python_app.hardware_full.laser_control.protocol import Protocol, TaskType
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
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.rph_radar.laser import apply_rph_radar_laser_control
from python_app.models.run_config_schema import RunConfigModel
@@ -18,11 +18,6 @@ DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
)
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = (
# Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the
# step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005).
"7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406"
)
class _FakeProtocol:
@@ -43,11 +38,11 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
self.controller.connect.return_value = True
@staticmethod
def _kamil_config(laser_payload: dict[str, object]) -> RunConfigModel:
def _rph_config(laser_payload: dict[str, object]) -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"model": "rph_radar",
"driver_mode": "native",
"laser_control": laser_payload,
}
@@ -71,7 +66,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
def test_variation_commands_match_device_main_bytes(self) -> None:
ld1_command = Protocol.encode_task_enable(
task_type=TaskType.CHANGE_CURRENT_LD1,
static_temp1=28.0,
static_temp2=28.9,
static_current1=33.0,
@@ -81,50 +75,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
step=0.05,
time_step=50,
delay_time=10,
message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560,
pi_coeff1_i=128,
pi_coeff2_p=2560,
pi_coeff2_i=128,
)
ld2_command = Protocol.encode_task_enable(
task_type=TaskType.CHANGE_CURRENT_LD2,
static_temp1=28.0,
static_temp2=28.9,
static_current1=33.0,
static_current2=35.0,
min_value=33.0,
max_value=35.0,
step=0.05,
time_step=50,
delay_time=10,
message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560,
pi_coeff1_i=128,
pi_coeff2_p=2560,
pi_coeff2_i=128,
)
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX)
def test_ld1_and_ld2_encode_current_step_identically(self) -> None:
# Regression guard for the LD2 step-scale bug: both current-variation channels
# must encode the step with the same scale (current_ma_to_n), like min/max.
ld1, ld2 = [Protocol.encode_task_enable(
task_type=task_type, static_temp1=28.0, static_temp2=28.9,
static_current1=33.0, static_current2=35.0, min_value=33.0, max_value=35.0,
step=0.05, time_step=50, delay_time=10, message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128,
) for task_type in (TaskType.CHANGE_CURRENT_LD1, TaskType.CHANGE_CURRENT_LD2)]
# Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step).
self.assertEqual(ld1[10:12], ld2[10:12])
def test_start_sequence_matches_device_main_order(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
controller = LaserController("/dev/ttyLASER", pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol))
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
@@ -137,7 +99,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=VariationType.CHANGE_CURRENT_LD1,
params={
"static_temp1": 28.0,
"static_temp2": 28.9,
@@ -160,34 +121,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
],
)
def test_stop_sequence_restores_device_main_manual_bytes(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol))
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
controller.set_manual_mode(
temp1=28.0,
temp2=28.9,
current1=33.0,
current2=35.0,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
fake_protocol.sent.clear()
controller.stop_task(restore_message_id=DEVICE_MAIN_MESSAGE_ID)
self.assertEqual(
[command.hex() for command in fake_protocol.sent],
[
"2222",
DEVICE_MAIN_MANUAL_HEX,
],
)
def test_apply_radar_variation_sequence_matches_device_main_order(self) -> None:
config = self._kamil_config(
config = self._rph_config(
{
"enabled": True,
"port": "/dev/ttyUSB0",
@@ -211,8 +146,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
applied = apply_kamil_adc_laser_control(config)
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
applied = apply_rph_radar_laser_control(config)
self.assertTrue(applied)
controller = self.controller
@@ -233,13 +168,12 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
)
# The variation handoff itself (type + params), not just call order, must be right.
variation_payload = controller.start_variation.call_args.kwargs
self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1)
self.assertEqual(variation_payload["params"]["step"], 0.05)
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
config = self._kamil_config(
config = self._rph_config(
{
"enabled": True,
"port": "/dev/ttyUSB0",
@@ -247,27 +181,53 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
"manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0},
}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
applied = apply_kamil_adc_laser_control(config)
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
applied = apply_rph_radar_laser_control(config)
self.assertTrue(applied)
controller = self.controller
self.assertEqual([call[0] for call in controller.method_calls], ["connect", "reset", "set_manual_mode", "disconnect"])
self.assertEqual(controller.set_manual_mode.call_args.kwargs["current1"], 30.0)
def test_apply_radar_unknown_variation_type_raises(self) -> None:
config = self._kamil_config(
config = self._rph_config(
{"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation",
"variation": {"variation_type": "NOT_A_REAL_TYPE"}}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
with self.assertRaisesRegex(ValueError, "variation_type"):
apply_kamil_adc_laser_control(config)
apply_rph_radar_laser_control(config)
def test_state_failure_cannot_report_command_success(self) -> None:
controller = LaserController("/dev/ttyLASER")
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
with patch("python_app.hardware_full.laser_control.controller.time.sleep"):
with self.assertRaises(CommunicationError):
controller.reset()
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", "step", 0.0),
("variation", "step", 0.002),
("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})
target = config.radar.laser_control
setattr(target if mode == "pi" else getattr(target, mode), field, value)
with self.subTest(mode=mode, field=field), patch(
"python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller
) as factory:
with self.assertRaises((ValueError, ValidationError)):
apply_rph_radar_laser_control(config)
factory.assert_not_called()
def test_apply_radar_skips_disabled_laser_control(self) -> None:
config = self._kamil_config({"enabled": False})
config = self._rph_config({"enabled": False})
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller) as factory:
applied = apply_kamil_adc_laser_control(config)
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller) as factory:
applied = apply_rph_radar_laser_control(config)
self.assertFalse(applied)
factory.assert_not_called()
@@ -1,218 +0,0 @@
"""Tests for the laser current-variation temperature monitoring package."""
from __future__ import annotations
import tempfile
import threading
import unittest
from unittest import mock
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
from python_app.hardware_full.laser_control.monitoring import (
LaserTemperatureChecker,
LaserTemperatureMonitor,
LaserVariationSession,
ReadingReader,
ReadingWriter,
TemperatureReading,
compute_sweep_period_s,
resolve_period_s,
)
@dataclass
class _FakeMeasurements:
temp1: float
temp2: float
temp_ext1: Optional[float] = None
temp_ext2: Optional[float] = None
current1: Optional[float] = None
current2: Optional[float] = None
class _FakeController:
"""Returns a queued sequence of measurements, then None."""
def __init__(self, measurements: List[Optional[_FakeMeasurements]]) -> None:
self._queue = list(measurements)
def get_measurements(self) -> Optional[_FakeMeasurements]:
return self._queue.pop(0) if self._queue else None
class SessionRoundTripTest(unittest.TestCase):
def test_save_then_load_preserves_targets_and_tolerance(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "session.json"
LaserVariationSession(
variation_type="CHANGE_CURRENT_LD1",
target_temp1=28.0,
target_temp2=28.9,
tolerance_c=0.03,
started_at_iso="2026-07-27T12:00:00",
).save(path)
loaded = LaserVariationSession.load(path)
self.assertEqual(loaded.variation_type, "CHANGE_CURRENT_LD1")
self.assertAlmostEqual(loaded.target_temp1, 28.0)
self.assertAlmostEqual(loaded.target_temp2, 28.9)
self.assertAlmostEqual(loaded.tolerance_c, 0.03)
def test_load_missing_file_raises(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(FileNotFoundError):
LaserVariationSession.load(Path(tmp) / "absent.json")
class ReadingsChannelTest(unittest.TestCase):
def _reading(self, seq: int, t1: float = 25.0, t2: float = 25.0) -> TemperatureReading:
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
def test_reader_tails_appended_lines_in_order(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "readings.jsonl"
reader = ReadingReader(path) # start at (nonexistent) end
with ReadingWriter(path) as writer:
writer.write(self._reading(0, 25.0))
writer.write(self._reading(1, 26.0))
first = list(reader.poll())
writer.write(self._reading(2, 27.0))
second = list(reader.poll())
self.assertEqual([r.seq for r in first], [0, 1])
self.assertEqual([r.seq for r in second], [2])
self.assertAlmostEqual(first[1].temp1, 26.0)
def test_partial_trailing_line_is_buffered_until_newline(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "readings.jsonl"
path.write_text('{"seq":0,"mono_ns":0,"temp1":25.0,"temp2":25.0}\n{"seq":1,"mono',
encoding="utf-8")
reader = ReadingReader(path, from_start=True)
first = list(reader.poll())
# Complete the truncated line.
with path.open("a", encoding="utf-8") as fh:
fh.write('_ns":1,"temp1":26.0,"temp2":26.0}\n')
second = list(reader.poll())
self.assertEqual([r.seq for r in first], [0])
self.assertEqual([r.seq for r in second], [1])
class SweepPeriodTest(unittest.TestCase):
def test_compute_sweep_period_matches_formula(self) -> None:
# (35-33)/0.05 = 40 -> 41 points; per point = 10ms + 50us = 0.01005s.
period = compute_sweep_period_s(33.0, 35.0, 0.05, time_step_us=50, delay_time_ms=10)
self.assertAlmostEqual(period, 41 * 0.01005, places=6)
def test_resolve_interval_strategy(self) -> None:
period = resolve_period_s(
"interval:250", min_value=33.0, max_value=35.0, step=0.05,
time_step_us=50, delay_time_ms=10,
)
self.assertAlmostEqual(period, 0.25)
def test_resolve_rejects_unknown_strategy(self) -> None:
with self.assertRaises(ValueError):
resolve_period_s("bogus", min_value=0, max_value=1, step=0.1,
time_step_us=50, delay_time_ms=10)
class MonitorTest(unittest.TestCase):
def test_read_once_maps_measurement_fields(self) -> None:
controller = _FakeController([_FakeMeasurements(
temp1=28.01, temp2=28.9, temp_ext1=22.0, temp_ext2=23.0,
current1=33.0, current2=35.0,
)])
with tempfile.TemporaryDirectory() as tmp:
with ReadingWriter(Path(tmp) / "r.jsonl") as writer:
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
reading = monitor.read_once(7)
assert reading is not None
self.assertEqual(reading.seq, 7)
self.assertAlmostEqual(reading.temp1, 28.01)
assert reading.temp_ext1 is not None and reading.current2 is not None
self.assertAlmostEqual(reading.temp_ext1, 22.0)
self.assertAlmostEqual(reading.current2, 35.0)
def test_run_publishes_until_stopped(self) -> None:
controller = _FakeController([
_FakeMeasurements(28.0, 28.9),
_FakeMeasurements(28.0, 28.9),
])
stop = threading.Event()
writer = mock.Mock(spec=ReadingWriter)
writer.write.side_effect = lambda _reading: stop.set()
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
monitor.run(stop)
writer.write.assert_called_once()
self.assertEqual(writer.write.call_args.args[0].seq, 0)
class CheckerTest(unittest.TestCase):
def _checker(self, reminder_every: int = 0) -> LaserTemperatureChecker:
return LaserTemperatureChecker(target_temp1=28.0, target_temp2=28.9,
tolerance_c=0.03, reminder_every=reminder_every)
def _reading(self, t1: float, t2: float, seq: int = 0) -> TemperatureReading:
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
def test_laser1_off_target_warns_once_for_laser1(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=28.05, t2=28.9)) # laser1 off by 0.05
self.assertEqual([d.laser for d in warned], [1])
def test_within_tolerance_no_warning(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=28.01, t2=28.9)) # 0.01 < 0.03
self.assertEqual(warned, [])
def test_boundary_equal_tolerance_is_ok(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=28.03, t2=28.9)) # |Δ|==tol -> within
self.assertEqual(warned, [])
def test_both_lasers_off_target_warn_independently(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=27.9, t2=29.0))
self.assertEqual(sorted(d.laser for d in warned), [1, 2])
def test_persistent_mismatch_warns_once_then_silent(self) -> None:
checker = self._checker()
first = checker.process(self._reading(t1=28.1, t2=28.9, seq=0))
second = checker.process(self._reading(t1=28.1, t2=28.9, seq=1))
self.assertEqual([d.laser for d in first], [1])
self.assertEqual(second, []) # no reminder configured
def test_reminder_repeats_warning(self) -> None:
checker = self._checker(reminder_every=2)
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # initial warn
self.assertEqual(checker.process(self._reading(t1=28.1, t2=28.9, seq=1)), [])
again = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # reminder
self.assertEqual([d.laser for d in again], [1])
def test_recovery_clears_mismatch_state(self) -> None:
checker = self._checker()
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # warn
checker.process(self._reading(t1=28.0, t2=28.9, seq=1)) # recover (info, no warn)
rewarn = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # warns again
self.assertEqual([d.laser for d in rewarn], [1])
def test_from_session_uses_session_targets(self) -> None:
session = LaserVariationSession(
variation_type="CHANGE_CURRENT_LD1",
target_temp1=30.0, target_temp2=31.0, tolerance_c=0.03,
)
checker = LaserTemperatureChecker.from_session(session)
warned = checker.process(self._reading(t1=30.1, t2=31.0))
self.assertEqual([d.laser for d in warned], [1])
if __name__ == "__main__":
unittest.main()
@@ -7,7 +7,9 @@ from unittest import mock
from PyQt6.QtWidgets import QApplication, QComboBox, QDoubleSpinBox, QStackedWidget
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
from python_app.gui.controllers.radar_settings_controller import RadarSettingsController
from python_app.gui.controllers.sections.processing_section import build_processing_group
from python_app.gui.controllers.sections.radar_section import build_radar_group
from python_app.models.gui_profile_schema import GuiStateModel
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_schema import RunConfigModel
@@ -33,6 +35,26 @@ class LiveSettingsRegressionTest(unittest.TestCase):
editor = widget(section, f"_{mode}_{bound}_freq_mhz", QDoubleSpinBox)
self.assertEqual((editor.minimum(), editor.maximum(), editor.value()), (1, 9500, expected))
def test_rph_processing_limits_use_band_instead_of_hidden_vna_fields(self) -> None:
config, gui = RunConfigModel(), GuiStateModel()
config.radar.model = "rph_radar"
config.radar.sweep.start_hz, config.radar.sweep.stop_hz = 1e6, 2e6
config.radar.rph_radar.band.start_hz, config.radar.rph_radar.band.stop_hz = 2.1e9, 5.5e9
gui.processing.bscan.start_freq_mhz, gui.processing.bscan.stop_freq_mhz = 2200, 5400
radar, processing = build_radar_group(config), build_processing_group(config, gui)
for group in (radar.group, processing.group):
self.addCleanup(group.deleteLater)
controller = RadarSettingsController(radar, mock.Mock(), processing, lambda: config, mock.Mock(),
reset_preprocess=lambda: None, live_changed=lambda: None)
for stage in ("initial", "resync"):
if stage == "resync":
controller.sync_frequency_limits()
with self.subTest(stage=stage):
for editor in processing.frequency_controls:
self.assertEqual((editor.minimum(), editor.maximum()), (2100, 5500))
self.assertEqual(widget(processing, "_bscan_start_freq_mhz", QDoubleSpinBox).value(), 2200)
self.assertEqual(widget(processing, "_bscan_stop_freq_mhz", QDoubleSpinBox).value(), 5400)
def test_failed_live_write_restores_web_batch_and_desktop_changes(self) -> None:
window = isolated_window(self.addCleanup)
window._pipeline.timer.stop()
+12 -15
View File
@@ -11,12 +11,12 @@ from python_app.models.run_config_schema import RunConfigModel
from python_app.workflows.neutral_preprocess import build_neutral_s21_sets, supports_neutral_preprocess_sets
def _kamil_config(*, points: int = 5) -> RunConfigModel:
def _rph_config(*, points: int = 5) -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"kamil_adc": {
"model": "rph_radar",
"rph_radar": {
"band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points},
},
},
@@ -26,9 +26,9 @@ def _kamil_config(*, points: int = 5) -> RunConfigModel:
)
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
class RphRadarNeutralPreprocessTest(unittest.TestCase):
def test_builds_passthrough_s21_sets_on_band_grid(self) -> None:
calibration, reference = build_neutral_s21_sets(_kamil_config(points=5))
calibration, reference = build_neutral_s21_sets(_rph_config(points=5))
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
self.assertEqual(len(calibration.traces), 2)
@@ -46,22 +46,19 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_grid_matches_processor_grid(self) -> None:
"""The neutral axis must be byte-identical to the acquisition grid."""
from python_app.hardware_full.kamil_adc import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
config = _kamil_config(points=17)
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
config = _rph_config(points=17)
processor = RphRadarSweepProcessor(config.radar.rph_radar)
calibration, _reference = build_neutral_s21_sets(config)
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
def test_rejects_degenerate_band(self) -> None:
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
build_neutral_s21_sets(_kamil_config(points=1))
with self.assertRaisesRegex(ValueError, "band.points must be"):
build_neutral_s21_sets(_rph_config(points=1))
def test_dtypes_are_float32_and_complex64(self) -> None:
calibration, _reference = build_neutral_s21_sets(_kamil_config())
calibration, _reference = build_neutral_s21_sets(_rph_config())
trace = calibration.traces[0]
self.assertEqual(trace.frequency_hz.dtype, np.float32)
self.assertEqual(trace.s21.dtype, np.complex64)
@@ -69,7 +66,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
# The preprocessor divides measured/calibration, so calibration S21
# must never be zero — that is the whole point of the '1+0j neutral' contract.
calibration, _reference = build_neutral_s21_sets(_kamil_config())
calibration, _reference = build_neutral_s21_sets(_rph_config())
for trace in calibration.traces:
self.assertTrue(bool(np.all(trace.s21 != 0)))
@@ -141,9 +141,9 @@ class RadarModelCommandTest(unittest.TestCase):
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_kamil_producer_for_kamil_adc(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
def test_rph_producer_for_rph_radar(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("rph_radar"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer"])
def test_single_and_multi_use_python_in_both_modes(self) -> None:
@@ -270,19 +270,19 @@ class SupervisorLifecycleTest(unittest.TestCase):
group_kill.assert_not_called()
kill.assert_called_once_with(123, signal.SIGTERM)
def test_collector_reaper_does_not_match_command_text(self) -> None:
def test_stop_does_not_kill_unowned_collector(self) -> None:
entries = [Path("/proc/123"), Path("/proc/456")]
cmdlines = [
b"bash\x00-c\x00echo kamil_adc_collector\x00",
b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00",
b"bash\x00-c\x00echo rph_radar_collector\x00",
b"/project/build/bin/rph_radar_collector\x00--config\x00config.json\x00",
]
with (
mock.patch.object(Path, "iterdir", return_value=entries),
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
):
self.supervisor._kill_kamil_collectors()
signal_group.assert_called_once_with(456, signal.SIGKILL)
self.supervisor.stop_all()
signal_group.assert_not_called()
if __name__ == "__main__":
@@ -0,0 +1,91 @@
"""Phase-only calibration preserves acquisition settings and unrelated JSON bytes."""
from contextlib import redirect_stdout
from io import StringIO
import json
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
import numpy as np
from python_app.models.run_config_schema import RunConfigModel
from python_app.scripts import rph_radar_calibrate as calibration
class RphCalibrationTest(unittest.TestCase):
def setUp(self) -> None:
logging_setup = patch.object(calibration.logging, "basicConfig")
logging_setup.start()
self.addCleanup(logging_setup.stop)
self.payload = {"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
"executable_path": "/custom/collector", "args": ["duration_ms:123"],
"band": {"start_hz": 1.2e9, "stop_hz": 1.8e9, "points": 137},
"phase_calibration": {"phase0_rad": 3.456789, "phase1_rad": 9.456789,
"freq0_hz": 1e9, "freq1_hz": 2e9}}}, "note": "Пример: phase0_rad"}
self.config = RunConfigModel.from_dict(self.payload)
self.service = Mock()
def raw(self, phase: np.ndarray) -> SimpleNamespace:
return SimpleNamespace(reference=np.exp(1j * phase), dirty=False)
def test_apply_preserves_every_other_byte_and_uses_current_settings(self) -> None:
for indent, separators in ((None, (",", ":")), (2, (",", ": ")), ("\t", (",", " : "))):
original = json.dumps(self.payload, ensure_ascii=False, indent=indent, separators=separators).replace("\n", "\r\n").encode()
for apply in (False, True):
with self.subTest(indent=indent, apply=apply), TemporaryDirectory() as directory:
path = Path(directory) / "profile.json"
path.write_bytes(original)
self.service.read_raw_sweep.side_effect = [self.raw(np.array([0., 1.]))] + [
self.raw(np.linspace(start, start + 2., 20)) for start in (3.10, -3.10, 3.12)]
argv = ["calibrate", "--config", str(path), "--sweeps", "3", "--warmup", "1"]
with patch.object(calibration, "RphRadarService", return_value=self.service) as factory, \
patch("sys.argv", argv + (["--apply"] if apply else [])), redirect_stdout(StringIO()) as output:
self.assertEqual(calibration.main(), 0)
result = json.loads(output.getvalue())
self.assertAlmostEqual(result["phase0_rad"], 3.12)
self.assertAlmostEqual(result["phase1_rad"], 5.12)
expected = original
if apply:
expected = original.replace(b"3.456789", json.dumps(result["phase0_rad"]).encode()).replace(
b"9.456789", json.dumps(result["phase1_rad"]).encode())
self.assertEqual(path.read_bytes(), expected)
self.assertEqual(factory.call_args.args[0].to_dict(), self.config.to_dict())
self.assertEqual(self.service.read_raw_sweep.call_count, 4)
self.service.close.assert_called_once()
self.service.reset_mock()
def test_invalid_reference_never_produces_anchors_and_always_closes(self) -> None:
for reference in (np.array([]), np.array([1]), np.array([1, np.nan]),
np.array([1, np.inf]), np.array([0, 1]), np.ones(3), np.ones((2, 2))):
self.service.read_raw_sweep.return_value = SimpleNamespace(reference=reference, dirty=False)
with self.subTest(reference=reference), patch.object(calibration, "RphRadarService", return_value=self.service):
with self.assertRaises(ValueError):
calibration.calibrate(self.config, sweeps=1, warmup=0)
self.service.close.assert_called_once()
self.service.reset_mock()
def test_changed_profile_is_not_overwritten(self) -> None:
with TemporaryDirectory() as directory:
path = Path(directory) / "profile.json"
path.write_text(json.dumps(self.payload))
def change_profile(*_args):
path.write_bytes(b"external edit")
return {"phase0_rad": 3., "phase1_rad": 6.}
with patch.object(calibration, "calibrate", side_effect=change_profile), \
patch("sys.argv", ["calibrate", "--config", str(path), "--apply"]), redirect_stdout(StringIO()):
with self.assertRaisesRegex(RuntimeError, "changed during capture"):
calibration.main()
self.assertEqual(path.read_bytes(), b"external edit")
def test_missing_anchors_fail_before_capture(self) -> None:
del self.payload["radar"]["rph_radar"]["phase_calibration"]["phase0_rad"]
with TemporaryDirectory() as directory:
path = Path(directory) / "profile.json"
path.write_text(json.dumps(self.payload))
with patch.object(calibration, "calibrate") as capture, patch("sys.argv", ["calibrate", "--config", str(path)]):
with self.assertRaisesRegex(ValueError, "explicit.*phase0_rad"):
calibration.main()
capture.assert_not_called()
@@ -1,4 +1,4 @@
"""Tests for the Kamil ADC reference-channel signal processing.
"""Tests for the RadioPhotonic radar (RPH) reference-channel signal processing.
References are built from an explicit *unwrapped* phase ramp whose first sample
lies in ``(-pi, pi]`` and whose steps are below ``pi``, so that
@@ -9,15 +9,12 @@ accumulating forward) and lets us reason precisely about the frequency mapping.
from __future__ import annotations
from dataclasses import replace
import unittest
import numpy as np
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
from python_app.models.run_config_schema import RphRadarModel, RphRadarBandModel, RphRadarPhaseCalibrationModel
def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
@@ -25,46 +22,14 @@ def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
return amplitude * np.exp(1j * np.asarray(phase, dtype=np.float64))
# Calibration shared by the processor tests: phase 0 rad -> 2 GHz, phase 100 rad
# -> 4 GHz (slope 2e7 Hz/rad). Band [2.5, 3.5] GHz corresponds to phase [25, 75].
_CALIBRATION = KamilAdcProcessingParams(
phase0_rad=0.0,
freq0_hz=2_000_000_000.0,
phase1_rad=100.0,
freq1_hz=4_000_000_000.0,
band_start_hz=2_500_000_000.0,
band_stop_hz=3_500_000_000.0,
band_points=11,
)
class ProcessingParamsTest(unittest.TestCase):
def _params(self, **overrides: float) -> KamilAdcProcessingParams:
return replace(_CALIBRATION, **overrides)
def test_slope_is_constant_from_anchors(self) -> None:
self.assertAlmostEqual(self._params().hz_per_rad, 2.0e7)
def test_rejects_equal_phase_anchors(self) -> None:
with self.assertRaisesRegex(ValueError, "distinct phases"):
self._params(phase0_rad=5.0, phase1_rad=5.0)
def test_rejects_equal_frequency_anchors(self) -> None:
with self.assertRaisesRegex(ValueError, "distinct frequencies"):
self._params(freq0_hz=3.0e9, freq1_hz=3.0e9)
def test_rejects_inverted_band(self) -> None:
with self.assertRaisesRegex(ValueError, "stop_hz must be greater"):
self._params(band_start_hz=4.0e9, band_stop_hz=3.0e9)
def test_rejects_degenerate_point_count(self) -> None:
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
self._params(band_points=1)
class SweepProcessorTest(unittest.TestCase):
def _processor(self, **overrides: float) -> KamilAdcSweepProcessor:
return KamilAdcSweepProcessor(replace(_CALIBRATION, **overrides))
def _processor(self, **phase: float) -> RphRadarSweepProcessor:
calibration = {"phase0_rad": 0., "freq0_hz": 2e9, "phase1_rad": 100., "freq1_hz": 4e9}
calibration.update(phase)
return RphRadarSweepProcessor(RphRadarModel(
phase_calibration=RphRadarPhaseCalibrationModel(**calibration),
band=RphRadarBandModel(start_hz=2.5e9, stop_hz=3.5e9, points=11),
))
def test_grid_is_fixed_and_identical_across_calls(self) -> None:
processor = self._processor()
@@ -0,0 +1,69 @@
"""Collector packets preserve whole sweeps, source windows and pair identity."""
import struct
import unittest
import numpy as np
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
def packet(*, source_id=1, cycle_id=1, start=100, end=200, combo=(1, 2), dirty=0, points=None):
if points is None:
points = [(0xA, 1, 10, -1), (0xA8, 1, 100, 5)]
payload = b"".join(struct.pack("<HHhh", *point) for point in points)
return struct.pack("<4sIQQQQHHI", b"RPH1", len(payload), source_id, cycle_id,
start, end, *combo, dirty) + payload
class RphRadarStreamParserTest(unittest.TestCase):
def test_fragmented_packets_keep_source_window_and_identity(self):
parser = RphRadarStreamParser()
sweeps = []
for byte in b"preroll" + packet(source_id=73, cycle_id=9):
sweeps.extend(parser.feed(bytes([byte])))
self.assertEqual(len(sweeps), 1)
sweep = sweeps[0]
self.assertEqual((sweep.source_id, sweep.cycle_id, sweep.capture_start_ns, sweep.capture_end_ns),
(73, 9, 100, 200))
self.assertEqual(sweep.combo, (1, 2))
self.assertEqual(sweep.main.tolist(), [10-1j])
self.assertEqual(sweep.reference.tolist(), [100+5j])
self.assertEqual(sweep.main.dtype, np.complex64)
def test_whole_packet_loss_does_not_merge_pairs(self):
parser = RphRadarStreamParser()
first, last = parser.feed(packet(source_id=1) + packet(source_id=3, combo=(2, 3),
points=[(0xA, 2, 20, 0), (0xA8, 2, 200, 0)]))
self.assertEqual((first.combo, first.steps.tolist()), ((1, 2), [1]))
self.assertEqual((last.combo, last.steps.tolist()), ((2, 3), [2]))
self.assertEqual(last.source_id, 3)
def test_aligns_both_channels_and_keeps_only_shared_steps(self):
parser = RphRadarStreamParser()
(sweep,) = parser.feed(packet(points=[(0xA, 3, 3, 0), (0xA8, 3, 30, 0),
(0xA, 1, 1, 0), (0xA8, 1, 10, 0), (0xA, 2, 2, 0)]))
self.assertEqual(sweep.steps.tolist(), [1, 3])
self.assertEqual(sweep.main.real.tolist(), [1, 3])
self.assertEqual(sweep.reference.real.tolist(), [10, 30])
def test_bad_window_size_flags_and_point_marker_fail_fast(self):
for encoded in (packet(start=0), packet(start=200, end=100), packet(dirty=2),
packet(source_id=0), packet(cycle_id=0),
packet(points=[(0x1A, 1, 1, 1)]),
b"RPH1" + struct.pack("<I", 0xFFFFFFFF) + bytes(40)):
with self.subTest(encoded=encoded), self.assertRaisesRegex(ValueError, "protocol"):
RphRadarStreamParser().feed(encoded)
def test_reset_discards_partial_packet(self):
parser = RphRadarStreamParser()
self.assertEqual(parser.feed(packet()[:53]), [])
parser.reset()
(sweep,) = parser.feed(packet(source_id=2, dirty=1, combo=(65535, 65535)))
self.assertEqual(sweep.source_id, 2)
self.assertIsNone(sweep.combo)
self.assertTrue(sweep.dirty)
if __name__ == "__main__":
unittest.main()
+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()
+48 -2
View File
@@ -99,6 +99,17 @@ class StrictNumericTypingTest(unittest.TestCase):
class StructuralTypingTest(unittest.TestCase):
"""A present-but-wrong-shaped section fails loudly instead of being dropped."""
def test_old_adc_model_is_rejected_without_alias(self) -> None:
payload = _valid_payload()
payload["radar"]["model"] = "kamil_adc"
with self.assertRaisesRegex(ValueError, "rph_radar"):
RunConfigModel.from_dict(payload)
def test_inactive_old_adc_section_from_saved_profiles_is_ignored(self) -> None:
payload = _valid_payload()
payload["radar"]["kamil_adc"] = {"tty_path": "/tmp/old"}
self.assertEqual(RunConfigModel.from_dict(payload).radar.model, payload["radar"]["model"])
def test_root_must_be_an_object(self) -> None:
for payload in (None, [], 'bad', 42):
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'):
@@ -112,6 +123,19 @@ class StructuralTypingTest(unittest.TestCase):
class AcquisitionValidationTest(unittest.TestCase):
def test_rph_key_normalizes_active_laser_mode(self) -> None:
keys = []
for mode in ("variation", " Variation ", "VARIATION"):
config = self.config("rph_radar")
laser = config.radar.laser_control
laser.enabled, laser.mode = True, mode
original = config.radar_key()
keys.append(original)
laser.variation.max_value += 1
with self.subTest(mode=mode):
self.assertNotEqual(config.radar_key(), original)
self.assertEqual(len(set(keys)), 1)
def test_default_combo_expansion_is_bounded_before_allocation(self) -> None:
payload = _valid_payload()
payload["switches"]["port1"]["positions"] = 65536
@@ -149,9 +173,31 @@ class AcquisitionValidationTest(unittest.TestCase):
ring.slot_size_bytes = original
validate_acquisition_config(config)
def test_kamil_requires_native_mode(self) -> None:
def test_radar_key_separates_switch_matrices(self) -> None:
stock, per_point = self.config("librevna_multi"), self.config("librevna_multi")
per_point.radar.multi_device.stimulus_switch_positions = 2
self.assertTrue(stock.radar_key().endswith("_out1_in1_stim1"))
self.assertNotEqual(stock.radar_key(), per_point.radar_key())
def test_rph_requires_native_mode(self) -> None:
with self.assertRaisesRegex(ValueError, "native"):
validate_acquisition_config(self.config("kamil_adc"))
validate_acquisition_config(self.config("rph_radar"))
def test_adc_phase_band_and_timeouts_fail_preflight(self) -> None:
for section, field, value in (
("phase_calibration", "phase1_rad", 0.0),
("phase_calibration", "freq1_hz", 2_046_000_000.0),
("phase_calibration", "phase0_rad", float("nan")),
("band", "stop_hz", 2_100_000_000.0),
("band", "start_hz", -1.0), ("band", "points", 1),
("", "sweep_timeout_s", 0.0), ("", "startup_timeout_s", float("inf")),
):
config = self.config("rph_radar")
config.radar.driver_mode = "native"
adc = config.radar.rph_radar
setattr(getattr(adc, section) if section else adc, field, value)
with self.subTest(section=section, field=field), self.assertRaises(ValueError):
validate_acquisition_config(config)
def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None:
config = self.config("librevna_multi")
+31
View File
@@ -75,6 +75,37 @@ class RemoveLastHistoryTest(unittest.TestCase):
class RuntimeSignatureTest(unittest.TestCase):
def test_reference_clock_changes_multi_device_history_only(self) -> None:
for model in ("librevna_multi", "librevna", "rph_radar"):
config = RunConfigModel()
config.radar.model = model
before = build_run_history_signature(config)
config.radar.multi_device.force_external_reference = not config.radar.multi_device.force_external_reference
with self.subTest(model=model):
self.assertEqual(before != build_run_history_signature(config), model == "librevna_multi")
def test_adc_key_and_history_follow_measurement_not_launch_paths(self) -> None:
original = RunConfigModel()
original.radar.model, original.radar.driver_mode = "rph_radar", "native"
for section, field, value, changes_identity in (
("rph_radar.phase_calibration", "phase1_rad", 301.0, True),
("rph_radar.band", "points", 1024, True),
("rph_radar.band", "start_hz", 2.2e9, True),
("rph_radar", "executable_path", "/new/collector", False),
("rph_radar", "tty_path", "/new/tty", False),
("sweep", "if_bandwidth_hz", 999.0, False),
("", "serial", "unused ADC serial", False),
):
changed = original.clone()
target = changed.radar
for part in section.split(".") if section else ():
target = getattr(target, part)
setattr(target, field, value)
with self.subTest(section=section, field=field):
self.assertEqual(original.radar_key() != changed.radar_key(), changes_identity)
self.assertEqual(build_run_history_signature(original) != build_run_history_signature(changed),
changes_identity)
def test_every_ring_endpoint_setting_requires_restart(self) -> None:
original = RunConfigModel()
signature = build_processor_run_signature(original)
+2 -2
View File
@@ -76,8 +76,8 @@ class RadarConfigPrefixTest(unittest.TestCase):
def test_adc_model_reports_adc_point_count(self) -> None:
self.assertEqual(
radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0),
"kamil_adc_1-6GHz_adc_0dBm",
radar_config_filename_prefix("rph_radar", 1e9, 6e9, 1, 0.0),
"rph_radar_1-6GHz_adc_0dBm",
)