Rename kamil_adc to rph_radar and fix its data path

This commit is contained in:
Ayzen
2026-10-06 18:15:18 +03:00
parent aef549ea76
commit a185e61495
94 changed files with 2851 additions and 9485 deletions
@@ -1,36 +0,0 @@
"""Kamil ADC reference-channel acquisition.
Layered into small, single-responsibility modules:
* :mod:`~python_app.hardware_full.kamil_adc.protocol` — TTY wire format → aligned
:class:`RawSweep` (pure, incremental parser).
* :mod:`~python_app.hardware_full.kamil_adc.processing` — reference-phase
frequency axis, amplitude normalization, crop + resample to a fixed grid (pure).
* :mod:`~python_app.hardware_full.kamil_adc.tty_reader` — background thread that
drains the TTY and publishes the latest sweep.
* :mod:`~python_app.hardware_full.kamil_adc.service` — collector process lifecycle
and ``acquire() -> SweepResult``.
* :mod:`~python_app.hardware_full.kamil_adc.laser` — pre-acquisition laser setup.
"""
from python_app.hardware_full.kamil_adc.laser import apply_kamil_adc_laser_control
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.kamil_adc.protocol import (
KamilAdcStreamParser,
RawSweep,
)
from python_app.hardware_full.kamil_adc.service import KamilAdcService
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
__all__ = [
"KamilAdcProcessingParams",
"KamilAdcService",
"KamilAdcStreamParser",
"KamilAdcSweepProcessor",
"KamilAdcTtyReader",
"RawSweep",
"apply_kamil_adc_laser_control",
]
-129
View File
@@ -1,129 +0,0 @@
"""Laser-controller setup applied before Kamil ADC acquisition.
This mirrors the legacy ``device_main`` command sequence: connect to the laser
controller, reset it, and apply either manual or variation mode per
``radar.laser_control``. The wire protocol is unchanged from the standalone tool;
only its home moved into the project.
"""
from __future__ import annotations
import logging
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger(__name__)
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"""Apply configured laser settings via the device_main command sequence.
Returns ``True`` when settings were applied, ``False`` when laser control is
disabled. The controller is always disconnected before returning.
"""
laser = config.radar.laser_control
if not laser.enabled:
logger.debug("Kamil ADC laser control disabled; skipping")
return False
_validate_laser_control_config(config)
from python_app.hardware_full.laser_control.controller import (
DEVICE_MAIN_MESSAGE_ID,
LaserController,
)
from python_app.hardware_full.laser_control.models import VariationType
controller = LaserController(
port=laser.port,
pi_coeff1_p=laser.pi_coeff1_p,
pi_coeff1_i=laser.pi_coeff1_i,
pi_coeff2_p=laser.pi_coeff2_p,
pi_coeff2_i=laser.pi_coeff2_i,
)
try:
controller.connect()
controller.reset()
mode = laser.mode.strip().lower()
logger.info("Applying Kamil ADC laser control in %s mode", mode)
if mode == "manual":
manual = laser.manual
controller.set_manual_mode(
temp1=manual.temp1,
temp2=manual.temp2,
current1=manual.current1,
current2=manual.current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
return True
if mode == "variation":
variation = laser.variation
try:
variation_type = VariationType[variation.variation_type]
except KeyError as exc:
raise ValueError(
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
) from exc
controller.set_manual_mode(
temp1=variation.static_temp1,
temp2=variation.static_temp2,
current1=variation.static_current1,
current2=variation.static_current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=variation_type,
params={
"static_temp1": variation.static_temp1,
"static_temp2": variation.static_temp2,
"static_current1": variation.static_current1,
"static_current2": variation.static_current2,
"min_value": variation.min_value,
"max_value": variation.max_value,
"step": variation.step,
"time_step": variation.time_step,
"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:
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
mode = laser.mode.strip().lower()
if mode not in {"manual", "variation"}:
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
if mode == "variation" and not laser.variation.variation_type:
raise ValueError("radar.laser_control.variation.variation_type is required")
@@ -1,171 +0,0 @@
"""Wire protocol for the Kamil ADC collector's TTY stream.
The collector publishes a stream of 8-byte little-endian frames, four 16-bit
words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame
kinds appear in ``do8_freq_ref`` mode:
* **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps.
* **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``.
* **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with
the main sample of the same ``step`` (emitted only where the DI8 loopback
settled, so reference points are sparser than main points).
:class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one
:class:`RawSweep` per completed sweep. Main and reference points are aligned by
step index; only steps carrying *both* survive (a step needs its reference for
the frequency axis and its main for the signal). Anything other than the three
known frame kinds is a protocol violation and raises — the reader fails fast and
lets the supervisor relaunch a clean collector rather than silently resyncing.
"""
from __future__ import annotations
from dataclasses import dataclass
import struct
import numpy as np
from numpy.typing import NDArray
# Wire-format constants.
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.
_FRAME = struct.Struct("<HHhh")
# The last three words of a boundary frame are all 0xFFFF; a real step is
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
# (profile-dependent) start marker.
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
@dataclass(frozen=True, slots=True)
class RawSweep:
"""One sweep's main and reference samples, aligned and ordered by step.
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
samples at those steps. All three arrays share the same length.
"""
steps: NDArray[np.int32]
main: NDArray[np.complex64]
reference: NDArray[np.complex64]
combo: tuple[int, int] | None = None
dirty: bool = False
@property
def size(self) -> int:
return int(self.steps.size)
class KamilAdcStreamParser:
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
sweeps completed by that chunk (usually zero or one). The parser is stateful
but holds no I/O and is cheap to unit-test.
"""
__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."""
self._buffer.extend(data)
if not self._aligned and not self._align():
return []
sweeps: list[RawSweep] = []
buffer = self._buffer
while len(buffer) >= FRAME_BYTES:
frame = bytes(buffer[:FRAME_BYTES])
del buffer[:FRAME_BYTES]
if frame[2:] == _BOUNDARY_TAIL:
sweep = self._take_sweep()
if sweep is not None:
sweeps.append(sweep)
continue
marker, step, real, imag = _FRAME.unpack(frame)
if marker == MAIN_MARKER:
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}"
)
return sweeps
def reset(self) -> None:
"""Drop all buffered state (e.g. after the collector is relaunched)."""
self._buffer.clear()
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.
Returns ``True`` once frame-aligned. Keeps a short tail so a boundary
split across two feeds can still be found on the next chunk.
"""
index = self._buffer.find(_START_FRAME)
if index < 0:
if len(self._buffer) >= FRAME_BYTES:
del self._buffer[: -(FRAME_BYTES - 1)]
return False
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
def _take_sweep(self) -> RawSweep | None:
"""Assemble the buffered points into a sweep and reset for the next one."""
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,
)
@@ -1,3 +1 @@
"""Vendored laser-control package used by the Kamil ADC hardware path."""
__version__ = "1.0.0"
"""Vendored laser-control package used by the RadioPhotonic radar (RPH) hardware path."""
@@ -9,43 +9,8 @@ laser control board.
BAUDRATE = 115200
SERIAL_TIMEOUT_SEC = 1.0
GET_DATA_TOTAL_LENGTH = 30 # bytes in device DATA response
SEND_PARAMS_TOTAL_LENGTH = 30 # bytes in DECODE_ENABLE command
TASK_ENABLE_COMMAND_LENGTH = 32 # bytes in TASK_ENABLE command
WAIT_AFTER_SEND_SEC = 0.15 # delay after sending a command
# ---- Command codes (as sent to device, already flipped to LE)
CMD_DECODE_ENABLE = 0x1111 # Set control parameters
CMD_DEFAULT_ENABLE = 0x2222 # Reset device
CMD_TRANS_ENABLE = 0x4444 # Request last data
CMD_REMOVE_FILE = 0x5555 # Delete saved data
CMD_STATE = 0x6666 # Request state
CMD_TASK_ENABLE = 0x7777 # Start a task
# ---- Error codes from device STATE response (after flipfour)
STATE_OK = '0000'
STATE_SD_ERR = '0001' # SD Card read/write error
STATE_UART_ERR = '0002' # Command (UART) error
STATE_UART_DECODE_ERR = '0004' # Wrong parameter value
STATE_TEC1_ERR = '0008' # Laser 1 TEC driver overheat
STATE_TEC2_ERR = '0010' # Laser 2 TEC driver overheat
STATE_DEFAULT_ERR = '0020' # System reset error
STATE_REMOVE_ERR = '0040' # File deletion error
STATE_DESCRIPTIONS = {
STATE_OK: "All ok.",
STATE_SD_ERR: "SD Card reading/writing error (SD_ERR).",
STATE_UART_ERR: "Command error (UART_ERR).",
STATE_UART_DECODE_ERR:"Wrong parameter value error (UART_DECODE_ERR).",
STATE_TEC1_ERR: "Laser 1: TEC driver overheat (TEC1_ERR).",
STATE_TEC2_ERR: "Laser 2: TEC driver overheat (TEC2_ERR).",
STATE_DEFAULT_ERR: "Resetting system error (DEFAULT_ERR).",
STATE_REMOVE_ERR: "File deletion error (REMOVE_ERR).",
}
# ---- Physical / hardware constants (from device_conversion.py)
VREF = 2.5 # Reference voltage, Volts
@@ -60,26 +25,13 @@ R6 = 56000 # Ohm
RREF = 10 # Current-setting resistor, Ohm
# (@1550 nm – 28.7 Ohm; @840 nm – 10 Ohm)
# External thermistor divider resistors
R7 = 22000 # Ohm
R8 = 22000 # Ohm
R9 = 5100 # Ohm
R10 = 180000 # Ohm
# Thermistor Steinhart–Hart B-coefficient (internal / external)
# Thermistor Steinhart–Hart B-coefficient
BETA_INTERNAL = 3900 # K
BETA_EXTERNAL = 3455 # K
T0_K = 298 # Kelvin (25 °C reference)
R0 = 10000 # Ohm (thermistor nominal at 25 °C)
# ADC resolution
# DAC resolution
ADC_BITS_16 = 65535 # 2^16 - 1
ADC_BITS_12 = 4095 # 2^12 - 1
# Voltage conversion coefficients
U3V3_COEFF = 1.221e-3 # counts → Volts for 3.3V rail
U5V_COEFF = 1.8315e-3 # counts → Volts for 5V rails
U7V_COEFF = 6.72e-3 # counts → Volts for 7V rail
# ---- Operational limits (validated in validators.py)
@@ -93,9 +45,6 @@ CURRENT_MAX_MA = 60.0 # Maximum allowed laser current, mA
CURRENT_STEP_MIN_MA = 0.002 # Minimum current variation step, mA
CURRENT_STEP_MAX_MA = 0.5 # Maximum current variation step, mA
TEMP_STEP_MIN_C = 0.05 # Minimum temperature variation step, °C
TEMP_STEP_MAX_C = 1.0 # Maximum temperature variation step, °C
# Time parameter limits
TIME_STEP_MIN_US = 20 # Minimum time step, microseconds
TIME_STEP_MAX_US = 100 # Maximum time step, microseconds
@@ -1,355 +1,60 @@
"""
Main laser controller for the laser control module.
"""Board commands with mandatory acknowledgements."""
Provides a high-level API for controlling dual laser systems.
All input parameters are validated before being sent to the device.
Can be embedded in any Python application without GUI dependencies.
"""
import time
import logging
from typing import Optional, Callable
import time
from .protocol import Protocol, TaskType as ProtoTaskType
from .validators import ParameterValidator
from .models import (
VariationType,
Measurements,
)
from .exceptions import (
CommunicationError,
DeviceNotRespondingError,
)
from .constants import WAIT_AFTER_SEND_SEC
from .exceptions import CommunicationError
from .protocol import Protocol
logger = logging.getLogger(__name__)
# Default PI regulator coefficients (match firmware defaults)
DEFAULT_PI_P = 2560 # 10 * 256
DEFAULT_PI_I = 128 # 0.5 * 256
DEFAULT_PI_P = 2560
DEFAULT_PI_I = 128
DEVICE_MAIN_MESSAGE_ID = 0x00FF
class LaserController:
"""
High-level controller for the dual laser board.
"""Control constant setpoints or LD1 current variation through one serial port."""
Usage example::
ctrl = LaserController(port='/dev/ttyUSB0')
ctrl.connect()
ctrl.set_manual_mode(temp1=25.0, temp2=30.0,
current1=40.0, current2=35.0)
data = ctrl.get_measurements()
print(data.voltage_3v3)
ctrl.disconnect()
All public methods raise :class:`ValidationError` for bad parameters
and :class:`CommunicationError` for transport-level problems.
"""
def __init__(
self,
port: Optional[str] = None,
pi_coeff1_p: int = DEFAULT_PI_P,
pi_coeff1_i: int = DEFAULT_PI_I,
pi_coeff2_p: int = DEFAULT_PI_P,
pi_coeff2_i: int = DEFAULT_PI_I,
on_data: Optional[Callable[[Measurements], None]] = None,
):
"""
Args:
port: Serial port (e.g. '/dev/ttyUSB0'). None = auto-detect.
pi_coeff1_p: Proportional coefficient for laser 1 PI regulator.
pi_coeff1_i: Integral coefficient for laser 1 PI regulator.
pi_coeff2_p: Proportional coefficient for laser 2 PI regulator.
pi_coeff2_i: Integral coefficient for laser 2 PI regulator.
on_data: Optional callback called whenever new measurements
are received. Signature: ``callback(Measurements)``.
"""
def __init__(self, port: str,
pi_coeff1_p: int = DEFAULT_PI_P, pi_coeff1_i: int = DEFAULT_PI_I,
pi_coeff2_p: int = DEFAULT_PI_P, pi_coeff2_i: int = DEFAULT_PI_I):
self._protocol = Protocol(port)
self._pi1_p = pi_coeff1_p
self._pi1_i = pi_coeff1_i
self._pi2_p = pi_coeff2_p
self._pi2_i = pi_coeff2_i
self._on_data = on_data
self._message_id = 0
self._last_measurements: Optional[Measurements] = None
# Last manual-mode params, used to restore state after stop_task()
self._last_temp1: float = 25.0
self._last_temp2: float = 25.0
self._last_current1: float = 30.0
self._last_current2: float = 30.0
# ---- Connection -------------------------------------------------------
self._pi = dict(pi_coeff1_p=pi_coeff1_p, pi_coeff1_i=pi_coeff1_i,
pi_coeff2_p=pi_coeff2_p, pi_coeff2_i=pi_coeff2_i)
def connect(self) -> bool:
"""
Open connection to the device.
Returns:
True if connection succeeded.
Raises:
CommunicationError: If the port cannot be opened.
"""
self._protocol.connect()
logger.info("Connected to laser controller on port %s",
self._protocol._port_name or "auto")
logger.info("Connected to laser controller on port %s", self._protocol.port)
return True
def disconnect(self) -> None:
"""Close the serial port gracefully."""
self._protocol.disconnect()
logger.info("Disconnected from laser controller")
@property
def is_connected(self) -> bool:
"""True if the serial port is open."""
return self._protocol.is_connected
def set_manual_mode(self, temp1: float, temp2: float, current1: float, current2: float,
message_id: int) -> None:
manual = dict(temp1=temp1, temp2=temp2, current1=current1, current2=current2)
self._send_and_read_state(Protocol.encode_decode_enable(**manual, **self._pi, message_id=message_id))
logger.debug("Manual setpoints: %s", manual)
# ---- Public API -------------------------------------------------------
def set_manual_mode(
self,
temp1: float,
temp2: float,
current1: float,
current2: float,
message_id: Optional[int] = None,
) -> None:
"""
Set manual control parameters for both lasers.
Args:
temp1: Setpoint temperature for laser 1, °C.
Valid range: [15.0 … 40.0] °C.
temp2: Setpoint temperature for laser 2, °C.
Valid range: [15.0 … 40.0] °C.
current1: Drive current for laser 1, mA.
Valid range: [15.0 … 60.0] mA.
current2: Drive current for laser 2, mA.
Valid range: [15.0 … 60.0] mA.
message_id: Optional fixed DECODE_ENABLE message id. When
omitted, the id is incremented for backward
compatibility with the refactored API.
Raises:
ValidationError: If any parameter is out of range.
CommunicationError: If the command cannot be sent.
"""
validated = ParameterValidator.validate_manual_mode_params(
temp1, temp2, current1, current2
)
if message_id is None:
self._message_id = (self._message_id + 1) & 0xFFFF
else:
self._message_id = int(message_id) & 0xFFFF
cmd = Protocol.encode_decode_enable(
temp1=validated['temp1'],
temp2=validated['temp2'],
current1=validated['current1'],
current2=validated['current2'],
pi_coeff1_p=self._pi1_p,
pi_coeff1_i=self._pi1_i,
pi_coeff2_p=self._pi2_p,
pi_coeff2_i=self._pi2_i,
message_id=self._message_id,
)
self._send_and_read_state(cmd)
self._last_temp1 = validated['temp1']
self._last_temp2 = validated['temp2']
self._last_current1 = validated['current1']
self._last_current2 = validated['current2']
logger.debug("Manual mode set: T1=%.2f T2=%.2f I1=%.2f I2=%.2f",
validated['temp1'], validated['temp2'],
validated['current1'], validated['current2'])
def start_variation(
self,
variation_type: VariationType,
params: dict,
) -> None:
"""
Start a parameter variation task.
Args:
variation_type: Which parameter to vary
(:class:`VariationType.CHANGE_CURRENT_LD1` or
:class:`VariationType.CHANGE_CURRENT_LD2`).
params: Dictionary with the following keys:
- ``min_value`` – minimum value of the varied parameter.
- ``max_value`` – maximum value of the varied parameter.
- ``step`` – step size.
- ``time_step`` – discretisation time step, µs [20 … 100].
- ``delay_time``– delay between pulses, ms [3 … 10].
- ``static_temp1`` – fixed temperature for laser 1, °C.
- ``static_temp2`` – fixed temperature for laser 2, °C.
- ``static_current1`` – fixed current for laser 1, mA.
- ``static_current2`` – fixed current for laser 2, mA.
Raises:
ValidationError: If any parameter fails validation.
CommunicationError: If the command cannot be sent.
"""
# Validate variation-specific params
validated = ParameterValidator.validate_variation_params(
params, variation_type
)
# Validate static parameters
static_temp1 = ParameterValidator.validate_temperature(
params.get('static_temp1', 25.0), 'static_temp1'
)
static_temp2 = ParameterValidator.validate_temperature(
params.get('static_temp2', 25.0), 'static_temp2'
)
static_current1 = ParameterValidator.validate_current(
params.get('static_current1', 30.0), 'static_current1'
)
static_current2 = ParameterValidator.validate_current(
params.get('static_current2', 30.0), 'static_current2'
)
# Map VariationType → protocol TaskType
task_type_map = {
VariationType.CHANGE_CURRENT_LD1: ProtoTaskType.CHANGE_CURRENT_LD1,
VariationType.CHANGE_CURRENT_LD2: ProtoTaskType.CHANGE_CURRENT_LD2,
VariationType.CHANGE_TEMPERATURE_LD1: ProtoTaskType.CHANGE_TEMPERATURE_LD1,
VariationType.CHANGE_TEMPERATURE_LD2: ProtoTaskType.CHANGE_TEMPERATURE_LD2,
}
proto_task = task_type_map[validated['variation_type']]
cmd = Protocol.encode_task_enable(
task_type=proto_task,
static_temp1=static_temp1,
static_temp2=static_temp2,
static_current1=static_current1,
static_current2=static_current2,
min_value=validated['min_value'],
max_value=validated['max_value'],
step=validated['step'],
time_step=validated['time_step'],
delay_time=validated['delay_time'],
message_id=self._message_id,
pi_coeff1_p=self._pi1_p,
pi_coeff1_i=self._pi1_i,
pi_coeff2_p=self._pi2_p,
pi_coeff2_i=self._pi2_i,
)
self._send_and_read_state(cmd)
logger.info("Variation task started: type=%s min=%.3f max=%.3f step=%.3f",
validated['variation_type'].name,
validated['min_value'],
validated['max_value'],
validated['step'])
def stop_task(self, restore_message_id: Optional[int] = None) -> None:
"""Stop the current task and restore manual mode.
Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last
known manual-mode parameters. This two-step sequence matches the
original firmware protocol: after DEFAULT_ENABLE the board is in a
reset state and must receive DECODE_ENABLE before it can respond to
TRANS_ENABLE data requests again.
"""
cmd_reset = Protocol.encode_default_enable()
self._send_and_read_state(cmd_reset)
logger.info("Task stopped (DEFAULT_ENABLE sent)")
self.set_manual_mode(
temp1=self._last_temp1,
temp2=self._last_temp2,
current1=self._last_current1,
current2=self._last_current2,
message_id=restore_message_id,
)
logger.info("Manual mode restored after task stop")
def get_measurements(self) -> Optional[Measurements]:
"""
Request and return the latest measurements from the device.
Returns:
:class:`Measurements` dataclass, or None if no data available.
Raises:
CommunicationError: On transport errors.
"""
cmd = Protocol.encode_trans_enable()
self._send(cmd)
raw = self._protocol.receive_raw(30)
if not raw or len(raw) != 30:
logger.warning("No data received from device")
return None
response = Protocol.decode_response(raw)
measurements = response.to_measurements()
self._last_measurements = measurements
if self._on_data:
self._on_data(measurements)
return measurements
def start_variation(self, params: dict) -> None:
self._send_and_read_state(Protocol.encode_task_enable(
**params, pi_coeff1_p=self._pi["pi_coeff1_p"], pi_coeff1_i=self._pi["pi_coeff1_i"],
pi_coeff2_p=self._pi["pi_coeff2_p"], pi_coeff2_i=self._pi["pi_coeff2_i"],
))
logger.info("LD1 current variation started: %s", params)
def reset(self) -> None:
"""Send a hardware reset command to the device."""
cmd = Protocol.encode_default_enable()
self._send_and_read_state(cmd)
self._send_and_read_state(b"\x22\x22")
logger.info("Device reset command sent")
# ---- Internal helpers -------------------------------------------------
def _send(self, cmd: bytes) -> None:
"""Send command bytes and wait for the device to process."""
if not self.is_connected:
raise CommunicationError("Not connected to device. Call connect() first.")
self._protocol.send_raw(cmd)
def _send(self, command: bytes) -> None:
self._protocol.send_raw(command)
time.sleep(WAIT_AFTER_SEND_SEC)
def _send_and_read_state(self, cmd: bytes) -> int:
"""Send command and read the 2-byte STATE response the device always returns.
Commands DECODE_ENABLE, TASK_ENABLE and DEFAULT_ENABLE each trigger a
STATE reply from the firmware. If we don't consume those bytes here,
they accumulate in the serial buffer and corrupt the next DATA read.
Returns the decoded state code (0x0000 = OK).
"""
self._send(cmd)
raw = self._protocol.receive_raw(2)
if raw and len(raw) == 2:
state = Protocol.decode_state(raw)
if state != 0:
# Surface a device-reported non-OK STATE instead of silently
# treating a board-rejected command as success.
logger.warning(
"Device returned non-OK STATE 0x%04x after command: %s",
state,
Protocol.state_to_description(f"{state:04x}"),
)
else:
logger.debug("STATE response after command: 0x%04x", state)
return state
return 0
# ---- Context manager support -----------------------------------------
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Always try to stop any running task before closing the port.
# If we don't, the board stays in TASK state and ignores all future
# commands until its power is cycled.
if self.is_connected:
try:
self.stop_task()
except Exception:
logger.warning("Failed to stop laser task on exit; closing port anyway", exc_info=True)
self.disconnect()
return False
def _send_and_read_state(self, command: bytes) -> None:
self._send(command)
state = Protocol.decode_state(self._protocol.receive_raw(2))
if state:
raise CommunicationError(f"Device rejected command: STATE 0x{state:04x}")
@@ -5,14 +5,7 @@ values used by the device firmware, using the hardware's bridge/divider formulas
"""
import math
from .constants import (
VREF, R1, R3, R4, R5, R6,
R7, R8, R9, R10,
RREF,
BETA_INTERNAL, BETA_EXTERNAL, T0_K, R0,
ADC_BITS_16, ADC_BITS_12,
U3V3_COEFF, U5V_COEFF, U7V_COEFF,
)
from .constants import ADC_BITS_16, BETA_INTERNAL, R0, R1, R3, R4, R5, R6, RREF, T0_K, VREF
def temp_c_to_n(temp_c: float) -> int:
@@ -34,38 +27,6 @@ def temp_c_to_n(temp_c: float) -> int:
return n
def temp_n_to_c(n: int) -> float:
"""
Convert 16-bit ADC integer to temperature (°C).
Args:
n: Raw ADC value in [0, 65535].
Returns:
Temperature in degrees Celsius.
"""
u = n * VREF / ADC_BITS_16
rt = R1 * (VREF * R4 * (R5 + R6) - u * R5 * (R3 + R4)) / (
u * R5 * (R3 + R4) + VREF * R3 * R6 - VREF * R4 * R5
)
t = 1 / (1 / T0_K + 1 / BETA_INTERNAL * math.log(rt / R0)) - 273
return t
def temp_ext_n_to_c(n: int) -> float:
"""
Convert 12-bit ADC integer to external thermistor temperature (°C).
Args:
n: Raw 12-bit ADC value in [0, 4095].
Returns:
Temperature in degrees Celsius.
"""
u = n * VREF / ADC_BITS_12 * 1 / (1 + 100000 / R10) + VREF * R9 / (R8 + R9)
rt = R7 * u / (VREF - u)
t = 1 / (1 / T0_K + 1 / BETA_EXTERNAL * math.log(rt / R0)) - 273
return t
def current_ma_to_n(current_ma: float) -> int:
@@ -81,31 +42,3 @@ def current_ma_to_n(current_ma: float) -> int:
n = int(ADC_BITS_16 / 2000 * RREF * current_ma)
n = max(0, min(ADC_BITS_16, n))
return n
def current_n_to_ma(n: int) -> float:
"""
Convert raw ADC integer to photodiode current (mA).
Args:
n: Raw ADC value in [0, 65535].
Returns:
Current in milliamps.
"""
return n * 2.5 / (ADC_BITS_16 * 4.4) - 1 / 20.4
def voltage_3v3_n_to_v(n: int) -> float:
"""Convert 3.3V rail ADC count to volts."""
return n * U3V3_COEFF
def voltage_5v_n_to_v(n: int) -> float:
"""Convert 5V rail ADC count to volts (both 5V1 and 5V2)."""
return n * U5V_COEFF
def voltage_7v_n_to_v(n: int) -> float:
"""Convert 7V rail ADC count to volts."""
return n * U7V_COEFF
@@ -16,58 +16,11 @@ class ValidationError(LaserControlError):
pass
class TemperatureOutOfRangeError(ValidationError):
"""Exception raised when temperature is outside valid range."""
def __init__(self, param_name: str, value: float, min_val: float, max_val: float):
self.param_name = param_name
self.value = value
self.min_val = min_val
self.max_val = max_val
super().__init__(
f"{param_name}: Temperature {value}°C is out of range "
f"[{min_val}°C - {max_val}°C]"
)
class CurrentOutOfRangeError(ValidationError):
"""Exception raised when current is outside valid range."""
def __init__(self, param_name: str, value: float, min_val: float, max_val: float):
self.param_name = param_name
self.value = value
self.min_val = min_val
self.max_val = max_val
super().__init__(
f"{param_name}: Current {value}mA is out of range "
f"[{min_val}mA - {max_val}mA]"
)
class InvalidParameterError(ValidationError):
"""Exception raised for invalid parameter types or values."""
def __init__(self, param_name: str, message: str):
self.param_name = param_name
super().__init__(f"{param_name}: {message}")
class CommunicationError(LaserControlError):
"""Base exception for communication errors."""
pass
class PortNotFoundError(CommunicationError):
"""Exception raised when serial port cannot be found."""
def __init__(self, port: str | None = None):
if port:
message = f"Serial port '{port}' not found"
else:
message = "No suitable serial port found for device connection"
super().__init__(message)
class PortBusyError(CommunicationError):
"""Raised when the serial port is already held by another program.
@@ -86,28 +39,6 @@ class PortBusyError(CommunicationError):
)
class DeviceNotRespondingError(CommunicationError):
"""Exception raised when device doesn't respond to commands."""
def __init__(self, timeout: float | None = None):
if timeout:
message = f"Device did not respond within {timeout} seconds"
else:
message = "Device is not responding to commands"
super().__init__(message)
class CRCError(CommunicationError):
"""Exception raised when CRC check fails."""
def __init__(self, expected: int | None = None, received: int | None = None):
if expected is not None and received is not None:
message = f"CRC check failed. Expected: 0x{expected:04X}, Received: 0x{received:04X}"
else:
message = "CRC check failed on received data"
super().__init__(message)
class ProtocolError(CommunicationError):
"""Exception raised for protocol-level errors."""
@@ -1,65 +0,0 @@
"""
Data models for laser control module.
Provides dataclasses and enums for structured data representation
throughout the laser control system.
"""
from dataclasses import dataclass
from enum import IntEnum
from typing import Optional, Dict, Any
from datetime import datetime
class VariationType(IntEnum):
"""Types of parameter variation modes."""
MANUAL = 0x00
CHANGE_CURRENT_LD1 = 0x01
CHANGE_CURRENT_LD2 = 0x02
CHANGE_TEMPERATURE_LD1 = 0x03
CHANGE_TEMPERATURE_LD2 = 0x04
@dataclass
class Measurements:
"""Real-time measurements from the device."""
# Photodiode currents
current1: float # Photodiode current for laser 1 (mA)
current2: float # Photodiode current for laser 2 (mA)
# Temperatures
temp1: float # Temperature of laser 1 (°C)
temp2: float # Temperature of laser 2 (°C)
temp_ext1: Optional[float] = None # External thermistor 1 temperature (°C)
temp_ext2: Optional[float] = None # External thermistor 2 temperature (°C)
# Power supply voltages
voltage_3v3: float = 0.0 # 3.3V rail voltage
voltage_5v1: float = 0.0 # 5V rail 1 voltage
voltage_5v2: float = 0.0 # 5V rail 2 voltage
voltage_7v0: float = 0.0 # 7V rail voltage
# Metadata
timestamp: Optional[datetime] = None
message_id: Optional[int] = None
to6_counter_lsb: Optional[int] = None
to6_counter_msb: Optional[int] = None
def __post_init__(self):
"""Set timestamp if not provided."""
if self.timestamp is None:
self.timestamp = datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation."""
return {
'current1': self.current1,
'current2': self.current2,
'temp1': self.temp1,
'temp2': self.temp2,
'temp_ext1': self.temp_ext1,
'temp_ext2': self.temp_ext2,
'voltage_3v3': self.voltage_3v3,
'voltage_5v1': self.voltage_5v1,
'voltage_5v2': self.voltage_5v2,
'voltage_7v0': self.voltage_7v0,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'message_id': self.message_id
}
@@ -1,259 +0,0 @@
# Контроль температуры при вариации тока лазера
Набор из трёх развязанных компонентов для автоматизации измерений в режиме
**вариации тока лазера 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 в консоль
```
- **Порт лазера эксклюзивен.** При `--start` запуск и мониторинг используют одно
соединение. Штатный пайплайн закрывает порт после запуска вариации, и его
может открыть отдельный монитор. `checker` читает только файлы.
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
останавливаются и перезапускаются независимо, без рукопожатия.
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
термисторам `temp_ext*`), с общим допуском (по умолчанию `0.03 °C`).
## Быстрый старт (CLI, два процесса)
Команды запускаются из корня проекта. Терминал 1 — стартовать вариацию и
мониторить температуру:
```bash
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
--config run_config.json \
--start
```
Терминал 2 — валидировать температуру и печатать предупреждения:
```bash
.venv/bin/python -m python_app.scripts.laser_temp_checker
```
Пример вывода чекера при расхождении и возврате в допуск:
```
WARNING python_app.hardware_full.laser_control.monitoring.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 python_app.hardware_full.laser_control.monitoring.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` | Серийный порт лазерной платы; CLI-монитор допускает пустое значение для автоопределения, пайплайн требует явный порт |
| `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
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config run_config.json
```
Фиксированный период вместо расчётного (напр. раз в 500 мс):
```bash
.venv/bin/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
# монитор
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
# чекер
.venv/bin/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)), снимок сессии
`<tmp>/laser_variation_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)
worker = threading.Thread(target=monitor.run, args=(stop,))
worker.start()
# 3. Чекер: тайлить показания и валидировать оба лазера
checker = LaserTemperatureChecker.from_session(session)
reader = ReadingReader("readings.jsonl")
try:
while not stop.is_set():
for reading in reader.poll():
checker.process(reading) # печатает WARNING при |Δ| > tolerance
stop.wait(0.2)
except KeyboardInterrupt:
pass
finally:
stop.set()
worker.join() # завершить опрос до закрытия порта и файла
```
`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
.venv/bin/python -m unittest python_app.tests.test_laser_temp_monitoring
```
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому
лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление).
@@ -1,36 +0,0 @@
"""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",
]
@@ -1,139 +0,0 @@
"""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,
)
@@ -1,147 +0,0 @@
"""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 _Temperatures(Protocol):
temp1: float
temp2: float
class _MeasurementSource(Protocol):
"""Minimal controller surface the monitor depends on (eases testing)."""
def get_measurements(self) -> _Temperatures | None: ...
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: float | None) -> Optional[float]:
return None if value is None else float(value)
@@ -1,131 +0,0 @@
"""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: str | float | None) -> 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
@@ -1,72 +0,0 @@
"""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
from python_app.file_io import atomic_write_json
_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.
"""
return atomic_write_json(Path(path), asdict(self))
@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
@@ -1,156 +1,39 @@
"""Communication protocol for the laser control module.
Encodes commands to wire bytes, decodes device responses, and manages the
serial port connection to the laser control board.
"""
"""Serial transport and little-endian commands for the dual-laser board."""
import errno
import logging
from typing import Optional
from enum import IntEnum
from datetime import datetime
import struct
from functools import reduce
from operator import xor
import serial
import serial.tools.list_ports
from .constants import (
BAUDRATE, SERIAL_TIMEOUT_SEC,
GET_DATA_TOTAL_LENGTH,
SEND_PARAMS_TOTAL_LENGTH,
TASK_ENABLE_COMMAND_LENGTH,
CMD_DECODE_ENABLE, CMD_DEFAULT_ENABLE,
CMD_TRANS_ENABLE, CMD_REMOVE_FILE,
CMD_STATE, CMD_TASK_ENABLE,
STATE_DESCRIPTIONS, STATE_OK,
)
from .conversions import (
temp_c_to_n, temp_n_to_c,
temp_ext_n_to_c,
current_ma_to_n, current_n_to_ma,
voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v,
)
from .models import Measurements
from .exceptions import (
CommunicationError,
PortNotFoundError,
PortBusyError,
CRCError,
ProtocolError,
)
from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC
from .conversions import current_ma_to_n, temp_c_to_n
from .validators import validate_manual, validate_variation
from .exceptions import CommunicationError, PortBusyError, ProtocolError
logger = logging.getLogger(__name__)
class TaskType(IntEnum):
MANUAL = 0x00
CHANGE_CURRENT_LD1 = 0x01
CHANGE_CURRENT_LD2 = 0x02
CHANGE_TEMPERATURE_LD1 = 0x03
CHANGE_TEMPERATURE_LD2 = 0x04
def _command(header: int, *words: int) -> bytes:
"""Append XOR checksum; header is excluded, every word is uint16 LE."""
if any(not isinstance(word, int) or not 0 <= word <= 65535 for word in words):
raise ValueError("Laser command words must be uint16 integers")
return struct.pack(f"<{len(words) + 2}H", header, *words, reduce(xor, words, 0))
# ---- Low-level helpers --------------------------------------------------
def _int_to_hex4(value: int) -> str:
"""Return 4-character lowercase hex string (0–65535)."""
if value < 0 or value > 65535:
raise ValueError(f"Value {value} out of uint16 range [0, 65535]")
return f"{value:04x}"
def _flipfour(hex_word: str) -> str:
"""Swap the two byte-pairs of a 4-char hex word: 'aabb' -> 'bbaa' (little-endian)."""
if len(hex_word) != 4:
raise ValueError(f"Expected 4-char hex string, got '{hex_word}'")
return hex_word[2:4] + hex_word[0:2]
def _xor_crc(words: list) -> str:
"""XOR all 16-bit hex words and return 4-char hex CRC."""
result = int(words[0], 16)
for w in words[1:]:
result ^= int(w, 16)
return _int_to_hex4(result)
def _build_crc(data_hex: str) -> str:
"""Calculate XOR CRC over words 1..N of a hex string (skip word 0)."""
words = [data_hex[i:i+4] for i in range(0, len(data_hex), 4)]
return _xor_crc(words[1:])
def _encode_setup() -> str:
"""Build the 16-bit setup word (all subsystems enabled, SD save off)."""
bits = ['0'] * 16
bits[15] = '1' # enable work
bits[14] = '1' # enable 5v1
bits[13] = '1' # enable 5v2
bits[12] = '1' # enable LD1
bits[11] = '1' # enable LD2
bits[10] = '1' # enable REF1
bits[9] = '1' # enable REF2
bits[8] = '1' # enable TEC1
bits[7] = '1' # enable TEC2
bits[6] = '1' # enable temp stab 1
bits[5] = '1' # enable temp stab 2
bits[4] = '0' # enable sd save (disabled)
bits[3] = '1' # enable PI1 coef read
bits[2] = '1' # enable PI2 coef read
bits[1] = '0' # reserved
bits[0] = '0' # reserved
return f"{int(''.join(bits), 2):04x}"
# ---- Response dataclass --------------------------------------------------
class Response:
"""Decoded device DATA response."""
__slots__ = [
'current1', 'current2',
'temp1', 'temp2',
'temp_ext1', 'temp_ext2',
'voltage_3v3', 'voltage_5v1', 'voltage_5v2', 'voltage_7v0',
'to6_lsb', 'to6_msb',
'message_id',
'header',
]
def to_measurements(self) -> Measurements:
return Measurements(
current1=self.current1,
current2=self.current2,
temp1=self.temp1,
temp2=self.temp2,
temp_ext1=self.temp_ext1,
temp_ext2=self.temp_ext2,
voltage_3v3=self.voltage_3v3,
voltage_5v1=self.voltage_5v1,
voltage_5v2=self.voltage_5v2,
voltage_7v0=self.voltage_7v0,
timestamp=datetime.now(),
message_id=self.message_id,
to6_counter_lsb=self.to6_lsb,
to6_counter_msb=self.to6_msb,
)
# ---- Protocol class --------------------------------------------------
class Protocol:
"""
Encodes commands and decodes responses for the laser control board.
"""Exclusive serial connection and the commands used by the application."""
Can also manage a serial port connection when port is provided.
"""
def __init__(self, port: Optional[str] = None):
self._port_name = port
self._serial: Optional[serial.Serial] = None
def __init__(self, port: str):
self.port = port
self._serial: serial.Serial | None = None
# ---- Connection management
def connect(self) -> None:
"""Open the serial port. Auto-detects the device path when port is None.
"""Open the serial port.
The port is opened with ``exclusive=True`` so only one program can own the
laser controller at a time. This is required because the board is a single
@@ -162,7 +45,7 @@ class Protocol:
"""
if self.is_connected:
return
port = self._port_name or self._detect_port()
port = self.port
try:
self._serial = serial.Serial(
port=port,
@@ -194,13 +77,6 @@ class Protocol:
def is_connected(self) -> bool:
return self._serial is not None and self._serial.is_open
def _detect_port(self) -> str:
"""Return first available serial port device path."""
ports = list(serial.tools.list_ports.comports())
if not ports:
raise PortNotFoundError()
return ports[0].device
# ---- Raw I/O
def send_raw(self, data: bytes) -> None:
@@ -213,199 +89,45 @@ class Protocol:
raise CommunicationError("Serial port is not connected")
return self._serial.read(length)
# ---- Command encoders -----------------------------------------------
@staticmethod
def encode_decode_enable(
temp1: float,
temp2: float,
current1: float,
current2: float,
pi_coeff1_p: int,
pi_coeff1_i: int,
pi_coeff2_p: int,
pi_coeff2_i: int,
temp1: float, temp2: float, current1: float, current2: float,
pi_coeff1_p: int, pi_coeff1_i: int, pi_coeff2_p: int, pi_coeff2_i: int,
message_id: int,
) -> bytes:
"""
Build DECODE_ENABLE command (0x1111).
Sets temperature and current setpoints for both lasers.
Returns 30-byte bytearray.
"""
if current1 < 0 or current2 < 0:
raise ValueError("Current values must not be negative")
data = _flipfour(_int_to_hex4(CMD_DECODE_ENABLE)) # Word 0
data += _flipfour(_encode_setup()) # Word 1
data += _flipfour(_int_to_hex4(temp_c_to_n(temp1))) # Word 2
data += _flipfour(_int_to_hex4(temp_c_to_n(temp2))) # Word 3
data += _flipfour('0000') * 3 # Words 4-6
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 7
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 8
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 9
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 10
data += _flipfour(_int_to_hex4(message_id & 0xFFFF)) # Word 11
data += _flipfour(_int_to_hex4(current_ma_to_n(current1))) # Word 12
data += _flipfour(_int_to_hex4(current_ma_to_n(current2))) # Word 13
data += _build_crc(data) # Word 14
result = bytearray.fromhex(data)
assert len(result) == SEND_PARAMS_TOTAL_LENGTH, \
f"DECODE_ENABLE length mismatch: {len(result)}"
return bytes(result)
"""DECODE_ENABLE: constant currents, temperatures and PI coefficients."""
validate_manual(temp1, temp2, current1, current2)
# 0x37ff enables both channels and their regulators; SD recording is off.
return _command(
0x1111, 0x37ff, temp_c_to_n(temp1), temp_c_to_n(temp2), 0, 0, 0,
pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i, message_id & 0xffff,
current_ma_to_n(current1), current_ma_to_n(current2),
)
@staticmethod
def encode_task_enable(
task_type: TaskType,
static_temp1: float,
static_temp2: float,
static_current1: float,
static_current2: float,
min_value: float,
max_value: float,
step: float,
time_step: int,
delay_time: int,
message_id: int,
pi_coeff1_p: int = 1,
pi_coeff1_i: int = 1,
pi_coeff2_p: int = 1,
pi_coeff2_i: int = 1,
static_temp1: float, static_temp2: float, static_current1: float, static_current2: float,
min_value: float, max_value: float, step: float, time_step: int, delay_time: int,
pi_coeff1_p: int = 1, pi_coeff1_i: int = 1, pi_coeff2_p: int = 1, pi_coeff2_i: int = 1,
variation_type: str = "CHANGE_CURRENT_LD1",
) -> bytes:
"""
Build TASK_ENABLE command (0x7777).
Starts a measurement task (current or temperature variation).
Returns 32-byte bytearray.
"""
if not isinstance(task_type, TaskType):
try:
task_type = TaskType(task_type)
except ValueError:
raise ValueError(f"Invalid task_type: {task_type}")
data = _flipfour(_int_to_hex4(CMD_TASK_ENABLE)) # Word 0
data += _flipfour(_encode_setup()) # Word 1
data += _flipfour(_int_to_hex4(task_type.value)) # Word 2
match task_type:
case TaskType.CHANGE_CURRENT_LD1:
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 7
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current2)))# Word 8
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 9
case TaskType.CHANGE_CURRENT_LD2:
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
# Word 5: current step uses the same current_ma_to_n scaling as
# min/max (and as LD1) so equal physical steps map to equal wire values.
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 9
case TaskType.CHANGE_TEMPERATURE_LD1 | TaskType.CHANGE_TEMPERATURE_LD2:
raise NotImplementedError("Temperature variation is not yet implemented in firmware")
case _:
raise ValueError(f"Unsupported task type: {task_type}")
data += _flipfour(_int_to_hex4(int(delay_time))) # Word 10: Tau in ms (3-10)
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 11
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 12
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 13
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 14
data += _build_crc(data) # Word 15
result = bytearray.fromhex(data)
assert len(result) == TASK_ENABLE_COMMAND_LENGTH, \
f"TASK_ENABLE length mismatch: {len(result)}"
return bytes(result)
@staticmethod
def encode_trans_enable(message_id: int = 0) -> bytes:
"""Build TRANS_ENABLE command (0x4444) — request last data."""
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE)))
@staticmethod
def encode_state(message_id: int = 0) -> bytes:
"""Build STATE command (0x6666) — request device state."""
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_STATE)))
@staticmethod
def encode_default_enable(message_id: int = 0) -> bytes:
"""Build DEFAULT_ENABLE command (0x2222) — reset device."""
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
# ---- Response decoders -----------------------------------------------
@staticmethod
def decode_response(data: bytes) -> Response:
"""
Decode a 30-byte DATA response from the device.
Raises:
ProtocolError: If data length is wrong.
CRCError: If CRC check fails.
"""
if len(data) != GET_DATA_TOTAL_LENGTH:
raise ProtocolError(
f"Expected {GET_DATA_TOTAL_LENGTH} bytes, got {len(data)} bytes"
)
hex_str = data.hex()
def get_word(num: int) -> str:
return _flipfour(hex_str[num*4: num*4+4])
def get_int_word(num: int) -> int:
return int(get_word(num), 16)
# CRC check: XOR over words 1..13 (wire order), compare with word 14 (wire order)
crc_words = [hex_str[i:i+4] for i in range(4, len(hex_str)-4, 4)]
computed = int(crc_words[0], 16)
for w in crc_words[1:]:
computed ^= int(w, 16)
stored = int(hex_str[56:60], 16)
if computed != stored:
raise CRCError(expected=computed, received=stored)
resp = Response()
resp.header = get_word(0)
resp.current1 = current_n_to_ma(get_int_word(1))
resp.current2 = current_n_to_ma(get_int_word(2))
resp.to6_lsb = get_int_word(3)
resp.to6_msb = get_int_word(4)
resp.temp1 = temp_n_to_c(get_int_word(5))
resp.temp2 = temp_n_to_c(get_int_word(6))
resp.temp_ext1 = temp_ext_n_to_c(get_int_word(7))
resp.temp_ext2 = temp_ext_n_to_c(get_int_word(8))
resp.voltage_3v3 = voltage_3v3_n_to_v(get_int_word(9))
resp.voltage_5v1 = voltage_5v_n_to_v(get_int_word(10))
resp.voltage_5v2 = voltage_5v_n_to_v(get_int_word(11))
resp.voltage_7v0 = voltage_7v_n_to_v(get_int_word(12))
resp.message_id = get_int_word(13)
return resp
"""TASK_ENABLE: sweep LD1 current while LD2 and both temperatures stay fixed."""
if variation_type != "CHANGE_CURRENT_LD1":
raise ValueError("radar.laser_control.variation.variation_type must be CHANGE_CURRENT_LD1")
validate_manual(static_temp1, static_temp2, static_current1, static_current2)
validate_variation(min_value, max_value, step, time_step, delay_time)
low, high, delta = (current_ma_to_n(value) for value in (min_value, max_value, step))
if delta == 0 or low >= high:
raise ValueError("Current variation range and step must be representable by the DAC")
return _command(
0x7777, 0x37ff, 1, low, high, delta, int(time_step * 100),
temp_c_to_n(static_temp1), current_ma_to_n(static_current2), temp_c_to_n(static_temp2),
int(delay_time), pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i,
)
@staticmethod
def decode_state(data: bytes) -> int:
"""
Decode a 2-byte STATE response from the device.
Returns:
Integer state code; see STATE_* constants.
"""
if len(data) < 2:
raise ProtocolError(f"STATE response too short: {len(data)} bytes")
hex_str = data.hex()
state_hex = _flipfour(hex_str[0:4])
return int(state_hex, 16)
@staticmethod
def state_to_description(state_hex_str: str) -> str:
"""Return human-readable description for a state hex string."""
return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.")
"""STATE is a two-byte error bitmask; zero acknowledges the command."""
if len(data) != 2:
raise ProtocolError(f"Expected 2 STATE bytes, got {len(data)}")
return int.from_bytes(data, "little")
@@ -1,257 +1,33 @@
"""
Parameter validation for laser control module.
Validates all input parameters against physical constraints
and protocol limits before sending to device.
"""
"""Physical limits checked before command encoding or any board reset."""
import math
from typing import Dict, Any, Tuple
from .constants import (
TEMP_MIN_C, TEMP_MAX_C,
CURRENT_MIN_MA, CURRENT_MAX_MA,
TEMP_MIN_C, TEMP_MAX_C, CURRENT_MIN_MA, CURRENT_MAX_MA,
CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA,
TEMP_STEP_MIN_C, TEMP_STEP_MAX_C,
TIME_STEP_MIN_US, TIME_STEP_MAX_US,
DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS,
TIME_STEP_MIN_US, TIME_STEP_MAX_US, DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS,
)
from .exceptions import (
ValidationError,
TemperatureOutOfRangeError,
CurrentOutOfRangeError,
InvalidParameterError,
)
from .models import VariationType
from .exceptions import ValidationError
class ParameterValidator:
"""Validates all input parameters for the laser controller."""
def _range(value: float, name: str, low: float, high: float) -> None:
if not isinstance(value, (int, float)) or not math.isfinite(value) or not low <= value <= high:
raise ValidationError(f"{name} must be a finite number in [{low}, {high}], got {value!r}")
@staticmethod
def _check_numeric(value: Any, param_name: str) -> float:
"""Check that value is a valid finite number. Returns float."""
if value is None:
raise InvalidParameterError(param_name, "Value must not be None")
if not isinstance(value, (int, float)):
raise InvalidParameterError(param_name, "Value must be a number")
if math.isnan(value):
raise InvalidParameterError(param_name, "Value must not be NaN")
if math.isinf(value):
raise InvalidParameterError(param_name, "Value must not be infinite")
return float(value)
@staticmethod
def validate_temperature(value: Any, param_name: str) -> float:
"""
Validate a laser temperature value.
def validate_manual(temp1: float, temp2: float, current1: float, current2: float) -> None:
for name, value in (("temp1", temp1), ("temp2", temp2)):
_range(value, name, TEMP_MIN_C, TEMP_MAX_C)
for name, value in (("current1", current1), ("current2", current2)):
_range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA)
Args:
value: Temperature in °C.
param_name: Parameter name for error messages.
Returns:
Validated temperature as float.
Raises:
InvalidParameterError: If value is not a valid number.
TemperatureOutOfRangeError: If value is outside [TEMP_MIN_C, TEMP_MAX_C].
"""
value = ParameterValidator._check_numeric(value, param_name)
if value < TEMP_MIN_C or value > TEMP_MAX_C:
raise TemperatureOutOfRangeError(
param_name, value, TEMP_MIN_C, TEMP_MAX_C
)
return value
@staticmethod
def validate_current(value: Any, param_name: str) -> float:
"""
Validate a laser drive current value.
Args:
value: Current in mA.
param_name: Parameter name for error messages.
Returns:
Validated current as float.
Raises:
InvalidParameterError: If value is not a valid number.
CurrentOutOfRangeError: If value is outside [CURRENT_MIN_MA, CURRENT_MAX_MA].
"""
value = ParameterValidator._check_numeric(value, param_name)
if value < CURRENT_MIN_MA or value > CURRENT_MAX_MA:
raise CurrentOutOfRangeError(
param_name, value, CURRENT_MIN_MA, CURRENT_MAX_MA
)
return value
@staticmethod
def validate_time_params(time_step: Any, delay_time: Any) -> Tuple[int, int]:
"""
Validate time parameters for variation mode.
Args:
time_step: Discretisation time step in microseconds.
delay_time: Delay between pulses in milliseconds.
Returns:
Tuple (time_step, delay_time) as integers.
Raises:
InvalidParameterError: If values are not numeric.
ValidationError: If values are outside allowed ranges.
"""
if not isinstance(time_step, (int, float)):
raise InvalidParameterError("time_step", "Value must be a number")
if not isinstance(delay_time, (int, float)):
raise InvalidParameterError("delay_time", "Value must be a number")
time_step_int = int(time_step)
delay_time_int = int(delay_time)
if time_step_int < TIME_STEP_MIN_US or time_step_int > TIME_STEP_MAX_US:
raise ValidationError(
f"time step {time_step_int} µs is out of range "
f"[{TIME_STEP_MIN_US} - {TIME_STEP_MAX_US}] µs"
)
if delay_time_int < DELAY_TIME_MIN_MS or delay_time_int > DELAY_TIME_MAX_MS:
raise ValidationError(
f"delay time {delay_time_int} ms is out of range "
f"[{DELAY_TIME_MIN_MS} - {DELAY_TIME_MAX_MS}] ms"
)
return time_step_int, delay_time_int
@staticmethod
def validate_variation_params(
params: Dict[str, Any],
variation_type: Any
) -> Dict[str, Any]:
"""
Validate parameters for variation mode.
Args:
params: Dictionary with keys:
min_value, max_value, step, time_step, delay_time.
variation_type: A VariationType enum value.
Returns:
Dictionary with validated and type-coerced values.
Raises:
ValidationError: For any constraint violation.
InvalidParameterError: For wrong types.
"""
# Validate variation type
if not isinstance(variation_type, VariationType):
try:
variation_type = VariationType(variation_type)
except (ValueError, KeyError):
raise ValidationError(
f"Invalid variation type '{variation_type}'. "
f"Must be one of {[e.name for e in VariationType]}"
)
# Check required keys
required_keys = {'min_value', 'max_value', 'step', 'time_step', 'delay_time'}
missing = required_keys - params.keys()
if missing:
raise ValidationError(
f"Missing required parameters: {sorted(missing)}"
)
# Validate min/max
min_val = ParameterValidator._check_numeric(params['min_value'], 'min_value')
max_val = ParameterValidator._check_numeric(params['max_value'], 'max_value')
if min_val >= max_val:
raise ValidationError(
f"min_value ({min_val}) must be less than max_value ({max_val})"
)
# Validate step based on variation type
step = ParameterValidator._check_numeric(params['step'], 'step')
is_current_variation = variation_type in (
VariationType.CHANGE_CURRENT_LD1,
VariationType.CHANGE_CURRENT_LD2
)
is_temp_variation = variation_type in (
VariationType.CHANGE_TEMPERATURE_LD1,
VariationType.CHANGE_TEMPERATURE_LD2
)
if is_current_variation:
step_min, step_max = CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA
unit = "mA"
# Also validate range against current limits
ParameterValidator.validate_current(min_val, 'min_value')
ParameterValidator.validate_current(max_val, 'max_value')
elif is_temp_variation:
step_min, step_max = TEMP_STEP_MIN_C, TEMP_STEP_MAX_C
unit = "°C"
# Also validate range against temperature limits
ParameterValidator.validate_temperature(min_val, 'min_value')
ParameterValidator.validate_temperature(max_val, 'max_value')
else:
raise ValidationError(
f"Variation type {variation_type.name} cannot be used in variation mode"
)
if step <= 0:
raise ValidationError(
f"step must be positive, got {step} {unit}"
)
if step < step_min:
raise ValidationError(
f"step {step} {unit} is too small (minimum {step_min} {unit})"
)
if step > step_max:
raise ValidationError(
f"step {step} {unit} is too large (maximum {step_max} {unit})"
)
# Validate time parameters
time_step, delay_time = ParameterValidator.validate_time_params(
params['time_step'], params['delay_time']
)
return {
'variation_type': variation_type,
'min_value': min_val,
'max_value': max_val,
'step': step,
'time_step': time_step,
'delay_time': delay_time,
}
@staticmethod
def validate_manual_mode_params(
temp1: Any,
temp2: Any,
current1: Any,
current2: Any,
) -> Dict[str, float]:
"""
Validate all four manual mode parameters.
Args:
temp1: Laser 1 temperature, °C.
temp2: Laser 2 temperature, °C.
current1: Laser 1 current, mA.
current2: Laser 2 current, mA.
Returns:
Dict with validated floats: temp1, temp2, current1, current2.
Raises:
ValidationError: For any out-of-range value.
InvalidParameterError: For wrong types.
"""
return {
'temp1': ParameterValidator.validate_temperature(temp1, 'temp1'),
'temp2': ParameterValidator.validate_temperature(temp2, 'temp2'),
'current1': ParameterValidator.validate_current(current1, 'current1'),
'current2': ParameterValidator.validate_current(current2, 'current2'),
}
def validate_variation(min_value: float, max_value: float, step: float,
time_step: int, delay_time: int) -> None:
for name, value in (("min_value", min_value), ("max_value", max_value)):
_range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA)
if min_value >= max_value:
raise ValidationError("min_value must be less than max_value")
_range(step, "step", CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA)
_range(time_step, "time_step (us)", TIME_STEP_MIN_US, TIME_STEP_MAX_US)
_range(delay_time, "delay_time (ms)", DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS)
@@ -36,7 +36,7 @@ class MultiDeviceVnaController:
self,
master_serial_number: str,
slave_serial_numbers: Sequence[str] = (),
force_external_reference: bool = True,
force_external_reference: bool = False,
switch_substage_count: int = 1,
) -> None:
"""Open all configured devices and initialize controller state.
@@ -51,7 +51,7 @@ class MultiDeviceLibreVnaService:
master_serial: str
slave_serials: list[str]
force_external_reference: bool = True
force_external_reference: bool = False
recovery_attempts: int = 3
# Stimulus switch positions stepped through WITHIN one frequency point. 1 keeps the
# stock 2-output matrix; 2 doubles the outputs (4) without a second sweep, at the
@@ -0,0 +1 @@
"""L-Card acquisition, reference-phase processing and laser configuration."""
@@ -0,0 +1,37 @@
"""Apply constant laser settings or LD1 current variation before ADC acquisition."""
from dataclasses import asdict
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.models.run_config_schema import RunConfigModel
def apply_rph_radar_laser_control(config: RunConfigModel) -> bool:
"""Validate every command before connecting/resetting; leave a started sweep running."""
laser = config.radar.laser_control
if not laser.enabled:
return False
if not laser.port:
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
mode = laser.mode.strip().lower()
if mode not in {"manual", "variation"}:
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
pi = {name: getattr(laser, name) for name in ("pi_coeff1_p", "pi_coeff1_i", "pi_coeff2_p", "pi_coeff2_i")}
manual = (asdict(laser.manual) if mode == "manual" else
{name: getattr(laser.variation, "static_" + name) for name in ("temp1", "temp2", "current1", "current2")})
variation = asdict(laser.variation)
Protocol.encode_decode_enable(**manual, **pi, message_id=DEVICE_MAIN_MESSAGE_ID)
if mode == "variation":
Protocol.encode_task_enable(**variation, **pi)
controller = LaserController(port=laser.port, **pi)
try:
controller.connect()
controller.reset()
controller.set_manual_mode(**manual, message_id=DEVICE_MAIN_MESSAGE_ID)
if mode == "variation":
controller.start_variation(params=variation)
return True
finally:
controller.disconnect()
@@ -1,8 +1,8 @@
"""Signal processing for the Kamil ADC reference-channel acquisition.
"""Signal processing for the RadioPhotonic radar (RPH) reference-channel acquisition.
Each sweep arrives as two aligned complex arrays — the *main* signal and a
*reference* signal — sampled at the same step indices (see
:mod:`python_app.hardware_full.kamil_adc.protocol`). Turning that into a stable,
:mod:`python_app.hardware_full.rph_radar.protocol`). Turning that into a stable,
comparable S21 trace is a fixed three-stage pipeline:
1. **Frequency axis from the reference phase.** The reference arm has a constant
@@ -41,12 +41,13 @@ Everything here is pure and free of I/O so it can be unit-tested in isolation.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import replace
import numpy as np
from numpy.typing import NDArray
from python_app.models.run_config_schema import KamilAdcModel
from python_app.models.run_config_schema import RphRadarModel
from python_app.models.run_config_validation import validate_rph_radar_model
# A reference sample whose magnitude falls to (effectively) zero carries no usable
# amplitude or phase; such points are dropped before normalization rather than
@@ -61,94 +62,28 @@ _MIN_USABLE_POINTS = 2
# One full turn of reference phase. ``np.unwrap`` anchors each sweep's phase ramp
# to the raw angle of the first sample on the (-pi, pi] branch, so a stray sweep
# whose anchor crossed the +/-pi cut is offset by exactly this; the cross-sweep
# anchor tracking snaps it back (see ``KamilAdcSweepProcessor._align_phase_branch``).
# anchor tracking snaps it back (see ``RphRadarSweepProcessor._align_phase_branch``).
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
@dataclass(frozen=True, slots=True)
class KamilAdcProcessingParams:
"""Immutable phase→frequency calibration and output-grid definition.
Built from :class:`~python_app.models.run_config_schema.KamilAdcModel` via
:meth:`from_kamil_model`, but kept decoupled from the config types so the
processing can be exercised with plain numbers in tests.
"""
phase0_rad: float
freq0_hz: float
phase1_rad: float
freq1_hz: float
band_start_hz: float
band_stop_hz: float
band_points: int
reference_amplitude_floor: float = _REFERENCE_AMPLITUDE_FLOOR
def __post_init__(self) -> None:
if not np.isfinite([self.phase0_rad, self.phase1_rad, self.freq0_hz, self.freq1_hz]).all():
raise ValueError("Kamil ADC phase calibration anchors must be finite")
if self.phase0_rad == self.phase1_rad:
raise ValueError("Kamil ADC phase calibration anchors must use distinct phases")
if self.freq0_hz == self.freq1_hz:
raise ValueError("Kamil ADC phase calibration anchors must map to distinct frequencies")
if not (np.isfinite(self.band_start_hz) and np.isfinite(self.band_stop_hz)):
raise ValueError("Kamil ADC band edges must be finite")
if self.band_stop_hz <= self.band_start_hz:
raise ValueError("Kamil ADC band stop_hz must be greater than start_hz")
if self.band_points < 2:
raise ValueError("Kamil ADC band points must be >= 2")
if self.reference_amplitude_floor <= 0.0:
raise ValueError("Kamil ADC reference amplitude floor must be > 0")
@classmethod
def from_kamil_model(cls, kamil_adc: KamilAdcModel) -> KamilAdcProcessingParams:
"""Build parameters from the ``radar.kamil_adc`` config section."""
calibration = kamil_adc.phase_calibration
band = kamil_adc.band
return cls(
phase0_rad=float(calibration.phase0_rad),
freq0_hz=float(calibration.freq0_hz),
phase1_rad=float(calibration.phase1_rad),
freq1_hz=float(calibration.freq1_hz),
band_start_hz=float(band.start_hz),
band_stop_hz=float(band.stop_hz),
band_points=int(band.points),
)
@property
def hz_per_rad(self) -> float:
"""Frequency change per radian of reference phase (the calibration slope)."""
return (self.freq1_hz - self.freq0_hz) / (self.phase1_rad - self.phase0_rad)
class KamilAdcSweepProcessor:
class RphRadarSweepProcessor:
"""Turns aligned (main, reference) sweeps into S21 traces on a fixed grid.
The output grid is computed once from the parameters and reused for every
sweep, so all traces this processor emits share one identical frequency axis.
"""
__slots__ = ("_params", "_grid_hz", "_previous_anchor_rad")
def __init__(self, params: KamilAdcProcessingParams) -> None:
self._params = params
self._grid_hz = np.linspace(
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
)
# Absolute (branch-tracked) reference phase of the last ACCEPTED sweep's
# first sample, carried across sweeps so a +/-pi anchor wrap can be undone.
# ``None`` until the first sweep is accepted; reset by building a new
# processor (i.e. on reconfigure).
def __init__(self, config: RphRadarModel) -> None:
validate_rph_radar_model(config)
self._calibration = replace(config.phase_calibration)
self._band = replace(config.band)
self._hz_per_rad = ((self._calibration.freq1_hz - self._calibration.freq0_hz)
/ (self._calibration.phase1_rad - self._calibration.phase0_rad))
self._grid_hz = np.linspace(self._band.start_hz, self._band.stop_hz, self._band.points)
self.grid_hz = self._grid_hz.astype(np.float32)
self.grid_hz.setflags(write=False)
self._previous_anchor_rad: float | None = None
@property
def params(self) -> KamilAdcProcessingParams:
return self._params
@property
def grid_hz(self) -> np.ndarray:
"""The fixed output frequency axis (float32), identical for every sweep."""
return self._grid_hz.astype(np.float32)
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
@@ -161,7 +96,7 @@ class KamilAdcSweepProcessor:
def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray:
"""Apply the affine ``phase -> frequency`` calibration law."""
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
return self._calibration.freq0_hz + (phase - self._calibration.phase0_rad) * self._hz_per_rad
def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]:
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
@@ -182,7 +117,7 @@ class KamilAdcSweepProcessor:
reference_anchor = (
self._previous_anchor_rad
if self._previous_anchor_rad is not None
else self._params.phase0_rad
else self._calibration.phase0_rad
)
turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD)
if turns:
@@ -213,7 +148,7 @@ class KamilAdcSweepProcessor:
with np.errstate(divide="ignore", invalid="ignore"):
s21 = main / reference_amplitude
usable = (
(reference_amplitude > self._params.reference_amplitude_floor)
(reference_amplitude > _REFERENCE_AMPLITUDE_FLOOR)
& np.isfinite(freqs)
& np.isfinite(s21.real)
& np.isfinite(s21.imag)
@@ -232,7 +167,7 @@ class KamilAdcSweepProcessor:
# Reject sweeps that do not span the whole band: interpolating past the
# measured edge would inject flat, non-physical points.
if freqs[0] > self._params.band_start_hz or freqs[-1] < self._params.band_stop_hz:
if freqs[0] > self._band.start_hz or freqs[-1] < self._band.stop_hz:
return None
# The sweep is accepted: commit its branch-aligned anchor so the next
@@ -0,0 +1,89 @@
"""Whole-sweep RPH1 packets from the collector's PTY (little endian).
The 48-byte header carries payload size, source sweep/cycle IDs, monotonic capture
window, input/output (65535 for untagged) and dirty flag. Payload points are
8-byte ``marker, step, I, Q`` frames: A for main and A8 for reference.
Only complete queued packets may be dropped; a packet already being written is
always finished. Thus overflow cannot splice two physical sweeps together.
"""
from dataclasses import dataclass
import struct
import numpy as np
from numpy.typing import NDArray
MAIN_MARKER = 0x000A
REFERENCE_MARKER = 0x00A8
_HEADER = struct.Struct("<4sIQQQQHHI")
_POINT = struct.Struct("<HHhh")
_MAX_PAYLOAD_BYTES = 65534 * 2 * _POINT.size
@dataclass(frozen=True, slots=True)
class RawSweep:
steps: NDArray[np.int32]
main: NDArray[np.complex64]
reference: NDArray[np.complex64]
source_id: int
cycle_id: int
capture_start_ns: int
capture_end_ns: int
combo: tuple[int, int] | None = None
dirty: bool = False
@property
def size(self) -> int:
return int(self.steps.size)
class RphRadarStreamParser:
"""Incremental packet parser; corrupt headers or point markers fail closed."""
def __init__(self) -> None:
self._buffer = bytearray()
self._aligned = False
def reset(self) -> None:
self._buffer.clear()
self._aligned = False
def feed(self, data: bytes) -> list[RawSweep]:
self._buffer.extend(data)
if not self._aligned:
index = self._buffer.find(b"RPH1")
if index < 0:
del self._buffer[:-3]
return []
del self._buffer[:index]
self._aligned = True
sweeps = []
while len(self._buffer) >= _HEADER.size:
magic, size, source_id, cycle_id, start, end, inp, out, flags = _HEADER.unpack_from(self._buffer)
if (magic != b"RPH1" or size > _MAX_PAYLOAD_BYTES or size % _POINT.size
or not source_id or not cycle_id or not 0 < start <= end
or flags & ~1 or (inp == 65535) != (out == 65535)):
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid sweep header")
packet_size = _HEADER.size + size
if len(self._buffer) < packet_size:
break
main: dict[int, complex] = {}
reference: dict[int, complex] = {}
for marker, step, real, imag in _POINT.iter_unpack(self._buffer[_HEADER.size:packet_size]):
if marker not in (MAIN_MARKER, REFERENCE_MARKER) or not 0 < step < 65535:
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid point frame")
target = main if marker == MAIN_MARKER else reference
if step in target:
raise ValueError("RadioPhotonic radar (RPH) protocol violation: duplicate point")
target[step] = complex(real, imag)
del self._buffer[:packet_size]
shared = sorted(main.keys() & reference.keys())
if shared:
sweeps.append(RawSweep(
np.asarray(shared, dtype=np.int32),
np.asarray([main[step] for step in shared], dtype=np.complex64),
np.asarray([reference[step] for step in shared], dtype=np.complex64),
source_id, cycle_id, start, end,
None if inp == 65535 else (inp, out), bool(flags & 1),
))
return sweeps
@@ -1,14 +1,15 @@
"""Service that launches the Kamil ADC collector and serves processed sweeps.
"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed sweeps.
Owns the lifecycle of the external collector process and its TTY reader, and maps
each raw (main, reference) sweep to an :class:`SweepResult` on the fixed
processing grid via :class:`KamilAdcSweepProcessor`.
processing grid via :class:`RphRadarSweepProcessor`.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
import json
import logging
import os
from pathlib import Path
@@ -20,23 +21,21 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.kamil_adc.protocol import RawSweep
from python_app.hardware_full.kamil_adc.tty_reader import (
KamilAdcTtyReader,
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
from python_app.hardware_full.rph_radar.protocol import RawSweep
from python_app.hardware_full.rph_radar.tty_reader import (
RphRadarTtyReader,
raise_if_process_exited,
)
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.dataset_model import ComboKey
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
from python_app.models.run_config_validation import validate_rph_radar_model
logger = logging.getLogger(__name__)
# Project root, used to resolve relative collector paths (e.g.
# ``build/bin/kamil_adc_collector``) independent of the launching CWD.
# ``build/bin/rph_radar_collector``) independent of the launching CWD.
_REPO_ROOT = Path(__file__).resolve().parents[3]
# Default home of the proprietary L-Card runtime libraries the collector loads
# via dlopen. Prepended to LD_LIBRARY_PATH unless the config pins it explicitly.
@@ -48,41 +47,36 @@ _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)
class KamilAdcService:
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
class RphRadarService:
"""Launch the external RadioPhotonic radar (RPH) 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
collector_driven: bool = False
_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)
_reader: RphRadarTtyReader | None = field(init=False, default=None, repr=False)
_processor: RphRadarSweepProcessor | None = field(init=False, default=None, repr=False)
_rejected_count: int = field(init=False, default=0, repr=False)
_prepared_combo: ComboKey | None = field(init=False, default=None, repr=False)
last_cycle_id: int = field(init=False, default=0)
_output_thread: threading.Thread | None = field(init=False, default=None, repr=False)
_stop_event: threading.Event | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@property
def command(self) -> list[str]:
"""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
"""Pass the current typed settings once; the collector never rereads the profile."""
adc = self.config.radar.rph_radar
cmd = [str(self._resolve_executable()), *adc.args]
if self.switch_config_path is not None:
cmd.append(f"config:{self.switch_config_path}")
if self.collector_driven:
switching = {"input": asdict(self.config.input_switch), "output": asdict(self.config.output_switch),
"combos": [[combo.input, combo.output] for combo in self.config.combos],
"settling_ms": self.config.runtime.settling_ms}
cmd.append("switching:" + json.dumps(switching, separators=(",", ":")))
cmd.append(f"tty:{adc.tty_path}")
return cmd
@@ -94,51 +88,57 @@ class KamilAdcService:
"""
if self._reader is not None:
return
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
if self._process is not None and self._process.poll() is None:
raise RuntimeError("Previous RPH collector did not stop; cannot start a fresh capture")
self._stop_event = stop_event
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.rph_radar.tty_path)
try:
self._start_process()
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
reader = RphRadarTtyReader(self.config.radar.rph_radar.tty_path)
reader.open()
self._reader = reader
logger.info("Kamil ADC service opened")
logger.info("RadioPhotonic radar (RPH) service opened")
except Exception:
logger.exception("Kamil ADC service failed to open; cleaning up")
logger.exception("RadioPhotonic radar (RPH) service failed to open; cleaning up")
self.close()
raise
def close(self) -> None:
"""Stop the TTY reader and the external collector process."""
logger.debug("Closing Kamil ADC service")
logger.debug("Closing RadioPhotonic radar (RPH) service")
self._prepared_combo = None
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
if self._output_thread is not None:
self._output_thread.join(timeout=1.0)
self._output_thread = None
def configure(self, sweep: RadarSweepModel) -> None:
"""Build the sweep processor from the ``radar.kamil_adc`` calibration/band.
"""Build the sweep processor from the ``radar.rph_radar`` calibration/band.
The generic ``sweep`` argument is accepted for interface parity with the
other radar services but is not used: the Kamil ADC frequency axis comes
other radar services but is not used: the RadioPhotonic radar (RPH) frequency axis comes
from the reference-phase calibration, and the output grid from
``radar.kamil_adc.band`` — never from the nominal sweep bounds.
``radar.rph_radar.band`` — never from the nominal sweep bounds.
"""
self._processor = KamilAdcSweepProcessor(
KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc)
)
adc = self.config.radar.rph_radar
self._processor = RphRadarSweepProcessor(adc)
self._rejected_count = 0
params = self._processor.params
phase, band = adc.phase_calibration, adc.band
logger.debug(
"Kamil ADC configured: band %.6g-%.6g Hz, %d points; calibration "
"RadioPhotonic radar (RPH) configured: band %.6g-%.6g Hz, %d points; calibration "
"(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)",
params.band_start_hz, params.band_stop_hz, params.band_points,
params.phase0_rad, params.freq0_hz, params.phase1_rad, params.freq1_hz,
band.start_hz, band.stop_hz, band.points,
phase.phase0_rad, phase.freq0_hz, phase.phase1_rad, phase.freq1_hz,
)
def read_device_limits(self) -> dict[str, float | int]:
"""Kamil ADC has no runtime-readable sweep-limit API."""
raise RuntimeError("Kamil ADC device limits are not available")
"""RadioPhotonic radar (RPH) has no runtime-readable sweep-limit API."""
raise RuntimeError("RadioPhotonic radar (RPH) device limits are not available")
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
"""Return the next sweep that covers the band, as S21 on the fixed grid.
@@ -153,65 +153,45 @@ class KamilAdcService:
single newest sweep is returned regardless of combination.
"""
if self._processor is None:
raise RuntimeError("Kamil ADC service is not configured")
raise RuntimeError("RadioPhotonic radar (RPH) service is not configured")
if self._reader is None:
raise RuntimeError("Kamil ADC service is not open")
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
process = self._process
if process is None or process.poll() is not None:
return_code = None if process is None else process.poll()
raise RuntimeError(f"Kamil ADC collector is not running (code={return_code})")
raise RuntimeError(f"RadioPhotonic radar (RPH) collector is not running (code={return_code})")
grid = self._processor.grid_hz
points = int(grid.size)
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
deadline = time.monotonic() + self.config.radar.rph_radar.sweep_timeout_s
while True:
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
"Timed out waiting for a Kamil ADC sweep covering the configured band"
"Timed out waiting for a RadioPhotonic radar (RPH) sweep covering the configured band"
)
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)
raw = self._reader.read_sweep(timeout_s=remaining_s, combo=combo, process=process)
s21 = self._processor.process(raw.main, raw.reference)
if s21 is not None:
self.last_cycle_id = raw.cycle_id
return SweepResult(
x=grid.copy(),
x=grid,
traces={
"s21": s21,
},
capture_start_ns=raw.capture_start_ns,
capture_end_ns=raw.capture_end_ns,
)
self._log_rejected_sweep(raw)
def prepare_combo(self, combo: ComboKey) -> None:
"""Discard buffered sweeps that predate or straddle the physical switch."""
self.drain_after_switch()
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:
"""Start a fresh ADC session after switching; retain phase branch tracking."""
if self._prepared_combo == combo:
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)
self.close()
if self.config.runtime.settling_ms < 1:
time.sleep(.001) # The caller already waited any configured settling.
self.open(stop_event=self._stop_event)
self._prepared_combo = combo
def read_raw_sweep(self) -> RawSweep:
"""Return the next raw (main, reference) sweep without any processing.
@@ -221,9 +201,9 @@ class KamilAdcService:
unprocessed reference samples. Raises if the service is not open.
"""
if self._reader is None:
raise RuntimeError("Kamil ADC service is not open")
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
return self._reader.read_sweep(
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
timeout_s=self.config.radar.rph_radar.sweep_timeout_s,
process=self._process,
)
@@ -234,34 +214,45 @@ class KamilAdcService:
def _start_process(self) -> None:
if self._process is not None and self._process.poll() is None:
return
logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
logger.info("Starting RadioPhotonic radar (RPH) collector: %s", " ".join(self.command))
self._process = subprocess.Popen(
self.command,
cwd=str(self._resolve_project_dir()),
env=self._build_env(),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
self._output_thread = threading.Thread(target=self._log_output, args=(self._process,),
name="rph-radar-log", daemon=True)
self._output_thread.start()
@staticmethod
def _log_output(process: subprocess.Popen[bytes]) -> None:
if process.stdout is not None:
with process.stdout:
for line in process.stdout:
logger.info("Collector: %s", line.decode("utf-8", errors="replace").rstrip())
def _stop_process(self) -> None:
process = self._process
self._process = None
if process is None or process.poll() is not None:
self._process = None
return
logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid)
logger.info("Stopping RadioPhotonic radar (RPH) collector (pid=%d)", process.pid)
# The collector's graceful X502 teardown can block, so give it only a brief
# window to release the device cleanly, then SIGKILL the whole group hard.
grace_s = min(self.config.radar.kamil_adc.stop_timeout_s, _STOP_KILL_GRACE_S)
grace_s = min(self.config.radar.rph_radar.stop_timeout_s, _STOP_KILL_GRACE_S)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=grace_s)
self._process = None
return
except subprocess.TimeoutExpired:
pass
logger.warning("Kamil ADC collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
logger.warning("RadioPhotonic radar (RPH) collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
# close() must never raise: a collector wedged in uninterruptible I/O
@@ -269,6 +260,9 @@ class KamilAdcService:
# window even after SIGKILL. Best-effort wait; the OS reaps it eventually.
with suppress(subprocess.TimeoutExpired):
process.wait(timeout=1.0)
# Retain ownership in D-state so retries cannot launch a second collector.
if process.poll() is not None:
self._process = None
def _wait_for_tty(
self,
@@ -276,22 +270,22 @@ class KamilAdcService:
*,
stop_event: threading.Event | None = None,
) -> None:
adc = self.config.radar.kamil_adc
adc = self.config.radar.rph_radar
deadline = time.monotonic() + adc.startup_timeout_s
while time.monotonic() < deadline:
if stop_event is not None and stop_event.is_set():
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
raise_if_process_exited(self._process)
identity = _tty_identity(adc.tty_path)
if identity is not None and identity != previous_identity:
return
if stop_event is not None:
if stop_event.wait(0.05):
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
else:
time.sleep(0.05)
raise TimeoutError(
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
f"Timed out waiting for RadioPhotonic radar (RPH) TTY `{adc.tty_path}` to be created by the collector"
)
# ------------------------------------------------------------------
@@ -299,10 +293,10 @@ class KamilAdcService:
# ------------------------------------------------------------------
def _resolve_executable(self) -> Path:
return self._resolve_path(self.config.radar.kamil_adc.executable_path)
return self._resolve_path(self.config.radar.rph_radar.executable_path)
def _resolve_project_dir(self) -> Path:
project_dir = self.config.radar.kamil_adc.project_dir
project_dir = self.config.radar.rph_radar.project_dir
return self._resolve_path(project_dir) if project_dir else _REPO_ROOT
@staticmethod
@@ -318,7 +312,7 @@ class KamilAdcService:
``LD_LIBRARY_PATH`` itself, the default L-Card library directory is
prepended so the collector's dlopen of libx502api/libe502api succeeds.
"""
adc = self.config.radar.kamil_adc
adc = self.config.radar.rph_radar
env = os.environ.copy()
for key, value in adc.env.items():
env[key] = os.path.expandvars(os.path.expanduser(value))
@@ -333,33 +327,29 @@ class KamilAdcService:
# ------------------------------------------------------------------
def _validate_config(self) -> None:
if not self.config.is_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
if not self.config.is_rph_radar:
raise RuntimeError("RphRadarService requires radar.model='rph_radar'")
if self.config.radar.driver_mode != "native":
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
adc = self.config.radar.kamil_adc
adc = self.config.radar.rph_radar
validate_rph_radar_model(adc)
if not adc.executable_path:
raise ValueError("radar.kamil_adc.executable_path is required")
raise ValueError("radar.rph_radar.executable_path is required")
if not adc.tty_path:
raise ValueError("radar.kamil_adc.tty_path is required")
raise ValueError("radar.rph_radar.tty_path is required")
if any(arg.startswith("tty:") for arg in adc.args):
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
if getattr(adc, name) <= 0.0:
raise ValueError(f"radar.kamil_adc.{name} must be > 0")
raise ValueError("radar.rph_radar.args must not contain tty:<path>; use tty_path instead")
project_dir = self._resolve_project_dir()
if not project_dir.is_dir():
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
raise RuntimeError(f"radar.rph_radar.project_dir is not a directory: {project_dir}")
executable_path = self._resolve_executable()
if not executable_path.is_file():
raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
raise RuntimeError(f"radar.rph_radar.executable_path is not a file: {executable_path}")
if not os.access(executable_path, os.X_OK):
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
raise RuntimeError(f"radar.rph_radar.executable_path is not executable: {executable_path}")
# Surface a malformed calibration/band at open time, not mid-acquisition.
KamilAdcProcessingParams.from_kamil_model(adc)
def _log_rejected_sweep(self, raw) -> None:
"""Log a band-coverage rejection (throttled) with the measured span."""
@@ -367,16 +357,16 @@ class KamilAdcService:
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
return
assert self._processor is not None
params = self._processor.params
band = self.config.radar.rph_radar.band
try:
freqs = self._processor.reference_frequency_axis(raw.reference)
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
except Exception: # noqa: BLE001 — diagnostics must never raise
covered = "<unavailable>"
logger.warning(
"Kamil ADC sweep rejected (count=%d): covered %s Hz does not span band "
"RadioPhotonic radar (RPH) sweep rejected (count=%d): covered %s Hz does not span band "
"[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.",
self._rejected_count, covered, params.band_start_hz, params.band_stop_hz, raw.size,
self._rejected_count, covered, band.start_hz, band.stop_hz, raw.size,
)
@@ -1,8 +1,8 @@
"""Background TTY reader publishing the latest completed Kamil ADC sweep.
"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep.
The collector emits sweeps continuously, faster than callers invoke
:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the
TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, and stores the
:meth:`RphRadarService.acquire`. A daemon thread drains the device end of the
TTY non-stop, feeds the bytes to a :class:`RphRadarStreamParser`, and stores the
most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep`
returns the freshest sweep; if a newer one arrives before the consumer reads, it
overwrites the previous unread value — by design, since consumers always want the
@@ -21,7 +21,7 @@ import subprocess
import threading
import time
from python_app.hardware_full.kamil_adc.protocol import KamilAdcStreamParser, RawSweep
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser, RawSweep
logger = logging.getLogger(__name__)
@@ -39,11 +39,11 @@ def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
return
return_code = process.poll()
if return_code is not None:
raise RuntimeError(f"Kamil ADC collector exited with code {return_code}")
raise RuntimeError(f"RadioPhotonic radar (RPH) collector exited with code {return_code}")
@dataclass(slots=True)
class KamilAdcTtyReader:
class RphRadarTtyReader:
"""Daemon-thread TTY reader publishing the latest completed sweep."""
tty_path: str
@@ -54,7 +54,7 @@ class KamilAdcTtyReader:
_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.
# non-switch mode); an optional combo selects its own slot.
_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)
@@ -71,7 +71,7 @@ class KamilAdcTtyReader:
self._published_count = 0
self._thread = threading.Thread(
target=self._reader_loop,
name=f"kamil-adc-tty-reader[{self.tty_path}]",
name=f"rph-radar-tty-reader[{self.tty_path}]",
daemon=True,
)
try:
@@ -81,18 +81,18 @@ class KamilAdcTtyReader:
fd, self._fd = self._fd, None
os.close(fd)
raise
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
logger.info("RadioPhotonic radar (RPH) TTY reader started on %s", self.tty_path)
def close(self) -> None:
"""Stop the reader thread and close the TTY descriptor."""
logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path)
logger.debug("Stopping RadioPhotonic radar (RPH) TTY reader on %s", self.tty_path)
self._stop_event.set()
with self._mailbox_cv:
self._mailbox_cv.notify_all()
if self._thread is not None:
self._thread.join(timeout=1.0)
if self._thread.is_alive():
logger.warning("Kamil ADC reader thread did not stop within 1.0s")
logger.warning("RadioPhotonic radar (RPH) reader thread did not stop within 1.0s")
self._thread = None
if self._fd is not None:
try:
@@ -113,6 +113,7 @@ class KamilAdcTtyReader:
self,
*,
timeout_s: float,
combo: tuple[int, int] | None = None,
process: subprocess.Popen[bytes] | None = None,
) -> RawSweep:
"""Wait for and return the next published sweep.
@@ -122,52 +123,19 @@ class KamilAdcTtyReader:
error caught by the reader thread.
"""
if self._thread is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is not open")
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
if self._stop_event.is_set():
raise RuntimeError("Kamil ADC TTY reader is closed")
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is closed")
# Deliver a pending sweep first: if the reader both published a
# sweep and then died, the consumer still sees the good data and
# only meets the error on the next call.
if self._latest_sweep is not None:
sweep = self._latest_sweep
self._latest_sweep = 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 after {float(timeout_s):.3f}s"
)
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:
if self._stop_event.is_set():
raise RuntimeError("Kamil ADC TTY reader is closed")
sweep = self._combo_slots.pop(combo, None)
sweep = self._latest_sweep if combo is None else self._combo_slots.pop(combo, None)
if sweep is not None:
if combo is None:
self._latest_sweep = None
return sweep
if self._reader_error is not None:
raise self._reader_error
@@ -175,18 +143,13 @@ class KamilAdcTtyReader:
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"
f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s"
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
def _reader_loop(self) -> None:
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
parser = KamilAdcStreamParser()
parser = RphRadarStreamParser()
try:
while not self._stop_event.is_set():
chunk = self._read_available()
@@ -195,7 +158,7 @@ class KamilAdcTtyReader:
for sweep in parser.feed(chunk):
self._publish_sweep(sweep)
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
logger.exception("Kamil ADC reader thread failed on %s", self.tty_path)
logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.tty_path)
self._publish_error(exc)
def _read_available(self) -> bytes:
@@ -220,9 +183,9 @@ class KamilAdcTtyReader:
except OSError as exc:
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
return b""
raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) TTY `{self.tty_path}`: {exc}") from exc
if not chunk:
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
raise RuntimeError(f"RadioPhotonic radar (RPH) TTY `{self.tty_path}` closed while reading")
return chunk
def _publish_sweep(self, sweep: RawSweep) -> None:
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Protocol
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.rph_radar.service import RphRadarService
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.models.dataset_model import ComboKey
@@ -55,9 +55,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
return PointTargetMockService(config)
return LibreVnaService(serial=config.radar.serial or None)
if model == RunConfigModel.KAMIL_ADC_MODEL:
if model == RunConfigModel.RPH_RADAR_MODEL:
if config.radar.driver_mode != "native":
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
return KamilAdcService(config)
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
return RphRadarService(config)
raise RuntimeError(f"Unsupported single-radar model: {model}")