Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user