new kamil adc
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Tests for Kamil ADC config, frame parsing, TTY reader, and producer wiring."""
|
||||
"""Tests for the Kamil ADC TTY reader, config round-trip, and producer wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,40 +7,30 @@ 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_service import (
|
||||
KamilAdcTtyReader,
|
||||
_parse_point_frame,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
|
||||
from python_app.hardware_full.kamil_adc.protocol import MAIN_MARKER, REFERENCE_MARKER
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
|
||||
|
||||
def _start_frame() -> bytes:
|
||||
return struct.pack("<HHHH", 0x000A, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
def _boundary() -> bytes:
|
||||
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
def _point_frame(step: int, real: int, imag: int, *, marker: int = 0x000A) -> bytes:
|
||||
return struct.pack("<HHhh", marker, step, real, imag)
|
||||
def _main(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
|
||||
|
||||
|
||||
class ParsePointFrameTest(unittest.TestCase):
|
||||
def test_parses_valid_point(self) -> None:
|
||||
value = _parse_point_frame(_point_frame(1, 123, -45), expected_step=1)
|
||||
self.assertEqual(value, complex(123, -45))
|
||||
|
||||
def test_rejects_bad_marker(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "marker mismatch"):
|
||||
_parse_point_frame(_point_frame(1, 10, 20, marker=0x001A), expected_step=1)
|
||||
|
||||
def test_rejects_wrong_step(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "step mismatch"):
|
||||
_parse_point_frame(_point_frame(2, 10, 20), expected_step=1)
|
||||
def _reference(step: int, real: int, imag: int) -> bytes:
|
||||
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
|
||||
|
||||
|
||||
class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
@@ -61,97 +51,68 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
os.close(master_fd)
|
||||
os.close(slave_fd)
|
||||
|
||||
def test_publishes_first_complete_sweep(self) -> None:
|
||||
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,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, -20, 2)
|
||||
+ _start_frame(),
|
||||
_boundary()
|
||||
+ _main(1, 10, -1) + _reference(1, 100, 5)
|
||||
+ _main(2, -20, 2) + _reference(2, 200, 6)
|
||||
+ _boundary(),
|
||||
)
|
||||
values = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
self.assertEqual(reader.locked_points, 2)
|
||||
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_consecutive_constant_length_sweeps(self) -> None:
|
||||
"""Each newly-completed sweep is delivered once new data arrives after a read."""
|
||||
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:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, -20, 2)
|
||||
+ _start_frame(),
|
||||
)
|
||||
# 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.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
self.assertEqual(first.steps.tolist(), [1])
|
||||
|
||||
os.write(
|
||||
master_fd,
|
||||
_point_frame(1, 30, -3) + _point_frame(2, -40, 4) + _start_frame(),
|
||||
_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.tolist(), [complex(30, -3), complex(-40, 4)])
|
||||
self.assertEqual(second.steps.tolist(), [1, 2])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_shorter_sweep_after_lock_raises(self) -> None:
|
||||
"""A later sweep with fewer points than the locked-in count fails fast."""
|
||||
def test_only_latest_sweep_is_published(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, -20, 2)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 30, -3)
|
||||
+ _start_frame(),
|
||||
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()
|
||||
)
|
||||
first = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
with self.assertRaisesRegex(RuntimeError, "sweep length changed"):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
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_longer_sweep_after_lock_raises(self) -> None:
|
||||
"""A later sweep with more points than the locked-in count fails fast."""
|
||||
def test_corrupt_frame_fails_fast(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 30, -3)
|
||||
+ _point_frame(2, -40, 4)
|
||||
+ _start_frame(),
|
||||
)
|
||||
first = reader.read_sweep(timeout_s=1.0)
|
||||
self.assertEqual(first.tolist(), [complex(10, -1)])
|
||||
with self.assertRaisesRegex(RuntimeError, "exceeded locked point count"):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_corrupt_frame_fails_fast_without_resync(self) -> None:
|
||||
"""A garbage frame (bad marker) mid-stream surfaces on read; the reader does
|
||||
NOT silently resync — fail-fast lets the producer die and the supervisor relaunch."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, 5, 5, marker=0x001A) # corrupt marker (not 0x000A)
|
||||
+ _start_frame(),
|
||||
)
|
||||
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:
|
||||
@@ -160,44 +121,12 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
def test_no_completed_sweep_times_out(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
# Start marker plus a partial sweep with no follow-up boundary.
|
||||
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1))
|
||||
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_only_latest_sweep_is_published(self) -> None:
|
||||
"""If multiple sweeps arrive before the consumer reads, only the newest survives."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
payload = (
|
||||
_start_frame()
|
||||
+ _point_frame(1, 1, 0)
|
||||
+ _point_frame(2, 2, 0)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 3, 0)
|
||||
+ _point_frame(2, 4, 0)
|
||||
+ _start_frame()
|
||||
+ _point_frame(1, 5, 0)
|
||||
+ _point_frame(2, 6, 0)
|
||||
+ _start_frame()
|
||||
)
|
||||
os.write(master_fd, payload)
|
||||
# Wait until the reader thread has parsed all three sweeps before
|
||||
# reading from the mailbox — otherwise we'd race the producer and
|
||||
# might consume an intermediate value.
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline and reader.published_count < 3:
|
||||
time.sleep(0.005)
|
||||
self.assertGreaterEqual(reader.published_count, 3)
|
||||
values = reader.read_sweep(timeout_s=1.0)
|
||||
# The reader thread overwrites unread sweeps; the consumer sees the
|
||||
# most recently completed one.
|
||||
self.assertEqual(values.tolist(), [complex(5, 0), complex(6, 0)])
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
|
||||
class KamilAdcConfigTest(unittest.TestCase):
|
||||
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
||||
@@ -207,85 +136,84 @@ class KamilAdcConfigTest(unittest.TestCase):
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": "/home/europa/Documents/kamil_adc",
|
||||
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
|
||||
"project_dir": "",
|
||||
"executable_path": "build/bin/kamil_adc_collector",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": ["profile:phase", "do1_pair_subtract_avg"],
|
||||
"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,
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": True,
|
||||
"port": "/dev/ttyUSB0",
|
||||
"mode": "variation",
|
||||
"pi_coeff1_p": 2560,
|
||||
"pi_coeff1_i": 128,
|
||||
"pi_coeff2_p": 2600,
|
||||
"pi_coeff2_i": 140,
|
||||
"manual": {
|
||||
"temp1": 26.0,
|
||||
"temp2": 27.0,
|
||||
"current1": 31.0,
|
||||
"current2": 32.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,
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": "CHANGE_CURRENT_LD2",
|
||||
"static_temp1": 28.0,
|
||||
"static_temp2": 29.0,
|
||||
"static_current1": 33.0,
|
||||
"static_current2": 34.0,
|
||||
"min_value": 30.0,
|
||||
"max_value": 40.0,
|
||||
"step": 0.5,
|
||||
"time_step": 50,
|
||||
"delay_time": 10,
|
||||
"band": {
|
||||
"start_hz": 2_100_000_000.0,
|
||||
"stop_hz": 5_500_000_000.0,
|
||||
"points": 1024,
|
||||
},
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": 1.0,
|
||||
"stop_hz": 2.0,
|
||||
"points": 2,
|
||||
"if_bandwidth_hz": 1.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
},
|
||||
},
|
||||
"switches": {
|
||||
"port1": {"positions": 1},
|
||||
"port2": {"positions": 1},
|
||||
"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.assertNotIn("points", encoded["radar"]["sweep"])
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["tty_path"], "/tmp/ttyADC_data")
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"])
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["env"], {"ADC_ENV": "1"})
|
||||
self.assertEqual(encoded["radar"]["laser_control"]["mode"], "variation")
|
||||
self.assertEqual(
|
||||
encoded["radar"]["laser_control"]["variation"]["variation_type"],
|
||||
"CHANGE_CURRENT_LD2",
|
||||
)
|
||||
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)
|
||||
|
||||
class _UnreapableProcess:
|
||||
pid = 2_000_000_000 # implausible; killpg is patched out below anyway
|
||||
|
||||
def poll(self) -> None:
|
||||
return None # always "alive"
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
raise subprocess.TimeoutExpired(cmd="kamil_adc_collector", timeout=timeout)
|
||||
|
||||
service._process = _UnreapableProcess() # type: ignore[assignment]
|
||||
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
|
||||
service.close() # 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),
|
||||
],
|
||||
[sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer", "--config", str(config_path)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user