Merge branch 'laser-temp-monitoring' into feature/switched-matrix-radar
This commit is contained in:
@@ -8,6 +8,7 @@ import unittest
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
COMBO_MARKER,
|
||||
MAIN_MARKER,
|
||||
REFERENCE_MARKER,
|
||||
KamilAdcStreamParser,
|
||||
@@ -18,6 +19,10 @@ 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)
|
||||
|
||||
@@ -140,6 +145,40 @@ class KamilAdcStreamParserTest(unittest.TestCase):
|
||||
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())
|
||||
|
||||
@@ -16,7 +16,11 @@ 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 MAIN_MARKER, REFERENCE_MARKER
|
||||
from python_app.hardware_full.kamil_adc.protocol import (
|
||||
COMBO_MARKER,
|
||||
MAIN_MARKER,
|
||||
REFERENCE_MARKER,
|
||||
)
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
|
||||
@@ -33,6 +37,10 @@ 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."""
|
||||
|
||||
@@ -127,6 +135,41 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
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:
|
||||
@@ -205,6 +248,61 @@ class KamilAdcConfigTest(unittest.TestCase):
|
||||
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."""
|
||||
import types
|
||||
|
||||
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)
|
||||
service._reader = types.SimpleNamespace(published_count=10) # type: ignore[assignment]
|
||||
service._process = types.SimpleNamespace(poll=lambda: None) # type: ignore[assignment]
|
||||
|
||||
# Each poll-sleep advances the published count, as the reader thread would.
|
||||
def _advance(_seconds: float) -> None:
|
||||
service._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(service._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"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Tests for the laser current-variation temperature monitoring package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
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)
|
||||
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()
|
||||
|
||||
class _OneShotWriter:
|
||||
def __init__(self) -> None:
|
||||
self.written: List[TemperatureReading] = []
|
||||
|
||||
def write(self, reading: TemperatureReading) -> None:
|
||||
self.written.append(reading)
|
||||
stop.set() # stop after the first publish
|
||||
|
||||
writer = _OneShotWriter()
|
||||
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
|
||||
monitor.run(stop)
|
||||
|
||||
self.assertEqual(len(writer.written), 1)
|
||||
self.assertEqual(writer.written[0].seq, 0)
|
||||
|
||||
|
||||
class CheckerTest(unittest.TestCase):
|
||||
def _checker(self, **kwargs: object) -> LaserTemperatureChecker:
|
||||
return LaserTemperatureChecker(target_temp1=28.0, target_temp2=28.9,
|
||||
tolerance_c=0.03, **kwargs)
|
||||
|
||||
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()
|
||||
@@ -161,8 +161,8 @@ class WebControllerTest(unittest.TestCase):
|
||||
def test_known_field_emits_and_returns_snapshot(self) -> None:
|
||||
received: list[dict] = []
|
||||
self.controller.apply_settings_requested.connect(received.append)
|
||||
out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5})
|
||||
self.assertEqual(received, [{"gpr_min_visible_score": 0.5}])
|
||||
out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5})
|
||||
self.assertEqual(received, [{"gpr_object_min_frac": 0.5}])
|
||||
self.assertIsInstance(out, list)
|
||||
|
||||
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user