Rework radar system
This commit is contained in:
@@ -14,13 +14,11 @@ 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
|
||||
GUI_POLL_INTERVAL_MS = 5 # GUI event loop timeout
|
||||
|
||||
# ---- Command codes (as sent to device, already flipped to LE)
|
||||
|
||||
CMD_DECODE_ENABLE = 0x1111 # Set control parameters
|
||||
CMD_DEFAULT_ENABLE = 0x2222 # Reset device
|
||||
CMD_TRANSS_ENABLE = 0x3333 # Request all saved data (not implemented)
|
||||
CMD_TRANS_ENABLE = 0x4444 # Request last data
|
||||
CMD_REMOVE_FILE = 0x5555 # Delete saved data
|
||||
CMD_STATE = 0x6666 # Request state
|
||||
@@ -54,7 +52,6 @@ VREF = 2.5 # Reference voltage, Volts
|
||||
|
||||
# Bridge resistors for temperature measurement
|
||||
R1 = 10000 # Ohm
|
||||
R2 = 2200 # Ohm
|
||||
R3 = 27000 # Ohm
|
||||
R4 = 30000 # Ohm
|
||||
R5 = 27000 # Ohm
|
||||
@@ -105,17 +102,3 @@ TIME_STEP_MAX_US = 100 # Maximum time step, microseconds
|
||||
|
||||
DELAY_TIME_MIN_MS = 3 # Minimum delay between pulses, milliseconds
|
||||
DELAY_TIME_MAX_MS = 10 # Maximum delay between pulses, milliseconds
|
||||
|
||||
# ---- Acceptable voltage tolerances for power rail health check
|
||||
|
||||
VOLT_3V3_MIN = 3.1
|
||||
VOLT_3V3_MAX = 3.5
|
||||
VOLT_5V_MIN = 4.8
|
||||
VOLT_5V_MAX = 5.3
|
||||
VOLT_7V_MIN = 6.5
|
||||
VOLT_7V_MAX = 7.5
|
||||
|
||||
# ---- Data buffer limits
|
||||
|
||||
MAX_DATA_POINTS = 1000 # Max stored measurement points
|
||||
PLOT_POINTS = 100 # Points shown in real-time plots
|
||||
@@ -13,18 +13,12 @@ from typing import Optional, Callable
|
||||
from .protocol import Protocol, TaskType as ProtoTaskType
|
||||
from .validators import ParameterValidator
|
||||
from .models import (
|
||||
ManualModeParams,
|
||||
VariationParams,
|
||||
VariationType,
|
||||
Measurements,
|
||||
DeviceStatus,
|
||||
DeviceState,
|
||||
)
|
||||
from .exceptions import (
|
||||
ValidationError,
|
||||
CommunicationError,
|
||||
DeviceNotRespondingError,
|
||||
DeviceStateError,
|
||||
)
|
||||
from .constants import WAIT_AFTER_SEND_SEC
|
||||
|
||||
@@ -301,38 +295,6 @@ class LaserController:
|
||||
|
||||
return measurements
|
||||
|
||||
def get_status(self) -> DeviceStatus:
|
||||
"""
|
||||
Request and return the current device status.
|
||||
|
||||
Returns:
|
||||
:class:`DeviceStatus` with state and latest measurements.
|
||||
|
||||
Raises:
|
||||
CommunicationError: On transport errors.
|
||||
"""
|
||||
cmd = Protocol.encode_state()
|
||||
self._send(cmd)
|
||||
|
||||
raw = self._protocol.receive_raw(2)
|
||||
if not raw or len(raw) < 2:
|
||||
raise DeviceNotRespondingError()
|
||||
|
||||
state_code = Protocol.decode_state(raw)
|
||||
|
||||
# Try to get measurements as well
|
||||
measurements = self._last_measurements
|
||||
|
||||
return DeviceStatus(
|
||||
state=DeviceState(state_code) if state_code in DeviceState._value2member_map_
|
||||
else DeviceState.ERROR,
|
||||
measurements=measurements,
|
||||
is_connected=self.is_connected,
|
||||
last_command_id=self._message_id,
|
||||
error_message=Protocol.state_to_description(f"{state_code:04x}")
|
||||
if state_code != 0 else None,
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Send a hardware reset command to the device."""
|
||||
cmd = Protocol.encode_default_enable()
|
||||
|
||||
@@ -60,7 +60,7 @@ class CommunicationError(LaserControlError):
|
||||
class PortNotFoundError(CommunicationError):
|
||||
"""Exception raised when serial port cannot be found."""
|
||||
|
||||
def __init__(self, port: str = None):
|
||||
def __init__(self, port: str | None = None):
|
||||
if port:
|
||||
message = f"Serial port '{port}' not found"
|
||||
else:
|
||||
@@ -77,7 +77,7 @@ class PortBusyError(CommunicationError):
|
||||
of silently sharing the bus and corrupting both sides' traffic.
|
||||
"""
|
||||
|
||||
def __init__(self, port: str = None):
|
||||
def __init__(self, port: str | None = None):
|
||||
target = f"'{port}'" if port else "the laser controller port"
|
||||
super().__init__(
|
||||
f"Serial port {target} is busy — another program is already using "
|
||||
@@ -89,7 +89,7 @@ class PortBusyError(CommunicationError):
|
||||
class DeviceNotRespondingError(CommunicationError):
|
||||
"""Exception raised when device doesn't respond to commands."""
|
||||
|
||||
def __init__(self, timeout: float = None):
|
||||
def __init__(self, timeout: float | None = None):
|
||||
if timeout:
|
||||
message = f"Device did not respond within {timeout} seconds"
|
||||
else:
|
||||
@@ -100,7 +100,7 @@ class DeviceNotRespondingError(CommunicationError):
|
||||
class CRCError(CommunicationError):
|
||||
"""Exception raised when CRC check fails."""
|
||||
|
||||
def __init__(self, expected: int = None, received: int = None):
|
||||
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:
|
||||
@@ -113,45 +113,3 @@ class ProtocolError(CommunicationError):
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(f"Protocol error: {message}")
|
||||
|
||||
|
||||
class DeviceError(LaserControlError):
|
||||
"""Base exception for device-level errors."""
|
||||
pass
|
||||
|
||||
|
||||
class DeviceOverheatingError(DeviceError):
|
||||
"""Exception raised when device reports overheating."""
|
||||
|
||||
def __init__(self, laser_id: int = None, temperature: float = None):
|
||||
if laser_id and temperature:
|
||||
message = f"Laser {laser_id} overheating: {temperature}°C"
|
||||
else:
|
||||
message = "Device overheating detected"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PowerSupplyError(DeviceError):
|
||||
"""Exception raised when power supply issues are detected."""
|
||||
|
||||
def __init__(self, rail: str = None, voltage: float = None, expected: float = None):
|
||||
if rail and voltage is not None:
|
||||
if expected:
|
||||
message = f"Power supply {rail}: {voltage}V (expected ~{expected}V)"
|
||||
else:
|
||||
message = f"Power supply {rail}: abnormal voltage {voltage}V"
|
||||
else:
|
||||
message = "Power supply error detected"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class DeviceStateError(DeviceError):
|
||||
"""Exception raised when device is in an error state."""
|
||||
|
||||
def __init__(self, state_code: int, state_name: str = None):
|
||||
self.state_code = state_code
|
||||
if state_name:
|
||||
message = f"Device error state: {state_name} (0x{state_code:04X})"
|
||||
else:
|
||||
message = f"Device error state: 0x{state_code:04X}"
|
||||
super().__init__(message)
|
||||
@@ -20,77 +20,6 @@ class VariationType(IntEnum):
|
||||
CHANGE_TEMPERATURE_LD2 = 0x04
|
||||
|
||||
|
||||
class DeviceState(IntEnum):
|
||||
"""Device operational states."""
|
||||
IDLE = 0x0000
|
||||
RUNNING = 0x0001
|
||||
BUSY = 0x0002
|
||||
ERROR = 0x00FF
|
||||
ERROR_OVERHEAT = 0x0100
|
||||
ERROR_POWER = 0x0200
|
||||
ERROR_COMMUNICATION = 0x0400
|
||||
ERROR_INVALID_COMMAND = 0x0800
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualModeParams:
|
||||
"""Parameters for manual control mode."""
|
||||
temp1: float # Temperature for laser 1 (°C)
|
||||
temp2: float # Temperature for laser 2 (°C)
|
||||
current1: float # Current for laser 1 (mA)
|
||||
current2: float # Current for laser 2 (mA)
|
||||
pi_coeff1_p: float = 1.0 # PI controller proportional coefficient for laser 1
|
||||
pi_coeff1_i: float = 0.5 # PI controller integral coefficient for laser 1
|
||||
pi_coeff2_p: float = 1.0 # PI controller proportional coefficient for laser 2
|
||||
pi_coeff2_i: float = 0.5 # PI controller integral coefficient for laser 2
|
||||
|
||||
def to_dict(self) -> Dict[str, float]:
|
||||
"""Convert to dictionary representation."""
|
||||
return {
|
||||
'temp1': self.temp1,
|
||||
'temp2': self.temp2,
|
||||
'current1': self.current1,
|
||||
'current2': self.current2,
|
||||
'pi_coeff1_p': self.pi_coeff1_p,
|
||||
'pi_coeff1_i': self.pi_coeff1_i,
|
||||
'pi_coeff2_p': self.pi_coeff2_p,
|
||||
'pi_coeff2_i': self.pi_coeff2_i
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class VariationParams:
|
||||
"""Parameters for variation mode."""
|
||||
variation_type: VariationType
|
||||
# Static parameters (fixed during variation)
|
||||
static_temp1: float
|
||||
static_temp2: float
|
||||
static_current1: float
|
||||
static_current2: float
|
||||
# Variation range
|
||||
min_value: float # Minimum value for varied parameter
|
||||
max_value: float # Maximum value for varied parameter
|
||||
step: float # Step size for variation
|
||||
# Time parameters
|
||||
time_step: int # Time step in microseconds (20-100)
|
||||
delay_time: int # Delay between measurements in milliseconds (3-10)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary representation."""
|
||||
return {
|
||||
'variation_type': self.variation_type.value,
|
||||
'static_temp1': self.static_temp1,
|
||||
'static_temp2': self.static_temp2,
|
||||
'static_current1': self.static_current1,
|
||||
'static_current2': self.static_current2,
|
||||
'min_value': self.min_value,
|
||||
'max_value': self.max_value,
|
||||
'step': self.step,
|
||||
'time_step': self.time_step,
|
||||
'delay_time': self.delay_time
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Measurements:
|
||||
"""Real-time measurements from the device."""
|
||||
@@ -134,86 +63,3 @@ class Measurements:
|
||||
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||
'message_id': self.message_id
|
||||
}
|
||||
|
||||
def check_power_rails(self) -> Dict[str, bool]:
|
||||
"""Check if power supply voltages are within acceptable range."""
|
||||
return {
|
||||
'3v3': 3.1 <= self.voltage_3v3 <= 3.5,
|
||||
'5v1': 4.8 <= self.voltage_5v1 <= 5.3,
|
||||
'5v2': 4.8 <= self.voltage_5v2 <= 5.3,
|
||||
'7v0': 6.5 <= self.voltage_7v0 <= 7.5
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceStatus:
|
||||
"""Complete device status information."""
|
||||
state: DeviceState
|
||||
measurements: Optional[Measurements] = None
|
||||
is_connected: bool = False
|
||||
last_command_id: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_idle(self) -> bool:
|
||||
"""Check if device is idle."""
|
||||
return self.state == DeviceState.IDLE
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if device is running a task."""
|
||||
return self.state == DeviceState.RUNNING
|
||||
|
||||
@property
|
||||
def has_error(self) -> bool:
|
||||
"""Check if device has any error."""
|
||||
return self.state >= DeviceState.ERROR
|
||||
|
||||
@property
|
||||
def error_type(self) -> Optional[str]:
|
||||
"""Get human-readable error type."""
|
||||
if not self.has_error:
|
||||
return None
|
||||
|
||||
error_map = {
|
||||
DeviceState.ERROR_OVERHEAT: "Overheating",
|
||||
DeviceState.ERROR_POWER: "Power supply issue",
|
||||
DeviceState.ERROR_COMMUNICATION: "Communication error",
|
||||
DeviceState.ERROR_INVALID_COMMAND: "Invalid command"
|
||||
}
|
||||
return error_map.get(self.state, "Unknown error")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary representation."""
|
||||
return {
|
||||
'state': self.state.value,
|
||||
'state_name': self.state.name,
|
||||
'measurements': self.measurements.to_dict() if self.measurements else None,
|
||||
'is_connected': self.is_connected,
|
||||
'last_command_id': self.last_command_id,
|
||||
'error_message': self.error_message,
|
||||
'is_idle': self.is_idle,
|
||||
'is_running': self.is_running,
|
||||
'has_error': self.has_error,
|
||||
'error_type': self.error_type
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationData:
|
||||
"""Calibration data for device sensors."""
|
||||
# Temperature calibration coefficients
|
||||
temp1_offset: float = 0.0
|
||||
temp1_scale: float = 1.0
|
||||
temp2_offset: float = 0.0
|
||||
temp2_scale: float = 1.0
|
||||
# Current calibration coefficients
|
||||
current1_offset: float = 0.0
|
||||
current1_scale: float = 1.0
|
||||
current2_offset: float = 0.0
|
||||
current2_scale: float = 1.0
|
||||
# Voltage calibration
|
||||
voltage_3v3_scale: float = 1.0
|
||||
voltage_5v1_scale: float = 1.0
|
||||
voltage_5v2_scale: float = 1.0
|
||||
voltage_7v0_scale: float = 1.0
|
||||
@@ -21,7 +21,7 @@
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
[starter] ── TASK_ENABLE ──► плата кратко открыл порт, послал, закрыл
|
||||
[starter] ── TASK_ENABLE ──► плата запускает вариацию
|
||||
│ пишет session.json (target temp1/2, tolerance, variation_type)
|
||||
▼
|
||||
[monitor] ── TRANS_ENABLE ──► плата владеет портом всё время работы
|
||||
@@ -32,19 +32,21 @@
|
||||
сверяет temp1↔target_temp1 и temp2↔target_temp2, |Δ|>tol ─► WARNING в консоль
|
||||
```
|
||||
|
||||
- **Порт лазера эксклюзивен.** `starter` трогает его кратко, затем `monitor`
|
||||
владеет им всё время. `checker` порт не трогает вовсе — читает только файлы.
|
||||
- **Порт лазера эксклюзивен.** При `--start` запуск и мониторинг используют одно
|
||||
соединение. Штатный пайплайн закрывает порт после запуска вариации, и его
|
||||
может открыть отдельный монитор. `checker` читает только файлы.
|
||||
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
|
||||
останавливаются и перезапускаются независимо, без рукопожатия.
|
||||
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
|
||||
термисторам `temp_ext*`), каждый со своим допуском (по умолчанию `0.03 °C`).
|
||||
термисторам `temp_ext*`), с общим допуском (по умолчанию `0.03 °C`).
|
||||
|
||||
## Быстрый старт (CLI, два процесса)
|
||||
|
||||
Терминал 1 — стартовать вариацию и мониторить температуру:
|
||||
Команды запускаются из корня проекта. Терминал 1 — стартовать вариацию и
|
||||
мониторить температуру:
|
||||
|
||||
```bash
|
||||
python -m python_app.scripts.laser_temp_monitor \
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
|
||||
--config run_config.json \
|
||||
--start
|
||||
```
|
||||
@@ -52,15 +54,15 @@ python -m python_app.scripts.laser_temp_monitor \
|
||||
Терминал 2 — валидировать температуру и печатать предупреждения:
|
||||
|
||||
```bash
|
||||
python -m python_app.scripts.laser_temp_checker
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_checker
|
||||
```
|
||||
|
||||
Пример вывода чекера при расхождении и возврате в допуск:
|
||||
|
||||
```
|
||||
WARNING laser_temp_checker: Laser 1 temperature off target: measured 28.100 °C,
|
||||
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 laser_temp_checker: Laser 1 temperature back within tolerance:
|
||||
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]
|
||||
```
|
||||
|
||||
@@ -69,7 +71,7 @@ INFO laser_temp_checker: Laser 1 temperature back within tolerance:
|
||||
## Конфигурация
|
||||
|
||||
Параметры берутся из `run_config.json`, секция `radar.laser_control`. Мониторинг
|
||||
использует блок `variation` и новое поле `temp_tolerance_c`:
|
||||
использует блок `variation`, включая `temp_tolerance_c`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -105,7 +107,7 @@ INFO laser_temp_checker: Laser 1 temperature back within tolerance:
|
||||
|
||||
| Поле | Смысл |
|
||||
|---|---|
|
||||
| `port` | Серийный порт лазерной платы (пусто → автоопределение) |
|
||||
| `port` | Серийный порт лазерной платы; CLI-монитор допускает пустое значение для автоопределения, пайплайн требует явный порт |
|
||||
| `static_temp1` / `static_temp2` | Целевые статичные температуры лазеров 1/2, °C |
|
||||
| `min_value` / `max_value` / `step` | Диапазон и шаг свипа тока, мА — из них считается период свипа |
|
||||
| `time_step` / `delay_time` | Тайминги точки (мкс / мс) — тоже входят в период свипа |
|
||||
@@ -126,13 +128,13 @@ INFO laser_temp_checker: Laser 1 temperature back within tolerance:
|
||||
Мониторить уже запущенную из GUI/пайплайна вариацию (без повторного старта):
|
||||
|
||||
```bash
|
||||
python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config run_config.json
|
||||
```
|
||||
|
||||
Фиксированный период вместо расчётного (напр. раз в 500 мс):
|
||||
|
||||
```bash
|
||||
python -m python_app.scripts.laser_temp_monitor \
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
|
||||
--config run_config.json --strategy interval:500
|
||||
```
|
||||
|
||||
@@ -150,10 +152,10 @@ python -m python_app.scripts.laser_temp_monitor \
|
||||
|
||||
```bash
|
||||
# монитор
|
||||
python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
|
||||
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
|
||||
# чекер
|
||||
python -m python_app.scripts.laser_temp_checker \
|
||||
.venv/bin/python -m python_app.scripts.laser_temp_checker \
|
||||
--readings /tmp/run7.jsonl --session /tmp/run7.session.json --reminder-every 20
|
||||
```
|
||||
|
||||
@@ -161,9 +163,10 @@ python -m python_app.scripts.laser_temp_checker \
|
||||
|
||||
Когда вариацию стартует штатный пайплайн
|
||||
([`apply_kamil_adc_laser_control`](../../kamil_adc/laser.py)), снимок сессии
|
||||
`session.json` пишется автоматически. Достаточно запустить только чекер
|
||||
(и, при желании, монитор без `--start`, чтобы он опрашивал плату). Так консоль
|
||||
получит предупреждения о рассинхроне температуры прямо во время захвата.
|
||||
`<tmp>/laser_variation_session.json` пишется автоматически. Для предупреждений
|
||||
во время захвата нужны монитор без `--start`, который создаёт показания, и чекер.
|
||||
Если монитор уже работает, достаточно добавить чекер. Он загружает сессию
|
||||
один раз при запуске; после запуска новой вариации чекер нужно перезапустить.
|
||||
|
||||
## Встраивание в свой код (без CLI)
|
||||
|
||||
@@ -187,15 +190,22 @@ period = resolve_period_s("computed", min_value=33.0, max_value=60.0, step=0.05,
|
||||
stop = threading.Event()
|
||||
with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl") as w:
|
||||
monitor = LaserTemperatureMonitor(ctrl, w, period_s=period)
|
||||
threading.Thread(target=monitor.run, args=(stop,), daemon=True).start()
|
||||
worker = threading.Thread(target=monitor.run, args=(stop,))
|
||||
worker.start()
|
||||
|
||||
# 3. Чекер: тайлить показания и валидировать оба лазера
|
||||
checker = LaserTemperatureChecker.from_session(session)
|
||||
reader = ReadingReader("readings.jsonl")
|
||||
while not stop.is_set():
|
||||
for reading in reader.poll():
|
||||
checker.process(reading) # печатает WARNING при |Δ| > tolerance
|
||||
stop.wait(0.2)
|
||||
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)` возвращает список
|
||||
@@ -216,7 +226,7 @@ with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl
|
||||
}
|
||||
```
|
||||
|
||||
`readings.jsonl` (по одной строке-объекту на свип):
|
||||
`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}
|
||||
@@ -232,14 +242,16 @@ per_point_s = delay_time / 1000 + time_step / 1_000_000
|
||||
sweep_period = num_steps × per_point_s
|
||||
```
|
||||
|
||||
Монитор публикует одно показание за такой период. Если нужен другой темп —
|
||||
Монитор опрашивает плату сразу после запуска, затем ждёт такой период после
|
||||
каждого опроса; это оценка, без синхронизации с границами аппаратных свипов.
|
||||
Если нужен другой темп —
|
||||
`--strategy interval:<ms>`. (Внутренний счётчик `TO6` платы существует, но его
|
||||
семантика не гарантирована, поэтому для тайминга он не используется.)
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
python -m pytest python_app/tests/test_laser_temp_monitoring.py -q
|
||||
.venv/bin/python -m unittest python_app.tests.test_laser_temp_monitoring
|
||||
```
|
||||
|
||||
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
|
||||
|
||||
@@ -23,10 +23,15 @@ 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) -> object: ...
|
||||
def get_measurements(self) -> _Temperatures | None: ...
|
||||
|
||||
|
||||
def compute_sweep_period_s(
|
||||
@@ -138,5 +143,5 @@ class LaserTemperatureMonitor:
|
||||
logger.info("Temperature monitor stopped after %d readings", seq)
|
||||
|
||||
|
||||
def _opt(value: object) -> Optional[float]:
|
||||
def _opt(value: float | None) -> Optional[float]:
|
||||
return None if value is None else float(value)
|
||||
|
||||
@@ -58,7 +58,7 @@ class TemperatureReading:
|
||||
)
|
||||
|
||||
|
||||
def _opt_float(value: object) -> Optional[float]:
|
||||
def _opt_float(value: str | float | None) -> Optional[float]:
|
||||
return None if value is None else float(value)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ serial port connection to the laser control board.
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import struct
|
||||
from typing import Optional
|
||||
from enum import IntEnum
|
||||
from datetime import datetime
|
||||
@@ -30,7 +29,7 @@ from .conversions import (
|
||||
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, VariationType
|
||||
from .models import Measurements
|
||||
from .exceptions import (
|
||||
CommunicationError,
|
||||
PortNotFoundError,
|
||||
@@ -42,16 +41,6 @@ from .exceptions import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Re-export enums so tests can import from protocol module
|
||||
class CommandCode(IntEnum):
|
||||
DECODE_ENABLE = CMD_DECODE_ENABLE
|
||||
DEFAULT_ENABLE = CMD_DEFAULT_ENABLE
|
||||
TRANS_ENABLE = CMD_TRANS_ENABLE
|
||||
REMOVE_FILE = CMD_REMOVE_FILE
|
||||
STATE = CMD_STATE
|
||||
TASK_ENABLE = CMD_TASK_ENABLE
|
||||
|
||||
|
||||
class TaskType(IntEnum):
|
||||
MANUAL = 0x00
|
||||
CHANGE_CURRENT_LD1 = 0x01
|
||||
@@ -60,17 +49,6 @@ class TaskType(IntEnum):
|
||||
CHANGE_TEMPERATURE_LD2 = 0x04
|
||||
|
||||
|
||||
class DeviceState(IntEnum):
|
||||
IDLE = 0x0000
|
||||
RUNNING = 0x0001
|
||||
BUSY = 0x0002
|
||||
ERROR = 0x00FF
|
||||
ERROR_OVERHEAT = 0x0100
|
||||
ERROR_POWER = 0x0200
|
||||
ERROR_COMMUNICATION = 0x0400
|
||||
ERROR_INVALID_COMMAND = 0x0800
|
||||
|
||||
|
||||
# ---- Low-level helpers --------------------------------------------------
|
||||
|
||||
def _int_to_hex4(value: int) -> str:
|
||||
@@ -156,20 +134,6 @@ class Response:
|
||||
)
|
||||
|
||||
|
||||
# ---- Message builder --------------------------------------------------
|
||||
|
||||
class Message:
|
||||
"""Named container for an encoded command byte array."""
|
||||
def __init__(self, data: bytearray):
|
||||
self._data = data
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
return bytes(self._data)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._data)
|
||||
|
||||
|
||||
# ---- Protocol class --------------------------------------------------
|
||||
|
||||
class Protocol:
|
||||
@@ -364,17 +328,17 @@ class Protocol:
|
||||
@staticmethod
|
||||
def encode_trans_enable(message_id: int = 0) -> bytes:
|
||||
"""Build TRANS_ENABLE command (0x4444) — request last data."""
|
||||
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE)))
|
||||
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 bytearray.fromhex(_flipfour(_int_to_hex4(CMD_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 bytearray.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
|
||||
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
|
||||
|
||||
# ---- Response decoders -----------------------------------------------
|
||||
|
||||
@@ -433,7 +397,7 @@ class Protocol:
|
||||
Decode a 2-byte STATE response from the device.
|
||||
|
||||
Returns:
|
||||
Integer state code (compare with DeviceState enum).
|
||||
Integer state code; see STATE_* constants.
|
||||
"""
|
||||
if len(data) < 2:
|
||||
raise ProtocolError(f"STATE response too short: {len(data)} bytes")
|
||||
|
||||
Reference in New Issue
Block a user