Merge branch 'laser-temp-monitoring' into feature/switched-matrix-radar

This commit is contained in:
2026-08-04 14:57:03 +03:00
45 changed files with 2520 additions and 182 deletions
@@ -86,12 +86,38 @@ def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"delay_time": variation.delay_time,
},
)
_write_variation_session(variation)
return True
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
finally:
controller.disconnect()
def _write_variation_session(variation) -> None:
"""Freeze the variation's static temperature targets for the checker.
Best-effort: a failure to write the session snapshot must never abort the
acquisition setup, so any error is logged and swallowed.
"""
from datetime import datetime
try:
from python_app.hardware_full.laser_control.monitoring.session import (
LaserVariationSession,
)
LaserVariationSession(
variation_type=variation.variation_type,
target_temp1=variation.static_temp1,
target_temp2=variation.static_temp2,
tolerance_c=variation.temp_tolerance_c,
started_at_iso=datetime.now().isoformat(timespec="seconds"),
).save()
logger.debug("Wrote laser variation session snapshot for the temperature checker")
except Exception: # noqa: BLE001 — session snapshot is auxiliary, never fatal
logger.warning("Failed to write laser variation session snapshot", exc_info=True)
def _validate_laser_control_config(config: RunConfigModel) -> None:
laser = config.radar.laser_control
if not laser.port:
+30 -1
View File
@@ -29,6 +29,12 @@ import numpy as np
FRAME_BYTES = 8
MAIN_MARKER = 0x000A
REFERENCE_MARKER = 0x00A8
# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware
# collector right after a sweep boundary to label the upcoming sweep with the RF
# switch combination it was captured under (``dirty != 0`` means the sweep straddled
# a switch transition and must be dropped). Absent in the standalone/calibration
# collector, where every sweep carries no combo (``combo is None``).
COMBO_MARKER = 0x00C0
_BOUNDARY_STEP = 0xFFFF
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
@@ -52,6 +58,8 @@ class RawSweep:
steps: np.ndarray
main: np.ndarray
reference: np.ndarray
combo: tuple[int, int] | None = None
dirty: bool = False
@property
def size(self) -> int:
@@ -66,13 +74,17 @@ class KamilAdcStreamParser:
but holds no I/O and is cheap to unit-test.
"""
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
__slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty")
def __init__(self) -> None:
self._buffer = bytearray()
self._aligned = False
self._main: dict[int, complex] = {}
self._reference: dict[int, complex] = {}
# Combo tag for the sweep currently being accumulated (set by the combo
# frame right after each boundary; ``None`` in non-switch collector modes).
self._pending_combo: tuple[int, int] | None = None
self._pending_dirty = False
def feed(self, data: bytes) -> list[RawSweep]:
"""Append ``data`` and return any sweeps completed by it."""
@@ -95,6 +107,10 @@ class KamilAdcStreamParser:
self._main[step] = complex(real, imag)
elif marker == REFERENCE_MARKER:
self._reference[step] = complex(real, imag)
elif marker == COMBO_MARKER:
# step = input_pos, real = output_pos, imag = dirty flag.
self._pending_combo = (int(step), int(real))
self._pending_dirty = imag != 0
else:
raise ValueError(
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
@@ -107,6 +123,8 @@ class KamilAdcStreamParser:
self._aligned = False
self._main.clear()
self._reference.clear()
self._pending_combo = None
self._pending_dirty = False
def _align(self) -> bool:
"""Discard pre-roll up to and including the first sweep boundary.
@@ -122,6 +140,8 @@ class KamilAdcStreamParser:
del self._buffer[: index + FRAME_BYTES]
self._main.clear()
self._reference.clear()
self._pending_combo = None
self._pending_dirty = False
self._aligned = True
return True
@@ -130,12 +150,21 @@ class KamilAdcStreamParser:
shared = sorted(self._main.keys() & self._reference.keys())
main = self._main
reference = self._reference
combo = self._pending_combo
dirty = self._pending_dirty
self._main = {}
self._reference = {}
# The next sweep's combo is set by its own combo frame (right after this
# boundary); clear so a sweep without one reports combo=None rather than
# inheriting a stale tag.
self._pending_combo = None
self._pending_dirty = False
if not shared:
return None
return RawSweep(
steps=np.asarray(shared, dtype=np.int32),
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
combo=combo,
dirty=dirty,
)
+54 -4
View File
@@ -47,6 +47,10 @@ _REJECT_LOG_EVERY = 50
# brief window to release the device cleanly before escalating to SIGKILL. Caps
# the configured stop_timeout_s so a stop can never hang.
_STOP_KILL_GRACE_S = 0.5
# Sweeps to skip after a Python-driven switch change before trusting a capture: one
# for the pre-switch sweep still in the mailbox, one for a possible transition
# straddler in flight. Calibration speed is not critical, so we err on safety.
_SWITCH_DRAIN_SWEEPS = 2
@dataclass(slots=True)
@@ -54,6 +58,10 @@ class KamilAdcService:
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
config: RunConfigModel
# When set, the collector is launched with ``config:<path>`` so it drives the RF
# switches itself (switch-aware mode) from this run_config.json. ``None`` keeps
# the standalone collector that streams a single channel (calibration / mock).
switch_config_path: str | None = None
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
@@ -64,9 +72,18 @@ class KamilAdcService:
@property
def command(self) -> list[str]:
"""External collector command, including the generated ``tty:`` argument."""
"""External collector command, including the generated ``tty:`` argument.
In switch-aware mode (``switch_config_path`` set) the collector also gets
``config:<path>`` so it reads the switch/combo configuration and drives the
switches in lock-step with the sweeps.
"""
adc = self.config.radar.kamil_adc
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
cmd = [str(self._resolve_executable()), *adc.args]
if self.switch_config_path is not None:
cmd.append(f"config:{self.switch_config_path}")
cmd.append(f"tty:{adc.tty_path}")
return cmd
def open(self, *, stop_event: threading.Event | None = None) -> None:
"""Launch the collector and start the TTY reader thread.
@@ -122,12 +139,17 @@ class KamilAdcService:
"""Kamil ADC has no runtime-readable sweep-limit API."""
raise RuntimeError("Kamil ADC device limits are not available")
def acquire(self) -> SweepResult:
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
"""Return the next sweep that covers the band, as S21 on the fixed grid.
Sweeps whose floated frequency range does not span the configured band are
rejected and the next sweep is read, until one passes or the sweep timeout
elapses (which then surfaces as a :class:`TimeoutError`).
When ``combo`` is given (switch-aware mode), only the clean sweep captured
under that switch combination is returned; the collector drives the switches
and tags each sweep. When ``None`` (calibration / non-switch mode), the
single newest sweep is returned regardless of combination.
"""
if self._processor is None:
raise RuntimeError("Kamil ADC service is not configured")
@@ -147,7 +169,10 @@ class KamilAdcService:
raise TimeoutError(
"Timed out waiting for a Kamil ADC sweep covering the configured band"
)
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
if combo is None:
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
else:
raw = self._reader.read_sweep_for(combo, timeout_s=remaining_s, process=process)
s21 = self._processor.process(raw.main, raw.reference)
if s21 is not None:
return SweepResult(
@@ -159,6 +184,31 @@ class KamilAdcService:
)
self._log_rejected_sweep(raw)
def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None:
"""Discard sweeps captured before / across a just-applied switch change.
The collector free-runs, so right after the RF switches move the reader
still holds a sweep captured in the *previous* combination, and a sweep that
straddles the transition may still be in flight. Without this, the next
:meth:`acquire` would return that stale data and the capture would be
attributed to the wrong combination (an off-by-one across the sequence).
Block until ``sweeps`` freshly-published sweeps have gone by, so the next
:meth:`acquire` returns a sweep captured entirely in the new switch state.
Used by the Python-driven calibration capture, where the collector does not
tag sweeps; the switch-aware collector path handles this with combo tags
instead. No-op when the service is not open.
"""
if self._reader is None:
return
target = self._reader.published_count + max(1, int(sweeps))
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
while self._reader.published_count < target:
if time.monotonic() > deadline:
raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change")
raise_if_process_exited(self._process)
time.sleep(0.005)
def read_raw_sweep(self) -> RawSweep:
"""Return the next raw (main, reference) sweep without any processing.
@@ -52,6 +52,10 @@ class KamilAdcTtyReader:
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
# Latest clean sweep per switch combination, for the switch-aware collector.
# read_sweep() ignores this and serves the single newest sweep (calibration /
# non-switch mode); read_sweep_for() serves a specific combination.
_combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False)
_reader_error: Exception | None = field(init=False, default=None, repr=False)
_published_count: int = field(init=False, default=0, repr=False)
@@ -62,6 +66,7 @@ class KamilAdcTtyReader:
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
self._stop_event.clear()
self._latest_sweep = None
self._combo_slots = {}
self._reader_error = None
self._published_count = 0
self._thread = threading.Thread(
@@ -89,6 +94,7 @@ class KamilAdcTtyReader:
finally:
self._fd = None
self._latest_sweep = None
self._combo_slots = {}
self._reader_error = None
@property
@@ -131,6 +137,39 @@ class KamilAdcTtyReader:
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
def read_sweep_for(
self,
combo: tuple[int, int],
*,
timeout_s: float,
process: subprocess.Popen[bytes] | None = None,
) -> RawSweep:
"""Wait for and return the latest clean sweep for ``combo``.
Used in switch-aware mode, where the collector drives the switches and tags
each sweep with its combination. Only clean sweeps are delivered (the reader
thread drops the dirty ones); the slot is consumed on read so each caller
gets a fresh capture. Raises like :meth:`read_sweep`.
"""
if self._thread is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
sweep = self._combo_slots.pop(combo, None)
if sweep is not None:
return sweep
if self._reader_error is not None:
raise self._reader_error
raise_if_process_exited(process)
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep for combo {combo} "
f"after {float(timeout_s):.3f}s"
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
@@ -177,11 +216,22 @@ class KamilAdcTtyReader:
return chunk
def _publish_sweep(self, sweep: RawSweep) -> None:
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
"""Publish a completed sweep to the mailbox(es), waking any waiter.
Dirty sweeps (those that straddled a switch transition) are counted but not
delivered: the collector re-takes that combination on the next sweep. Clean
tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode)
only update the single newest-sweep mailbox that read_sweep() serves.
"""
with self._mailbox_cv:
self._latest_sweep = sweep
self._published_count += 1
self._mailbox_cv.notify()
if sweep.dirty:
self._mailbox_cv.notify_all()
return
self._latest_sweep = sweep
if sweep.combo is not None:
self._combo_slots[sweep.combo] = sweep
self._mailbox_cv.notify_all()
def _publish_error(self, exc: Exception) -> None:
"""Record ``exc`` as the reader fault and wake any waiter."""
@@ -0,0 +1,247 @@
# Контроль температуры при вариации тока лазера
Набор из трёх развязанных компонентов для автоматизации измерений в режиме
**вариации тока лазера 1** (`CHANGE_CURRENT_LD1`). Пока плата гоняет свип тока,
температуры лазеров должны оставаться на заданных статичных уставках. Эти модули
раз в свип считывают реальную температуру и предупреждают, если она разошлась с
целью.
## Зачем это нужно
При запуске вариации тока из GUI изменённые значения температуры могут фактически
не дойти до цели — реальная температура остаётся прежней, и измерение становится
некорректным. Плата после старта задачи гоняет свип **автономно** и никак не
сигнализирует, что уставка не достигнута. Эти модули закрывают пробел: независимо
опрашивают плату и валидируют температуру относительно уставок, зафиксированных
**в момент старта вариации**.
> ⚠️ В прошивке реализована только **вариация тока** (`CHANGE_CURRENT_LD1`).
> Вариация температуры не поддерживается и в этот API не заложена.
## Архитектура
```
[starter] ── TASK_ENABLE ──► плата кратко открыл порт, послал, закрыл
│ пишет session.json (target temp1/2, tolerance, variation_type)
[monitor] ── TRANS_ENABLE ──► плата владеет портом всё время работы
│ раз в свип: get_measurements()
│ дописывает строку в readings.jsonl (seq, temp1, temp2, temp_ext, I1, I2)
[checker] читает session.json + tail readings.jsonl
сверяет temp1↔target_temp1 и temp2↔target_temp2, |Δ|>tol ─► WARNING в консоль
```
- **Порт лазера эксклюзивен.** `starter` трогает его кратко, затем `monitor`
владеет им всё время. `checker` порт не трогает вовсе — читает только файлы.
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
останавливаются и перезапускаются независимо, без рукопожатия.
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
термисторам `temp_ext*`), каждый со своим допуском (по умолчанию `0.03 °C`).
## Быстрый старт (CLI, два процесса)
Терминал 1 — стартовать вариацию и мониторить температуру:
```bash
python -m python_app.scripts.laser_temp_monitor \
--config run_config.json \
--start
```
Терминал 2 — валидировать температуру и печатать предупреждения:
```bash
python -m python_app.scripts.laser_temp_checker
```
Пример вывода чекера при расхождении и возврате в допуск:
```
WARNING laser_temp_checker: Laser 1 temperature off target: measured 28.100 °C,
target 28.000 °C, Δ=+0.100 °C exceeds tolerance ±0.030 °C [seq=1]
INFO laser_temp_checker: Laser 1 temperature back within tolerance:
28.000 °C (target 28.000, |Δ|=0.000 ≤ 0.030) [seq=2]
```
Остановка — `Ctrl+C` (SIGINT) в любом из процессов.
## Конфигурация
Параметры берутся из `run_config.json`, секция `radar.laser_control`. Мониторинг
использует блок `variation` и новое поле `temp_tolerance_c`:
```json
{
"radar": {
"model": "kamil_adc",
"laser_control": {
"enabled": true,
"port": "/dev/ttyUSB0",
"mode": "variation",
"pi_coeff1_p": 2560,
"pi_coeff1_i": 128,
"pi_coeff2_p": 2560,
"pi_coeff2_i": 128,
"variation": {
"variation_type": "CHANGE_CURRENT_LD1",
"static_temp1": 28.0,
"static_temp2": 28.9,
"static_current1": 33.0,
"static_current2": 35.0,
"min_value": 33.0,
"max_value": 60.0,
"step": 0.05,
"time_step": 50,
"delay_time": 10,
"temp_tolerance_c": 0.03
}
}
}
}
```
Ключевые поля для мониторинга:
| Поле | Смысл |
|---|---|
| `port` | Серийный порт лазерной платы (пусто → автоопределение) |
| `static_temp1` / `static_temp2` | Целевые статичные температуры лазеров 1/2, °C |
| `min_value` / `max_value` / `step` | Диапазон и шаг свипа тока, мА — из них считается период свипа |
| `time_step` / `delay_time` | Тайминги точки (мкс / мс) — тоже входят в период свипа |
| `temp_tolerance_c` | Допуск сверки, °C (по умолчанию `0.03`) |
## Опции CLI
### `laser_temp_monitor`
| Аргумент | По умолчанию | Назначение |
|---|---|---|
| `--config` | — (обязателен) | Путь к `run_config.json` |
| `--start` | выкл. | Послать `CHANGE_CURRENT_LD1` перед мониторингом и записать сессию |
| `--strategy` | `computed` | `computed` (раз в свип) или `interval:<ms>` (фикс. период) |
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Куда дописывать показания |
| `--session` | `<tmp>/laser_variation_session.json` | Куда писать снимок сессии (с `--start`) |
Мониторить уже запущенную из GUI/пайплайна вариацию (без повторного старта):
```bash
python -m python_app.scripts.laser_temp_monitor --config run_config.json
```
Фиксированный период вместо расчётного (напр. раз в 500 мс):
```bash
python -m python_app.scripts.laser_temp_monitor \
--config run_config.json --strategy interval:500
```
### `laser_temp_checker`
| Аргумент | По умолчанию | Назначение |
|---|---|---|
| `--session` | `<tmp>/laser_variation_session.json` | Снимок с целями и допуском |
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Какой канал показаний тайлить |
| `--tolerance` | из сессии | Переопределить допуск, °C |
| `--reminder-every` | `0` (выкл.) | Повторять предупреждение каждые N показаний, пока вне допуска |
| `--from-start` | выкл. | Проверить весь файл показаний, а не только новые строки |
Разные пути для нескольких одновременных прогонов:
```bash
# монитор
python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
# чекер
python -m python_app.scripts.laser_temp_checker \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json --reminder-every 20
```
## Интеграция с пайплайном Kamil ADC
Когда вариацию стартует штатный пайплайн
([`apply_kamil_adc_laser_control`](../../kamil_adc/laser.py)), снимок сессии
`session.json` пишется автоматически. Достаточно запустить только чекер
(и, при желании, монитор без `--start`, чтобы он опрашивал плату). Так консоль
получит предупреждения о рассинхроне температуры прямо во время захвата.
## Встраивание в свой код (без CLI)
```python
import threading
from python_app.hardware_full.laser_control.controller import LaserController
from python_app.hardware_full.laser_control.monitoring import (
LaserTemperatureMonitor, LaserTemperatureChecker, LaserVariationSession,
ReadingWriter, ReadingReader, resolve_period_s,
)
# 1. Зафиксировать цели при старте вариации
session = LaserVariationSession(
variation_type="CHANGE_CURRENT_LD1",
target_temp1=28.0, target_temp2=28.9, tolerance_c=0.03,
)
# 2. Монитор (в проде controller — реальный LaserController)
period = resolve_period_s("computed", min_value=33.0, max_value=60.0, step=0.05,
time_step_us=50, delay_time_ms=10)
stop = threading.Event()
with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl") as w:
monitor = LaserTemperatureMonitor(ctrl, w, period_s=period)
threading.Thread(target=monitor.run, args=(stop,), daemon=True).start()
# 3. Чекер: тайлить показания и валидировать оба лазера
checker = LaserTemperatureChecker.from_session(session)
reader = ReadingReader("readings.jsonl")
while not stop.is_set():
for reading in reader.poll():
checker.process(reading) # печатает WARNING при |Δ| > tolerance
stop.wait(0.2)
```
`LaserTemperatureChecker.evaluate(reading)` возвращает список
`LaserDeviation` (по лазеру: измеренное, цель, Δ, в допуске ли) без логирования —
удобно для собственной обработки/накопления статистики.
## Формат IPC-файлов
`session.json`:
```json
{
"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"
}
```
`readings.jsonl` (по одной строке-объекту на свип):
```json
{"seq":0,"mono_ns":123456789,"temp1":28.0,"temp2":28.9,"temp_ext1":22.0,"temp_ext2":23.0,"current1":33.0,"current2":35.0}
```
## Как определяется «раз в свип»
Плата не отдаёт явную границу свипа, поэтому период оценивается из параметров:
```
num_steps = round(|max_value - min_value| / step) + 1
per_point_s = delay_time / 1000 + time_step / 1_000_000
sweep_period = num_steps × per_point_s
```
Монитор публикует одно показание за такой период. Если нужен другой темп —
`--strategy interval:<ms>`. (Внутренний счётчик `TO6` платы существует, но его
семантика не гарантирована, поэтому для тайминга он не используется.)
## Тесты
```bash
python -m pytest python_app/tests/test_laser_temp_monitoring.py -q
```
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому
лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление).
@@ -0,0 +1,36 @@
"""Laser current-variation temperature monitoring and validation.
Three decoupled pieces connected via IPC files:
- :class:`LaserVariationSession` — target setpoints + tolerance frozen at start.
- :class:`LaserTemperatureMonitor` — polls the board once per sweep, publishes.
- :class:`LaserTemperatureChecker` — validates published readings, warns.
"""
from .checker import LaserDeviation, LaserTemperatureChecker
from .monitor import (
LaserTemperatureMonitor,
compute_sweep_period_s,
resolve_period_s,
)
from .readings_channel import ReadingReader, ReadingWriter, TemperatureReading
from .session import (
DEFAULT_READINGS_PATH,
DEFAULT_SESSION_PATH,
DEFAULT_TOLERANCE_C,
LaserVariationSession,
)
__all__ = [
"LaserDeviation",
"LaserTemperatureChecker",
"LaserTemperatureMonitor",
"compute_sweep_period_s",
"resolve_period_s",
"ReadingReader",
"ReadingWriter",
"TemperatureReading",
"DEFAULT_READINGS_PATH",
"DEFAULT_SESSION_PATH",
"DEFAULT_TOLERANCE_C",
"LaserVariationSession",
]
@@ -0,0 +1,139 @@
"""Independent laser temperature checker.
Reads the target setpoints frozen at variation start (:class:`LaserVariationSession`)
and validates each published :class:`TemperatureReading` against them. Both lasers
are checked independently: ``temp1`` against ``target_temp1`` and ``temp2`` against
``target_temp2``. When a laser's measured temperature deviates from its target by
more than the tolerance (default 0.03 °C), a warning is printed to the console.
Runs as its own process (see ``scripts/laser_temp_checker.py``), reading the JSONL
readings channel — it never touches the serial port, so it is fully independent of
the monitor and can be started, stopped, or restarted at any time.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import List
from .readings_channel import TemperatureReading
from .session import DEFAULT_TOLERANCE_C, LaserVariationSession
logger = logging.getLogger(__name__)
# Only a deviation strictly greater than the tolerance warns; this epsilon keeps a
# value the user intends to be exactly at the tolerance from tripping on float error
# (e.g. 28.03 - 28.00 == 0.030000000000001 in IEEE-754).
_FLOAT_EPS = 1e-9
@dataclass(slots=True)
class LaserDeviation:
"""Result of comparing one laser's measured temperature to its target."""
laser: int # 1 or 2
seq: int
measured: float
target: float
delta: float # measured - target, °C
within_tolerance: bool
class LaserTemperatureChecker:
"""Validates readings against per-laser targets and warns on mismatch.
Anti-spam: a laser's ok↔mismatch transitions are logged once; while a laser
stays out of tolerance, a reminder is emitted only every ``reminder_every``
readings (0 disables reminders). State is tracked independently per laser, so
a persistent laser-1 fault never suppresses a fresh laser-2 warning.
"""
def __init__(
self,
target_temp1: float,
target_temp2: float,
tolerance_c: float = DEFAULT_TOLERANCE_C,
reminder_every: int = 0,
) -> None:
self.target_temp1 = float(target_temp1)
self.target_temp2 = float(target_temp2)
self.tolerance_c = float(tolerance_c)
self.reminder_every = int(reminder_every)
# Per-laser state: mismatch flag + readings seen since the last log.
self._mismatch = {1: False, 2: False}
self._since_log = {1: 0, 2: 0}
@classmethod
def from_session(
cls, session: LaserVariationSession, reminder_every: int = 0
) -> "LaserTemperatureChecker":
return cls(
target_temp1=session.target_temp1,
target_temp2=session.target_temp2,
tolerance_c=session.tolerance_c,
reminder_every=reminder_every,
)
def evaluate(self, reading: TemperatureReading) -> List[LaserDeviation]:
"""Compute per-laser deviations without logging (pure)."""
return [
self._deviation(1, reading.seq, reading.temp1, self.target_temp1),
self._deviation(2, reading.seq, reading.temp2, self.target_temp2),
]
def process(self, reading: TemperatureReading) -> List[LaserDeviation]:
"""Evaluate a reading and emit console warnings, honouring anti-spam.
Returns the deviations for which a warning/reminder was emitted this call
(empty when both lasers are within tolerance and unchanged).
"""
warned: List[LaserDeviation] = []
for dev in self.evaluate(reading):
if self._should_warn(dev):
self._warn(dev)
warned.append(dev)
return warned
def _deviation(self, laser: int, seq: int, measured: float, target: float) -> LaserDeviation:
delta = measured - target
return LaserDeviation(
laser=laser,
seq=seq,
measured=measured,
target=target,
delta=delta,
within_tolerance=abs(delta) <= self.tolerance_c + _FLOAT_EPS,
)
def _should_warn(self, dev: LaserDeviation) -> bool:
laser = dev.laser
if not dev.within_tolerance:
if not self._mismatch[laser]:
# Fresh ok -> mismatch transition: always warn.
self._mismatch[laser] = True
self._since_log[laser] = 0
return True
# Still out of tolerance: warn again only every reminder_every readings.
self._since_log[laser] += 1
if self.reminder_every > 0 and self._since_log[laser] >= self.reminder_every:
self._since_log[laser] = 0
return True
return False
# Within tolerance: log a recovery once, then stay quiet.
if self._mismatch[laser]:
self._mismatch[laser] = False
self._since_log[laser] = 0
logger.info(
"Laser %d temperature back within tolerance: %.3f °C "
"(target %.3f, |Δ|=%.3f%.3f) [seq=%d]",
laser, dev.measured, dev.target, abs(dev.delta), self.tolerance_c, dev.seq,
)
return False
def _warn(self, dev: LaserDeviation) -> None:
logger.warning(
"Laser %d temperature off target: measured %.3f °C, target %.3f °C, "
"Δ=%+.3f °C exceeds tolerance ±%.3f °C [seq=%d]",
dev.laser, dev.measured, dev.target, dev.delta, self.tolerance_c, dev.seq,
)
@@ -0,0 +1,142 @@
"""Independent laser temperature monitor.
Owns a :class:`LaserController` connection and, once per current-variation sweep,
polls the board for a measurement and publishes it to a JSONL readings channel.
The board runs the current sweep autonomously after ``TASK_ENABLE``; the monitor
only reads the "last data point" via ``TRANS_ENABLE`` — exactly like the original
RadioPhotonic PC software's polling loop, but decoupled and headless.
Runs as its own process (see ``scripts/laser_temp_monitor.py``) so it is fully
independent of both the acquisition pipeline and the temperature checker.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Optional, Protocol
from .readings_channel import ReadingWriter, TemperatureReading
logger = logging.getLogger(__name__)
class _MeasurementSource(Protocol):
"""Minimal controller surface the monitor depends on (eases testing)."""
def get_measurements(self) -> object: ...
def compute_sweep_period_s(
min_value: float,
max_value: float,
step: float,
time_step_us: float,
delay_time_ms: float,
) -> float:
"""Estimate the duration of one min→max current sweep, in seconds.
``num_steps = round(|max - min| / step) + 1`` points, each taking roughly the
inter-point delay plus the discretisation time. The board gives no explicit
end-of-sweep marker, so this computed period is how "once per sweep" is timed
by default.
"""
if step <= 0:
raise ValueError(f"step must be > 0, got {step}")
span = abs(max_value - min_value)
num_steps = round(span / step) + 1
per_point_s = delay_time_ms / 1000.0 + time_step_us / 1_000_000.0
return num_steps * per_point_s
def resolve_period_s(
strategy: str,
*,
min_value: float,
max_value: float,
step: float,
time_step_us: float,
delay_time_ms: float,
) -> float:
"""Turn a strategy string into a concrete per-reading period in seconds.
Supported strategies:
- ``"computed"`` — one reading per estimated sweep duration (default).
- ``"interval:<ms>"`` — a fixed period of ``<ms>`` milliseconds.
"""
if strategy == "computed":
return compute_sweep_period_s(min_value, max_value, step, time_step_us, delay_time_ms)
if strategy.startswith("interval:"):
try:
ms = float(strategy.split(":", 1)[1])
except ValueError as exc:
raise ValueError(f"Invalid interval strategy {strategy!r}") from exc
if ms <= 0:
raise ValueError(f"interval must be > 0 ms, got {ms}")
return ms / 1000.0
raise ValueError(
f"Unknown strategy {strategy!r}; expected 'computed' or 'interval:<ms>'"
)
@dataclass(slots=True)
class LaserTemperatureMonitor:
"""Polls a laser board once per sweep and publishes temperature readings.
Args:
controller: object exposing ``get_measurements()`` (a real
:class:`LaserController` in production, a fake in tests).
writer: destination channel implementing ``write(TemperatureReading)``.
period_s: seconds between readings (see :func:`resolve_period_s`).
"""
controller: _MeasurementSource
writer: ReadingWriter
period_s: float
def read_once(self, seq: int) -> Optional[TemperatureReading]:
"""Poll one measurement and turn it into a reading, or None if no data."""
measurements = self.controller.get_measurements()
if measurements is None:
logger.warning("No measurement returned from laser board (seq=%d)", seq)
return None
return TemperatureReading(
seq=seq,
mono_ns=time.monotonic_ns(),
temp1=float(measurements.temp1),
temp2=float(measurements.temp2),
temp_ext1=_opt(getattr(measurements, "temp_ext1", None)),
temp_ext2=_opt(getattr(measurements, "temp_ext2", None)),
current1=_opt(getattr(measurements, "current1", None)),
current2=_opt(getattr(measurements, "current2", None)),
)
def run(self, stop_event: Optional[threading.Event] = None) -> None:
"""Poll-and-publish until ``stop_event`` is set (runs forever if None).
Each iteration reads once, publishes, then waits one period. The wait is
interruptible via ``stop_event`` for a prompt clean shutdown.
"""
stop = stop_event or threading.Event()
seq = 0
logger.info("Temperature monitor started: period=%.3fs", self.period_s)
while not stop.is_set():
try:
reading = self.read_once(seq)
except Exception: # noqa: BLE001 — a transient read error must not kill the monitor
logger.warning("Measurement read failed; continuing", exc_info=True)
reading = None
if reading is not None:
self.writer.write(reading)
logger.debug(
"Published reading seq=%d T1=%.3f T2=%.3f", seq, reading.temp1, reading.temp2
)
seq += 1
stop.wait(self.period_s)
logger.info("Temperature monitor stopped after %d readings", seq)
def _opt(value: object) -> Optional[float]:
return None if value is None else float(value)
@@ -0,0 +1,131 @@
"""JSONL append/tail channel carrying per-sweep temperature readings.
The monitor process appends one JSON object per line; the checker process tails
the file from its end and parses each newly-appended line. A newline-delimited
file is used (rather than a socket) so the monitor and checker can start, stop,
and restart on independent lifecycles without a handshake — the checker simply
resumes tailing wherever the file currently ends.
A reader only ever consumes lines terminated by ``\\n``; a partially-written last
line is left buffered until its newline arrives, so a reading is never parsed
half-written.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterator, Optional, Union
_PathLike = Union[str, os.PathLike[str]]
@dataclass(slots=True)
class TemperatureReading:
"""One temperature/current snapshot published once per sweep.
``temp1``/``temp2`` are the internal laser temperatures (the values validated
against the setpoints); ``temp_ext1``/``temp_ext2`` are the external
thermistors, carried for diagnostics only.
"""
seq: int
mono_ns: int
temp1: float
temp2: float
temp_ext1: Optional[float] = None
temp_ext2: Optional[float] = None
current1: Optional[float] = None
current2: Optional[float] = None
def to_json_line(self) -> str:
return json.dumps(asdict(self), separators=(",", ":"))
@classmethod
def from_json_line(cls, line: str) -> "TemperatureReading":
payload = json.loads(line)
return cls(
seq=int(payload["seq"]),
mono_ns=int(payload["mono_ns"]),
temp1=float(payload["temp1"]),
temp2=float(payload["temp2"]),
temp_ext1=_opt_float(payload.get("temp_ext1")),
temp_ext2=_opt_float(payload.get("temp_ext2")),
current1=_opt_float(payload.get("current1")),
current2=_opt_float(payload.get("current2")),
)
def _opt_float(value: object) -> Optional[float]:
return None if value is None else float(value)
class ReadingWriter:
"""Appends :class:`TemperatureReading` objects to a JSONL file.
Each write is a single line flushed to the OS so a tailing reader sees it
promptly. Use as a context manager or call :meth:`close` explicitly.
"""
def __init__(self, path: _PathLike) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
# Line-buffered append; each reading is one line.
self._fh = self.path.open("a", encoding="utf-8", buffering=1)
def write(self, reading: TemperatureReading) -> None:
self._fh.write(reading.to_json_line() + "\n")
self._fh.flush()
def close(self) -> None:
if not self._fh.closed:
self._fh.close()
def __enter__(self) -> "ReadingWriter":
return self
def __exit__(self, *_exc: object) -> None:
self.close()
class ReadingReader:
"""Tails a JSONL readings file, yielding complete lines as they appear.
``from_start=False`` (default) begins at the current end of file, so the
checker validates readings produced from the moment it starts. Partial
trailing lines are buffered until their newline arrives.
"""
def __init__(self, path: _PathLike, *, from_start: bool = False) -> None:
self.path = Path(path)
self._buffer = ""
self._pos = 0
if not from_start and self.path.exists():
self._pos = self.path.stat().st_size
def poll(self) -> Iterator[TemperatureReading]:
"""Yield every complete reading appended since the last poll.
Malformed lines are skipped silently (a truncated/legacy line must not
crash a long-running checker); callers that care can validate seq gaps.
"""
if not self.path.exists():
return
with self.path.open("r", encoding="utf-8") as fh:
fh.seek(self._pos)
chunk = fh.read()
self._pos = fh.tell()
if not chunk:
return
self._buffer += chunk
*complete, self._buffer = self._buffer.split("\n")
for line in complete:
line = line.strip()
if not line:
continue
try:
yield TemperatureReading.from_json_line(line)
except (ValueError, KeyError, TypeError):
continue
@@ -0,0 +1,75 @@
"""Variation-session snapshot shared between the temperature monitor and checker.
When a current-variation task is started, the target static laser temperatures
(``static_temp1``/``static_temp2``) and the acceptable tolerance are frozen into a
small JSON file. The temperature checker reads this file to know what "correct"
means for the run, so it validates against the setpoints that were in effect *at
variation start* — independent of any later edits to the run config.
Only current variation of laser 1 (``CHANGE_CURRENT_LD1``) is supported by the
firmware today; the session still records both laser targets because both
temperatures are held static during that task and both are validated.
"""
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Union
_PathLike = Union[str, os.PathLike[str]]
# Default IPC locations. Both are overridable via CLI/API so several runs can use
# distinct files. Kept in the system temp dir so no project state is polluted.
DEFAULT_SESSION_PATH = Path(tempfile.gettempdir()) / "laser_variation_session.json"
DEFAULT_READINGS_PATH = Path(tempfile.gettempdir()) / "laser_temp_readings.jsonl"
DEFAULT_TOLERANCE_C = 0.03
@dataclass(slots=True)
class LaserVariationSession:
"""Target setpoints and tolerance frozen at variation start."""
variation_type: str
target_temp1: float
target_temp2: float
tolerance_c: float = DEFAULT_TOLERANCE_C
started_at_iso: str = ""
def save(self, path: _PathLike = DEFAULT_SESSION_PATH) -> Path:
"""Atomically write the session snapshot to ``path`` and return it.
Writes to a temp file in the same directory then renames, so a concurrent
checker never observes a half-written file.
"""
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(f"{dest.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
os.replace(tmp, dest)
return dest
@classmethod
def load(cls, path: _PathLike = DEFAULT_SESSION_PATH) -> "LaserVariationSession":
"""Load a session snapshot from ``path``.
Raises FileNotFoundError if the file is absent and ValueError if it is not
a valid session object.
"""
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Session file must be a JSON object: {path}")
try:
return cls(
variation_type=str(payload["variation_type"]),
target_temp1=float(payload["target_temp1"]),
target_temp2=float(payload["target_temp2"]),
tolerance_c=float(payload.get("tolerance_c", DEFAULT_TOLERANCE_C)),
started_at_iso=str(payload.get("started_at_iso", "")),
)
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"Malformed session file {path}: {exc}") from exc