Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -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")