Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
+10
-45
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user