new kamil adc

This commit is contained in:
Ayzen
2026-06-11 13:02:02 +03:00
parent 21f76d7cd2
commit 9661504e51
46 changed files with 12097 additions and 1063 deletions
@@ -30,6 +30,22 @@ class GuiProfileCodecTest(unittest.TestCase):
self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0")
self.assertEqual(encoded["gui"]["processing"]["pass_through"]["combo_filter"], "0:0,1:0")
def test_pass_through_unwrap_phase_round_trips(self) -> None:
profile = GuiProfileModel(
gui=GuiStateModel(
processing=GuiProcessingStateModel(
pass_through=GuiPassThroughStateModel(unwrap_phase=True)
)
)
)
encoded = profile.to_dict()
decoded = GuiProfileModel.from_dict(encoded)
assert decoded.gui is not None
self.assertTrue(decoded.gui.processing.pass_through.unwrap_phase)
self.assertTrue(encoded["gui"]["processing"]["pass_through"]["unwrap_phase"])
def test_default_profile_round_trips_idempotently(self) -> None:
# Full-subtree idempotence catches field-drop/mis-map regressions across every
# sub-model, which the single combo_filter round-trip above cannot.
@@ -10,35 +10,26 @@ from python_app.models.run_config_model import RunConfigModel
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
def _kamil_config(*, points: int = 5) -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"kamil_adc": {
"band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points},
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
}
)
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_builds_passthrough_s21_sets_for_current_sweep(self) -> None:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"sweep": {
"start_hz": 1_000_000.0,
"stop_hz": 4_000_000.0,
"if_bandwidth_hz": 1.0,
"stimulus_power_dbm": -10.0,
},
},
"switches": {
"port1": {"positions": 1},
"port2": {"positions": 2},
},
"run": {
"combos": [
{"input": 0, "output": 0},
{"input": 1, "output": 0},
],
},
}
)
def test_builds_passthrough_s21_sets_on_band_grid(self) -> None:
calibration, reference = build_kamil_adc_neutral_s21_sets(_kamil_config(points=5))
calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count=4)
expected_frequency = np.linspace(1_000_000.0, 4_000_000.0, 4, dtype=np.float32)
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
self.assertEqual(len(calibration.traces), 2)
self.assertEqual(len(reference.traces), 2)
self.assertEqual(
@@ -47,47 +38,35 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
)
for trace in calibration.traces:
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.ones(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s11, np.zeros(5, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.ones(5, dtype=np.complex64))
for trace in reference.traces:
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.zeros(5, dtype=np.complex64))
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,
)
config = _kamil_config(points=17)
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
calibration, _reference = build_kamil_adc_neutral_s21_sets(config)
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
def test_rejects_non_kamil_config(self) -> None:
config = RunConfigModel.from_dict({"radar": {"model": "librevna"}})
with self.assertRaisesRegex(ValueError, "kamil_adc"):
build_kamil_adc_neutral_s21_sets(config, point_count=4)
build_kamil_adc_neutral_s21_sets(config)
@staticmethod
def _kamil_config() -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"sweep": {"start_hz": 1_000_000.0, "stop_hz": 4_000_000.0,
"if_bandwidth_hz": 1.0, "stimulus_power_dbm": -10.0},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
}
)
def test_point_count_zero_or_negative_raises(self) -> None:
config = self._kamil_config()
for bad in (0, -1):
with self.subTest(point_count=bad), self.assertRaisesRegex(ValueError, "point count"):
build_kamil_adc_neutral_s21_sets(config, point_count=bad)
def test_single_point_sweep(self) -> None:
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=1)
for trace in calibration.traces:
self.assertEqual(trace.frequency_hz.tolist(), [1_000_000.0])
self.assertEqual(trace.s21.shape, (1,))
def test_rejects_degenerate_band(self) -> None:
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
build_kamil_adc_neutral_s21_sets(_kamil_config(points=1))
def test_dtypes_are_float32_and_complex64(self) -> None:
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
calibration, _reference = build_kamil_adc_neutral_s21_sets(_kamil_config())
trace = calibration.traces[0]
self.assertEqual(trace.frequency_hz.dtype, np.float32)
self.assertEqual(trace.s21.dtype, np.complex64)
@@ -96,7 +75,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
# The C++ through-calibrator divides measured/calibration, so calibration S21
# must never be zero — that is the whole point of the '1+0j neutral' contract.
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
calibration, _reference = build_kamil_adc_neutral_s21_sets(_kamil_config())
for trace in calibration.traces:
self.assertTrue(bool(np.all(trace.s21 != 0)))
@@ -0,0 +1,172 @@
"""Tests for the Kamil ADC 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
``np.unwrap(np.angle(ref))`` recovers exactly the phase we specify. This mirrors
how a real swept reference behaves (absolute phase anchored at sample 0,
accumulating forward) and lets us reason precisely about the frequency mapping.
"""
from __future__ import annotations
import unittest
import numpy as np
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
"""A reference signal whose unwrapped phase equals ``phase`` (radians)."""
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 = dict(
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 KamilAdcProcessingParams(**{**_CALIBRATION, **overrides}) # type: ignore[arg-type]
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(KamilAdcProcessingParams(**{**_CALIBRATION, **overrides})) # type: ignore[arg-type]
def test_grid_is_fixed_and_identical_across_calls(self) -> None:
processor = self._processor()
grid = processor.grid_hz
self.assertEqual(grid.shape, (11,))
self.assertAlmostEqual(float(grid[0]), 2.5e9)
self.assertAlmostEqual(float(grid[-1]), 3.5e9)
np.testing.assert_array_equal(grid, processor.grid_hz)
def test_frequency_axis_follows_calibration_law(self) -> None:
processor = self._processor()
# A dense ramp 0 -> 50 rad: endpoints map to 2 GHz and 3 GHz.
ref = _reference(np.linspace(0.0, 50.0, 201))
freqs = processor.reference_frequency_axis(ref)
self.assertAlmostEqual(float(freqs[0]), 2.0e9, delta=1.0)
self.assertAlmostEqual(float(freqs[-1]), 3.0e9, delta=1.0)
def test_uses_config_constants_not_sweep_endpoints(self) -> None:
"""Two sweeps with different phase spans map a given absolute phase to the
SAME frequency — proving fixed config anchors, not endpoint normalization."""
processor = self._processor()
ref_short = _reference(np.linspace(0.0, 100.0, 401)) # spans phase [0, 100]
ref_long = _reference(np.linspace(0.0, 130.0, 521)) # spans phase [0, 130]
# Encode S(f) = (f - 3 GHz) / 1 GHz, a line in TRUE frequency.
line = lambda ref: ((processor.reference_frequency_axis(ref) - 3.0e9) / 1.0e9) * np.abs(ref)
s_short = processor.process(line(ref_short), ref_short)
s_long = processor.process(line(ref_long), ref_long)
self.assertIsNotNone(s_short)
self.assertIsNotNone(s_long)
expected = (processor.grid_hz.astype(np.float64) - 3.0e9) / 1.0e9
# Endpoint normalization would compress the longer sweep's axis and break this.
np.testing.assert_allclose(s_short.real, expected, atol=1e-3)
np.testing.assert_allclose(s_long.real, expected, atol=1e-3)
np.testing.assert_allclose(s_short.imag, 0.0, atol=1e-3)
def test_amplitude_normalization_divides_by_reference_magnitude(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401), amplitude=4.0)
# |main| = 8 everywhere -> |S| = 8 / 4 = 2.
main = 8.0 * np.exp(1j * np.angle(ref))
result = processor.process(main, ref)
self.assertIsNotNone(result)
np.testing.assert_allclose(np.abs(result), 2.0, atol=1e-3)
def test_rejects_sweep_that_does_not_cover_band(self) -> None:
processor = self._processor()
# Phase [0, 40] -> freq [2.0, 2.8] GHz, short of the 3.5 GHz band stop.
ref = _reference(np.linspace(0.0, 40.0, 201))
self.assertIsNone(processor.process(np.ones(201, dtype=np.complex128), ref))
def test_accepts_sweep_that_covers_band(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401)) # freq [2.0, 4.0] GHz
result = processor.process(np.abs(ref).astype(np.complex128), ref)
self.assertIsNotNone(result)
self.assertEqual(result.shape, (11,))
self.assertEqual(result.dtype, np.complex64)
np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3) # main=|ref| -> |S|=1
def test_interpolates_linear_trace_onto_grid(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401))
freqs = processor.reference_frequency_axis(ref)
# S(f) = (f - 2.5 GHz) / 1 GHz -> must resample to that same line on the grid.
main = ((freqs - 2.5e9) / 1.0e9) * np.abs(ref)
result = processor.process(main, ref)
self.assertIsNotNone(result)
expected = (processor.grid_hz.astype(np.float64) - 2.5e9) / 1.0e9
np.testing.assert_allclose(result.real, expected, atol=1e-3)
np.testing.assert_allclose(result.imag, 0.0, atol=1e-3)
def test_handles_descending_phase_direction(self) -> None:
# phase 0 -> 2 GHz, phase -100 -> 4 GHz (negative slope). Phase ramp
# 0 -> -100 therefore sweeps frequency UP across the band.
processor = self._processor(phase1_rad=-100.0)
ref = _reference(np.linspace(0.0, -100.0, 401))
result = processor.process(np.abs(ref).astype(np.complex128), ref)
self.assertIsNotNone(result)
self.assertEqual(result.shape, (11,))
np.testing.assert_allclose(np.abs(result), 1.0, atol=1e-3)
def test_rejects_too_few_points(self) -> None:
processor = self._processor()
one = np.ones(1, dtype=np.complex128)
self.assertIsNone(processor.process(one, one))
def test_rejects_length_mismatch(self) -> None:
processor = self._processor()
self.assertIsNone(
processor.process(np.ones(10, dtype=np.complex128), np.ones(9, dtype=np.complex128))
)
def test_drops_zero_amplitude_reference_points(self) -> None:
processor = self._processor()
ref = _reference(np.linspace(0.0, 100.0, 401))
main = np.abs(ref).astype(np.complex128)
ref[10] = 0.0 # vanished reference samples must be ignored, not crash
ref[200] = 0.0
result = processor.process(main, ref)
self.assertIsNotNone(result)
self.assertTrue(np.all(np.isfinite(result.real)))
self.assertTrue(np.all(np.isfinite(result.imag)))
if __name__ == "__main__":
unittest.main()
+152
View File
@@ -0,0 +1,152 @@
"""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 (
MAIN_MARKER,
REFERENCE_MARKER,
KamilAdcStreamParser,
)
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)
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_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()
+102 -174
View File
@@ -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)],
)
@@ -8,7 +8,7 @@ from unittest.mock import 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_service import apply_kamil_adc_laser_control
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
from python_app.models.run_config_model import RunConfigModel