first attempt at radioradar
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
# radar_system
|
||||
|
||||
Radar acquisition and processing system for single LibreVNA, synchronized
|
||||
multi-device LibreVNA, and Compact-M K209/S2VNA setups.
|
||||
multi-device LibreVNA, Compact-M K209/S2VNA, and Kamil ADC setups.
|
||||
|
||||
Start here:
|
||||
|
||||
- [Operation Modes](docs/operation_modes.md): what to run on each machine for
|
||||
`librevna`, `librevna_multi`, and `compact_m_k209`.
|
||||
`librevna`, `librevna_multi`, `compact_m_k209`, and `kamil_adc`.
|
||||
- [Run Config Reference](docs/run_config.md): `run_config.json` fields and
|
||||
example files.
|
||||
- [K209 Setup](docs/k209_setup.md): S2VNA, VISA, K209 limits, smoke tests, and
|
||||
@@ -27,4 +27,3 @@ Run the GUI:
|
||||
```bash
|
||||
.venv/bin/python -m python_app.gui.main
|
||||
```
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Available models:
|
||||
librevna
|
||||
librevna_multi
|
||||
compact_m_k209
|
||||
kamil_adc
|
||||
```
|
||||
|
||||
Example configs in the repository root:
|
||||
@@ -18,6 +19,7 @@ run_config_librevna.example.json
|
||||
run_config_librevna_multi.example.json
|
||||
run_config_compact_m_k209.example.json
|
||||
run_config_compact_m_k209_local_mock_switches.example.json
|
||||
run_config_kamil_adc.example.json
|
||||
run_config_simulator.example.json
|
||||
```
|
||||
|
||||
@@ -47,6 +49,7 @@ The GUI process supervisor starts the correct producer automatically:
|
||||
- `librevna` -> `build/bin/sweep_orchestrator`
|
||||
- `compact_m_k209` -> `build/bin/sweep_orchestrator`
|
||||
- `librevna_multi` -> `python_app.scripts.multi_device_raw_producer`
|
||||
- `kamil_adc` -> `python_app.scripts.kamil_adc_raw_producer`
|
||||
|
||||
## Pure Simulator
|
||||
|
||||
@@ -220,6 +223,63 @@ For a command-line connection check from the Raspberry Pi:
|
||||
|
||||
The Raspberry Pi does not need S2VNA or NI-VISA in this remote mode.
|
||||
|
||||
## Kamil ADC With Laser Control
|
||||
|
||||
Use this mode for the ADC collector from `/home/europa/Documents/kamil_adc`
|
||||
and the laser board configured through `laser_control`. The external
|
||||
`kamil_adc` project is not modified by `radar_system`; the producer launches
|
||||
the configured executable and reads its TTY stream.
|
||||
|
||||
Config:
|
||||
|
||||
```json
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": "/home/europa/Documents/kamil_adc",
|
||||
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": [
|
||||
"profile:phase",
|
||||
"clock:internal",
|
||||
"internal_ref_hz:2000000",
|
||||
"mode:diff",
|
||||
"channels:2",
|
||||
"ch1:2",
|
||||
"ch2:3",
|
||||
"do1_toggle_per_frame",
|
||||
"do1_pair_subtract_avg"
|
||||
]
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": true,
|
||||
"port": "/dev/ttyUSB0",
|
||||
"mode": "variation"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `executable_path` is mandatory and must name the real Raspberry Pi binary.
|
||||
- The producer appends `tty:<tty_path>` automatically; do not put `tty:` in
|
||||
`radar.kamil_adc.args`.
|
||||
- The laser-control driver is vendored under `python_app.hardware_full.laser_control`.
|
||||
- `laser_control` and `kamil_adc` are treated as one hardware configuration.
|
||||
Changing either section requires restarting acquisition so the lasers are
|
||||
configured before the ADC collector starts.
|
||||
- The TTY frame `0x000A step data1 data2` is imported as `S21 = data1 + j*data2`.
|
||||
`S11` is filled with explicit zeros.
|
||||
|
||||
Manual raw-producer run:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m python_app.scripts.kamil_adc_raw_producer \
|
||||
--config run_config_kamil_adc.example.json
|
||||
```
|
||||
|
||||
## K209 Remote Performance
|
||||
|
||||
The remote K209 path keeps one persistent TCP connection open. Configuration
|
||||
|
||||
+110
-2
@@ -32,6 +32,8 @@ Selects the radar model and sweep settings.
|
||||
"driver_mode": "native",
|
||||
"mock_signal_hz": 5000000.0,
|
||||
"multi_device": {},
|
||||
"kamil_adc": {},
|
||||
"laser_control": {},
|
||||
"sweep": {}
|
||||
}
|
||||
```
|
||||
@@ -40,13 +42,15 @@ Fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `model` | `librevna`, `librevna_multi`, or `compact_m_k209`. |
|
||||
| `model` | `librevna`, `librevna_multi`, `compact_m_k209`, or `kamil_adc`. |
|
||||
| `serial` | LibreVNA serial. Empty means first device for single LibreVNA. For `librevna_multi`, this is the master serial. |
|
||||
| `remote_host` | K209 remote server host. Used by `compact_m_k209`; ignored by LibreVNA modes. |
|
||||
| `remote_port` | K209 remote server TCP port. Default is `50209`. |
|
||||
| `driver_mode` | `native` for hardware, `mock` for supported synthetic LibreVNA modes. K209 requires `native`. |
|
||||
| `driver_mode` | `native` for hardware, `mock` for supported synthetic LibreVNA modes. K209 and Kamil ADC require `native`. |
|
||||
| `mock_signal_hz` | Existing LibreVNA mock signal parameter used by C++ mock acquisition. |
|
||||
| `multi_device` | Extra settings for `librevna_multi`. |
|
||||
| `kamil_adc` | External collector process and TTY settings for `kamil_adc`. |
|
||||
| `laser_control` | Laser board settings applied before `kamil_adc` collection starts. |
|
||||
| `sweep` | Frequency, point count, IFBW, and power settings. |
|
||||
|
||||
### `radar.sweep`
|
||||
@@ -103,6 +107,90 @@ Fields:
|
||||
| `force_external_reference` | Configure the synchronized external reference path. |
|
||||
| `recovery_attempts` | Reopen/retry attempts after native multi-device acquisition errors. |
|
||||
|
||||
### `radar.kamil_adc`
|
||||
|
||||
Used only when `radar.model == "kamil_adc"`.
|
||||
|
||||
```json
|
||||
"kamil_adc": {
|
||||
"project_dir": "/home/europa/Documents/kamil_adc",
|
||||
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": [
|
||||
"profile:phase",
|
||||
"clock:internal",
|
||||
"internal_ref_hz:2000000",
|
||||
"mode:diff",
|
||||
"channels:2",
|
||||
"ch1:2",
|
||||
"ch2:3",
|
||||
"do1_toggle_per_frame",
|
||||
"do1_pair_subtract_avg"
|
||||
],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `project_dir` | Working directory for the external ADC collector. Required. |
|
||||
| `executable_path` | Full path to the Raspberry Pi executable. Required; no filename is assumed. |
|
||||
| `tty_path` | TTY stream path, for example `/tmp/ttyADC_data`. The producer appends `tty:<tty_path>`. |
|
||||
| `args` | Explicit collector arguments, excluding any `tty:` argument. |
|
||||
| `env` | Extra environment variables for the collector process. |
|
||||
| `startup_timeout_s` | Time allowed for the collector to create a fresh TTY path. |
|
||||
| `sweep_timeout_s` | Time allowed to receive one full sweep packet. |
|
||||
| `stop_timeout_s` | Graceful stop timeout before killing the collector process. |
|
||||
|
||||
The TTY frame format is strict: packet start is `0x000A 0xFFFF 0xFFFF 0xFFFF`,
|
||||
then each sweep point is `0x000A step data1 data2`. Steps must arrive as
|
||||
`1..points`. `S21` is `data1 + j*data2`; `S11` is stored as explicit zeros.
|
||||
|
||||
### `radar.laser_control`
|
||||
|
||||
Used with `kamil_adc` when the laser board must be configured before ADC
|
||||
collection starts. `laser_control` and `kamil_adc` are one hardware
|
||||
configuration unit: changing either section requires restarting acquisition.
|
||||
|
||||
```json
|
||||
"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,
|
||||
"manual": {
|
||||
"temp1": 25.0,
|
||||
"temp2": 25.0,
|
||||
"current1": 30.0,
|
||||
"current2": 30.0
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mode="manual"` uses `manual`. `mode="variation"` uses `variation`.
|
||||
`variation_type` is the enum name from `laser_control`, for example
|
||||
`CHANGE_CURRENT_LD1` or `CHANGE_TEMPERATURE_LD2`.
|
||||
|
||||
## `switches`
|
||||
|
||||
Two RF switch sections are used:
|
||||
@@ -319,3 +407,23 @@ Compact-M K209 via remote server:
|
||||
"driver_mode": "native"
|
||||
}
|
||||
```
|
||||
|
||||
Kamil ADC:
|
||||
|
||||
```json
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": "/home/europa/Documents/kamil_adc",
|
||||
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
|
||||
"tty_path": "/tmp/ttyADC_data"
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": true,
|
||||
"port": "/dev/ttyUSB0",
|
||||
"mode": "variation"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -18,6 +18,8 @@ class AppWindowRadarLimitsMixin:
|
||||
def _refresh_radar_limits_from_device(self) -> bool:
|
||||
"""Query native radar limits and apply them to GUI fields."""
|
||||
config = self._defaults_config
|
||||
if config.is_kamil_adc:
|
||||
return self._apply_radar_limits_to_ui(None)
|
||||
if config.is_multi_device:
|
||||
radar_service = LibreVnaService(serial=config.radar.serial or None)
|
||||
else:
|
||||
|
||||
@@ -390,11 +390,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
extra_serials=(
|
||||
config.radar.multi_device.slave_serials
|
||||
if config.is_multi_device
|
||||
else None
|
||||
),
|
||||
extra_serials=config.radar_key_extra_parts() or None,
|
||||
)
|
||||
|
||||
def _radar_key_from_ui(self) -> str:
|
||||
@@ -408,11 +404,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
sweep_points=int(self._points_input.text().strip()),
|
||||
ifbw_hz=float(self._ifbw_input.text().strip()),
|
||||
power_dbm=float(self._power_input.text().strip()),
|
||||
extra_serials=(
|
||||
self._defaults_config.radar.multi_device.slave_serials
|
||||
if model_name == RunConfigModel.LIBREVNA_MULTI_MODEL
|
||||
else None
|
||||
),
|
||||
extra_serials=self._defaults_config.radar_key_extra_parts() or None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -192,6 +192,9 @@ class AppWindowPipelineMixin:
|
||||
if config.is_multi_device:
|
||||
self._log("Multi-device raw producer will configure all LibreVNA devices")
|
||||
return
|
||||
if config.is_kamil_adc:
|
||||
self._log("Kamil ADC raw producer will apply laser_control and start the external collector")
|
||||
return
|
||||
|
||||
radar_service = create_single_radar_service(config)
|
||||
if not getattr(radar_service, "driver_available", True):
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
"""Service for acquiring sweeps from the external Kamil ADC collector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KAMIL_ADC_MARKER = 0x000A
|
||||
KAMIL_ADC_START_STEP = 0xFFFF
|
||||
KAMIL_ADC_FRAME_BYTES = 8
|
||||
KAMIL_ADC_MAX_STEP = 0xFFFE
|
||||
|
||||
_RAW_FRAME_STRUCT = struct.Struct("<HHHH")
|
||||
_POINT_FRAME_STRUCT = struct.Struct("<HHhh")
|
||||
_START_FRAME = _RAW_FRAME_STRUCT.pack(
|
||||
KAMIL_ADC_MARKER,
|
||||
KAMIL_ADC_START_STEP,
|
||||
KAMIL_ADC_START_STEP,
|
||||
KAMIL_ADC_START_STEP,
|
||||
)
|
||||
|
||||
|
||||
class KamilAdcFrameParser:
|
||||
"""Strict parser for Kamil ADC 4-word TTY frames."""
|
||||
|
||||
@staticmethod
|
||||
def is_packet_start(frame: bytes) -> bool:
|
||||
"""Return whether `frame` is the packet-start marker."""
|
||||
return frame == _START_FRAME
|
||||
|
||||
@staticmethod
|
||||
def parse_point(frame: bytes, expected_step: int) -> complex:
|
||||
"""Parse one `0x000A step real imag` frame and validate ordering."""
|
||||
if len(frame) != KAMIL_ADC_FRAME_BYTES:
|
||||
raise ValueError(
|
||||
f"Kamil ADC frame must be {KAMIL_ADC_FRAME_BYTES} bytes, got {len(frame)}"
|
||||
)
|
||||
marker, step, real, imag = _POINT_FRAME_STRUCT.unpack(frame)
|
||||
if marker != KAMIL_ADC_MARKER:
|
||||
raise ValueError(f"Kamil ADC marker mismatch: got 0x{marker:04x}, expected 0x000a")
|
||||
if step != expected_step:
|
||||
raise ValueError(f"Kamil ADC step mismatch: got {step}, expected {expected_step}")
|
||||
return complex(real, imag)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcTtyReader:
|
||||
"""Read full Kamil ADC sweep packets from a nonblocking TTY stream."""
|
||||
|
||||
tty_path: str
|
||||
_fd: int | None = field(init=False, default=None, repr=False)
|
||||
_buffer: bytearray = field(init=False, default_factory=bytearray, repr=False)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the configured TTY path for binary reads."""
|
||||
if self._fd is not None:
|
||||
return
|
||||
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the TTY file descriptor."""
|
||||
if self._fd is None:
|
||||
return
|
||||
try:
|
||||
os.close(self._fd)
|
||||
finally:
|
||||
self._fd = None
|
||||
self._buffer.clear()
|
||||
|
||||
def read_sweep(
|
||||
self,
|
||||
*,
|
||||
points: int,
|
||||
timeout_s: float,
|
||||
process: subprocess.Popen[bytes] | None = None,
|
||||
) -> np.ndarray:
|
||||
"""Read one packet-start marker followed by exactly `points` IQ frames."""
|
||||
if points <= 0:
|
||||
raise ValueError("Kamil ADC sweep points must be > 0")
|
||||
if points > KAMIL_ADC_MAX_STEP:
|
||||
raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}")
|
||||
if self._fd is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
self._read_until_packet_start(deadline, process)
|
||||
|
||||
values = np.empty(points, dtype=np.complex64)
|
||||
for index in range(points):
|
||||
frame = self._read_frame(deadline, process, received_points=index, expected_points=points)
|
||||
values[index] = KamilAdcFrameParser.parse_point(frame, index + 1)
|
||||
return values
|
||||
|
||||
def discard_pending(self, process: subprocess.Popen[bytes] | None = None) -> None:
|
||||
"""Discard bytes already buffered before starting a new logical sweep."""
|
||||
if self._fd is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
self._buffer.clear()
|
||||
fd = self._require_fd()
|
||||
while True:
|
||||
self._raise_if_process_exited(process)
|
||||
try:
|
||||
readable, _, _ = select.select([fd], [], [], 0.0)
|
||||
except InterruptedError:
|
||||
continue
|
||||
if not readable:
|
||||
return
|
||||
try:
|
||||
chunk = os.read(fd, 4096)
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
||||
return
|
||||
raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while draining")
|
||||
|
||||
def _read_until_packet_start(
|
||||
self,
|
||||
deadline: float,
|
||||
process: subprocess.Popen[bytes] | None,
|
||||
) -> None:
|
||||
while True:
|
||||
start_index = self._buffer.find(_START_FRAME)
|
||||
if start_index >= 0:
|
||||
del self._buffer[: start_index + KAMIL_ADC_FRAME_BYTES]
|
||||
return
|
||||
if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES:
|
||||
del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1]
|
||||
self._read_available(deadline, process)
|
||||
|
||||
def _read_frame(
|
||||
self,
|
||||
deadline: float,
|
||||
process: subprocess.Popen[bytes] | None,
|
||||
*,
|
||||
received_points: int,
|
||||
expected_points: int,
|
||||
) -> bytes:
|
||||
while len(self._buffer) < KAMIL_ADC_FRAME_BYTES:
|
||||
self._read_available(deadline, process, received_points, expected_points)
|
||||
frame = bytes(self._buffer[:KAMIL_ADC_FRAME_BYTES])
|
||||
del self._buffer[:KAMIL_ADC_FRAME_BYTES]
|
||||
return frame
|
||||
|
||||
def _read_available(
|
||||
self,
|
||||
deadline: float,
|
||||
process: subprocess.Popen[bytes] | None,
|
||||
received_points: int | None = None,
|
||||
expected_points: int | None = None,
|
||||
) -> None:
|
||||
self._raise_if_process_exited(process)
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
if received_points is None or expected_points is None:
|
||||
raise TimeoutError("Timed out waiting for Kamil ADC packet-start marker")
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points"
|
||||
)
|
||||
|
||||
fd = self._require_fd()
|
||||
wait_s = min(0.05, remaining_s)
|
||||
try:
|
||||
readable, _, _ = select.select([fd], [], [], wait_s)
|
||||
except InterruptedError:
|
||||
return
|
||||
if not readable:
|
||||
return
|
||||
|
||||
try:
|
||||
chunk = os.read(fd, 4096)
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
||||
return
|
||||
raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
|
||||
if not chunk:
|
||||
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
|
||||
self._buffer.extend(chunk)
|
||||
|
||||
def _require_fd(self) -> int:
|
||||
if self._fd is None:
|
||||
raise RuntimeError("Kamil ADC TTY reader is not open")
|
||||
return self._fd
|
||||
|
||||
@staticmethod
|
||||
def _raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
||||
if process is None:
|
||||
return
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
raise RuntimeError(f"Kamil ADC process exited with code {return_code}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcService:
|
||||
"""Launch `kamil_adc`, configure laser board, and acquire TTY sweeps."""
|
||||
|
||||
config: RunConfigModel
|
||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
|
||||
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
|
||||
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
|
||||
_laser_controller: Any | None = field(init=False, default=None, repr=False)
|
||||
_laser_variation_active: bool = field(init=False, default=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate_config()
|
||||
|
||||
@property
|
||||
def command(self) -> list[str]:
|
||||
"""Return external collector command including the generated TTY argument."""
|
||||
adc = self.config.radar.kamil_adc
|
||||
executable_path = str(Path(adc.executable_path).expanduser())
|
||||
return [executable_path, *adc.args, f"tty:{adc.tty_path}"]
|
||||
|
||||
def open(self) -> None:
|
||||
"""Apply laser configuration, launch the collector, and open its TTY stream."""
|
||||
if self._reader is not None:
|
||||
return
|
||||
|
||||
previous_tty_identity = _tty_identity(self.config.radar.kamil_adc.tty_path)
|
||||
try:
|
||||
self._apply_laser_control()
|
||||
self._start_process()
|
||||
self._wait_for_tty(previous_tty_identity)
|
||||
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
|
||||
reader.open()
|
||||
self._reader = reader
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close TTY, stop the external collector, and disconnect laser control."""
|
||||
if self._reader is not None:
|
||||
with suppress(Exception):
|
||||
self._reader.close()
|
||||
self._reader = None
|
||||
|
||||
self._stop_process()
|
||||
self._close_laser_control()
|
||||
|
||||
def configure(self, sweep: RadarSweepModel) -> None:
|
||||
"""Store sweep settings and construct the synthetic frequency axis."""
|
||||
self._validate_sweep(sweep)
|
||||
self._settings = sweep
|
||||
self._frequency_hz = np.linspace(
|
||||
float(sweep.start_hz),
|
||||
float(sweep.stop_hz),
|
||||
int(sweep.points),
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
def read_device_limits(self) -> dict[str, float | int]:
|
||||
"""Kamil ADC has no runtime-readable sweep limit API."""
|
||||
raise RuntimeError("Kamil ADC device limits are not available")
|
||||
|
||||
def acquire(self) -> SweepResult:
|
||||
"""Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros."""
|
||||
if self._settings is None or self._frequency_hz is None:
|
||||
raise RuntimeError("Kamil ADC service is not configured")
|
||||
if self._reader is None:
|
||||
raise RuntimeError("Kamil ADC service is not open")
|
||||
process = self._process
|
||||
if process is None or process.poll() is not None:
|
||||
code = None if process is None else process.poll()
|
||||
raise RuntimeError(f"Kamil ADC process is not running (code={code})")
|
||||
|
||||
points = int(self._settings.points)
|
||||
self._reader.discard_pending(process)
|
||||
s21 = self._reader.read_sweep(
|
||||
points=points,
|
||||
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
|
||||
process=process,
|
||||
)
|
||||
return SweepResult(
|
||||
x=self._frequency_hz.copy(),
|
||||
traces={
|
||||
"s11": np.zeros(points, dtype=np.complex64),
|
||||
"s21": s21,
|
||||
},
|
||||
)
|
||||
|
||||
def _start_process(self) -> None:
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
return
|
||||
|
||||
adc = self.config.radar.kamil_adc
|
||||
env = os.environ.copy()
|
||||
env.update(adc.env)
|
||||
logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
|
||||
self._process = subprocess.Popen(
|
||||
self.command,
|
||||
cwd=str(Path(adc.project_dir).expanduser()),
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _stop_process(self) -> None:
|
||||
process = self._process
|
||||
self._process = None
|
||||
if process is None:
|
||||
return
|
||||
if process.poll() is not None:
|
||||
return
|
||||
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=self.config.radar.kamil_adc.stop_timeout_s)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
with suppress(ProcessLookupError):
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait(timeout=1.0)
|
||||
|
||||
def _wait_for_tty(self, previous_identity: tuple[object, ...] | None) -> None:
|
||||
adc = self.config.radar.kamil_adc
|
||||
deadline = time.monotonic() + adc.startup_timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
KamilAdcTtyReader._raise_if_process_exited(self._process)
|
||||
identity = _tty_identity(adc.tty_path)
|
||||
if identity is not None and identity != previous_identity:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
|
||||
)
|
||||
|
||||
def _apply_laser_control(self) -> None:
|
||||
laser = self.config.radar.laser_control
|
||||
if not laser.enabled:
|
||||
return
|
||||
|
||||
from python_app.hardware_full.laser_control.controller import LaserController
|
||||
from python_app.hardware_full.laser_control.models import VariationType
|
||||
|
||||
controller = LaserController(
|
||||
port=laser.port,
|
||||
pi_coeff1_p=laser.pi_coeff1_p,
|
||||
pi_coeff1_i=laser.pi_coeff1_i,
|
||||
pi_coeff2_p=laser.pi_coeff2_p,
|
||||
pi_coeff2_i=laser.pi_coeff2_i,
|
||||
)
|
||||
try:
|
||||
controller.connect()
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode == "manual":
|
||||
manual = laser.manual
|
||||
controller.set_manual_mode(
|
||||
temp1=manual.temp1,
|
||||
temp2=manual.temp2,
|
||||
current1=manual.current1,
|
||||
current2=manual.current2,
|
||||
)
|
||||
elif mode == "variation":
|
||||
variation = laser.variation
|
||||
try:
|
||||
variation_type = VariationType[variation.variation_type]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"Unsupported radar.laser_control.variation.variation_type: "
|
||||
f"{variation.variation_type}"
|
||||
) from exc
|
||||
controller.start_variation(
|
||||
variation_type=variation_type,
|
||||
params={
|
||||
"static_temp1": variation.static_temp1,
|
||||
"static_temp2": variation.static_temp2,
|
||||
"static_current1": variation.static_current1,
|
||||
"static_current2": variation.static_current2,
|
||||
"min_value": variation.min_value,
|
||||
"max_value": variation.max_value,
|
||||
"step": variation.step,
|
||||
"time_step": variation.time_step,
|
||||
"delay_time": variation.delay_time,
|
||||
},
|
||||
)
|
||||
self._laser_variation_active = True
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
|
||||
except Exception:
|
||||
with suppress(Exception):
|
||||
controller.disconnect()
|
||||
raise
|
||||
|
||||
self._laser_controller = controller
|
||||
|
||||
def _close_laser_control(self) -> None:
|
||||
controller = self._laser_controller
|
||||
self._laser_controller = None
|
||||
if controller is None:
|
||||
self._laser_variation_active = False
|
||||
return
|
||||
|
||||
try:
|
||||
if self._laser_variation_active:
|
||||
controller.stop_task()
|
||||
finally:
|
||||
self._laser_variation_active = False
|
||||
controller.disconnect()
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
if not self.config.is_kamil_adc:
|
||||
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
|
||||
if self.config.radar.driver_mode != "native":
|
||||
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
|
||||
|
||||
adc = self.config.radar.kamil_adc
|
||||
if not adc.project_dir:
|
||||
raise ValueError("radar.kamil_adc.project_dir is required")
|
||||
if not adc.executable_path:
|
||||
raise ValueError("radar.kamil_adc.executable_path is required")
|
||||
if not adc.tty_path:
|
||||
raise ValueError("radar.kamil_adc.tty_path is required")
|
||||
if any(arg.startswith("tty:") for arg in adc.args):
|
||||
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
|
||||
if adc.startup_timeout_s <= 0.0:
|
||||
raise ValueError("radar.kamil_adc.startup_timeout_s must be > 0")
|
||||
if adc.sweep_timeout_s <= 0.0:
|
||||
raise ValueError("radar.kamil_adc.sweep_timeout_s must be > 0")
|
||||
if adc.stop_timeout_s <= 0.0:
|
||||
raise ValueError("radar.kamil_adc.stop_timeout_s must be > 0")
|
||||
|
||||
project_dir = Path(adc.project_dir).expanduser()
|
||||
if not project_dir.is_dir():
|
||||
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
|
||||
executable_path = Path(adc.executable_path).expanduser()
|
||||
if not executable_path.is_file():
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
|
||||
if not os.access(executable_path, os.X_OK):
|
||||
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
|
||||
|
||||
laser = self.config.radar.laser_control
|
||||
if laser.enabled:
|
||||
if not laser.port:
|
||||
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
|
||||
mode = laser.mode.strip().lower()
|
||||
if mode not in {"manual", "variation"}:
|
||||
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
|
||||
if mode == "variation" and not laser.variation.variation_type:
|
||||
raise ValueError("radar.laser_control.variation.variation_type is required")
|
||||
|
||||
@staticmethod
|
||||
def _validate_sweep(sweep: RadarSweepModel) -> None:
|
||||
points = int(sweep.points)
|
||||
if points <= 0:
|
||||
raise ValueError("Kamil ADC sweep points must be > 0")
|
||||
if points > KAMIL_ADC_MAX_STEP:
|
||||
raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}")
|
||||
if float(sweep.stop_hz) < float(sweep.start_hz):
|
||||
raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz")
|
||||
|
||||
|
||||
def _tty_identity(path: str) -> tuple[object, ...] | None:
|
||||
try:
|
||||
if os.path.islink(path):
|
||||
return ("link", os.readlink(path))
|
||||
stat_result = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
return (
|
||||
"node",
|
||||
int(stat_result.st_dev),
|
||||
int(stat_result.st_ino),
|
||||
int(stat_result.st_mtime_ns),
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Vendored laser-control package used by the Kamil ADC hardware path."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Constants for laser control module.
|
||||
|
||||
Physical constraints, protocol parameters, and operational limits
|
||||
extracted from original device_commands.py and device_conversion.py.
|
||||
"""
|
||||
|
||||
# ---- Protocol constants
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
# Bridge resistors for temperature measurement
|
||||
R1 = 10000 # Ohm
|
||||
R2 = 2200 # Ohm
|
||||
R3 = 27000 # Ohm
|
||||
R4 = 30000 # Ohm
|
||||
R5 = 27000 # Ohm
|
||||
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)
|
||||
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
|
||||
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)
|
||||
|
||||
TEMP_MIN_C = 15.0 # Minimum allowed laser temperature, °C
|
||||
TEMP_MAX_C = 40.0 # Maximum allowed laser temperature, °C
|
||||
|
||||
CURRENT_MIN_MA = 15.0 # Minimum allowed laser current, mA
|
||||
CURRENT_MAX_MA = 60.0 # Maximum allowed laser current, mA
|
||||
|
||||
# Variation step limits
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Main laser controller for the laser control module.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default PI regulator coefficients (match firmware defaults)
|
||||
DEFAULT_PI_P = 2560 # 10 * 256
|
||||
DEFAULT_PI_I = 128 # 0.5 * 256
|
||||
|
||||
|
||||
class LaserController:
|
||||
"""
|
||||
High-level controller for the dual laser board.
|
||||
|
||||
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)``.
|
||||
"""
|
||||
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 -------------------------------------------------------
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
# ---- Public API -------------------------------------------------------
|
||||
|
||||
def set_manual_mode(
|
||||
self,
|
||||
temp1: float,
|
||||
temp2: float,
|
||||
current1: float,
|
||||
current2: float,
|
||||
) -> 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.
|
||||
|
||||
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
|
||||
)
|
||||
self._message_id = (self._message_id + 1) & 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) -> 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)")
|
||||
|
||||
# Restore manual mode so the board is ready for TRANS_ENABLE requests
|
||||
self._message_id = (self._message_id + 1) & 0xFFFF
|
||||
cmd_restore = Protocol.encode_decode_enable(
|
||||
temp1=self._last_temp1,
|
||||
temp2=self._last_temp2,
|
||||
current1=self._last_current1,
|
||||
current2=self._last_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_restore)
|
||||
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 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()
|
||||
self._send_and_read_state(cmd)
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
pass
|
||||
self.disconnect()
|
||||
return False
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Physical unit conversions for laser control module.
|
||||
|
||||
Converts between physical quantities (°C, mA, V) and
|
||||
raw ADC/DAC integer values used by the device firmware.
|
||||
|
||||
All formulas are taken directly from the original device_conversion.py.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def temp_c_to_n(temp_c: float) -> int:
|
||||
"""
|
||||
Convert temperature (°C) to 16-bit DAC integer (Wheatstone bridge setpoint).
|
||||
|
||||
Args:
|
||||
temp_c: Temperature in degrees Celsius.
|
||||
|
||||
Returns:
|
||||
Integer in [0, 65535] for the DAC.
|
||||
"""
|
||||
rt = R0 * math.exp(BETA_INTERNAL / (temp_c + 273) - BETA_INTERNAL / T0_K)
|
||||
u = VREF / (R5 * (R3 + R4)) * (
|
||||
R1 * R4 * (R5 + R6) - rt * (R3 * R6 - R4 * R5)
|
||||
) / (rt + R1)
|
||||
n = int(u * ADC_BITS_16 / VREF)
|
||||
n = max(0, min(ADC_BITS_16, n))
|
||||
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:
|
||||
"""
|
||||
Convert laser drive current (mA) to 16-bit DAC integer.
|
||||
|
||||
Args:
|
||||
current_ma: Current in milliamps.
|
||||
|
||||
Returns:
|
||||
Integer in [0, 65535] for the DAC.
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Custom exceptions for laser control module.
|
||||
|
||||
Provides a hierarchy of exceptions for different error conditions
|
||||
that may occur during laser control operations.
|
||||
"""
|
||||
|
||||
|
||||
class LaserControlError(Exception):
|
||||
"""Base exception for all laser control errors."""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(LaserControlError):
|
||||
"""Base exception for validation errors."""
|
||||
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):
|
||||
if port:
|
||||
message = f"Serial port '{port}' not found"
|
||||
else:
|
||||
message = "No suitable serial port found for device connection"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class DeviceNotRespondingError(CommunicationError):
|
||||
"""Exception raised when device doesn't respond to commands."""
|
||||
|
||||
def __init__(self, timeout: float = 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, received: int = 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."""
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
# 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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Communication protocol for laser control module.
|
||||
|
||||
Encodes commands to bytes and decodes device responses.
|
||||
Faithful re-implementation of the logic in device_commands.py,
|
||||
refactored into a clean, testable class-based API.
|
||||
"""
|
||||
|
||||
import struct
|
||||
from typing import Optional
|
||||
from enum import IntEnum
|
||||
from datetime import datetime
|
||||
|
||||
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, VariationType
|
||||
from .exceptions import (
|
||||
CommunicationError,
|
||||
PortNotFoundError,
|
||||
CRCError,
|
||||
ProtocolError,
|
||||
)
|
||||
|
||||
|
||||
# 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
|
||||
CHANGE_CURRENT_LD2 = 0x02
|
||||
CHANGE_TEMPERATURE_LD1 = 0x03
|
||||
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:
|
||||
"""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(s: str) -> str:
|
||||
"""Swap two byte-pairs: 'aabb' → 'bbaa' (little-endian word)."""
|
||||
if len(s) != 4:
|
||||
raise ValueError(f"Expected 4-char hex string, got '{s}'")
|
||||
return s[2:4] + s[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,
|
||||
)
|
||||
|
||||
|
||||
# ---- 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:
|
||||
"""
|
||||
Encodes commands and decodes responses for the laser control board.
|
||||
|
||||
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
|
||||
|
||||
# ---- Connection management
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Open the serial port. Auto-detects if port is None."""
|
||||
port = self._port_name or self._detect_port()
|
||||
try:
|
||||
self._serial = serial.Serial(
|
||||
port=port,
|
||||
baudrate=BAUDRATE,
|
||||
timeout=SERIAL_TIMEOUT_SEC,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise CommunicationError(
|
||||
f"Cannot connect to port '{port}': {exc}"
|
||||
) from exc
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close the serial port if open."""
|
||||
if self._serial and self._serial.is_open:
|
||||
self._serial.close()
|
||||
|
||||
@property
|
||||
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:
|
||||
if self._serial is None or not self._serial.is_open:
|
||||
raise CommunicationError("Serial port is not connected")
|
||||
self._serial.write(data)
|
||||
|
||||
def receive_raw(self, length: int) -> bytes:
|
||||
if self._serial is None or not self._serial.is_open:
|
||||
raise CommunicationError("Serial port is not connected")
|
||||
return self._serial.read(length)
|
||||
|
||||
# ---- Static encoding helpers (no connection required) ---------------
|
||||
|
||||
@staticmethod
|
||||
def flipfour(value: int) -> int:
|
||||
"""Byte-swap a 16-bit integer (little-endian word swap)."""
|
||||
return ((value & 0xFF) << 8) | ((value >> 8) & 0xFF)
|
||||
|
||||
@staticmethod
|
||||
def pack_float(value: float) -> bytes:
|
||||
return struct.pack('<f', value)
|
||||
|
||||
@staticmethod
|
||||
def pack_uint16(value: int) -> bytes:
|
||||
return struct.pack('<H', value)
|
||||
|
||||
@staticmethod
|
||||
def calculate_crc(data: bytes) -> int:
|
||||
"""
|
||||
XOR CRC over all 16-bit words except the last two bytes (CRC field).
|
||||
Mirrors the original CalculateCRC logic.
|
||||
"""
|
||||
hex_str = data.hex()
|
||||
words = [hex_str[i:i+4] for i in range(0, len(hex_str), 4)]
|
||||
# Skip word 0 (command code) per original firmware expectation
|
||||
crc_words = words[1:]
|
||||
result = int(crc_words[0], 16)
|
||||
for w in crc_words[1:]:
|
||||
result ^= int(w, 16)
|
||||
return result
|
||||
|
||||
# ---- 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,
|
||||
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)
|
||||
|
||||
@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,
|
||||
) -> 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
|
||||
data += _flipfour(_int_to_hex4(int(step * 100))) # 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 bytearray.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)))
|
||||
|
||||
@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)))
|
||||
|
||||
@staticmethod
|
||||
def encode_remove_file() -> bytes:
|
||||
"""Build REMOVE_FILE command (0x5555) — delete saved data."""
|
||||
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_REMOVE_FILE)))
|
||||
|
||||
# ---- 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
|
||||
|
||||
@staticmethod
|
||||
def decode_state(data: bytes) -> int:
|
||||
"""
|
||||
Decode a 2-byte STATE response from the device.
|
||||
|
||||
Returns:
|
||||
Integer state code (compare with DeviceState enum).
|
||||
"""
|
||||
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.")
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Parameter validation for laser control module.
|
||||
|
||||
Validates all input parameters against physical constraints
|
||||
and protocol limits before sending to device.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from .constants import (
|
||||
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,
|
||||
)
|
||||
from .exceptions import (
|
||||
ValidationError,
|
||||
TemperatureOutOfRangeError,
|
||||
CurrentOutOfRangeError,
|
||||
InvalidParameterError,
|
||||
)
|
||||
from .models import VariationType
|
||||
|
||||
|
||||
class ParameterValidator:
|
||||
"""Validates all input parameters for the laser controller."""
|
||||
|
||||
@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.
|
||||
|
||||
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'),
|
||||
}
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from python_app.hardware_full.kamil_adc_service import KamilAdcService
|
||||
from python_app.hardware_full.librevna_driver.models import SweepResult
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.remote_compact_m_k209_service import RemoteCompactMK209Service
|
||||
@@ -46,4 +47,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
|
||||
port=config.radar.remote_port,
|
||||
)
|
||||
|
||||
if model == RunConfigModel.KAMIL_ADC_MODEL:
|
||||
if config.radar.driver_mode != "native":
|
||||
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
|
||||
return KamilAdcService(config)
|
||||
|
||||
raise RuntimeError(f"Unsupported single-radar model: {model}")
|
||||
|
||||
@@ -30,6 +30,36 @@ def _load_preprocess_asset(payload: dict[str, Any], target: PreprocessAssetModel
|
||||
target.bundle_path = str(payload.get("bundle_path", target.bundle_path))
|
||||
|
||||
|
||||
def _load_string_list(payload: dict[str, Any], key: str, context: str) -> list[str]:
|
||||
"""Load an optional list of strings with strict shape validation."""
|
||||
raw_value = payload.get(key, [])
|
||||
if raw_value is None:
|
||||
return []
|
||||
if not isinstance(raw_value, list):
|
||||
raise ValueError(f"{context}.{key} must be a JSON array")
|
||||
values: list[str] = []
|
||||
for index, item in enumerate(raw_value):
|
||||
if not isinstance(item, str):
|
||||
raise ValueError(f"{context}.{key}[{index}] must be a JSON string")
|
||||
values.append(item)
|
||||
return values
|
||||
|
||||
|
||||
def _load_string_dict(payload: dict[str, Any], key: str, context: str) -> dict[str, str]:
|
||||
"""Load an optional string-to-string dictionary with strict shape validation."""
|
||||
raw_value = payload.get(key, {})
|
||||
if raw_value is None:
|
||||
return {}
|
||||
if not isinstance(raw_value, dict):
|
||||
raise ValueError(f"{context}.{key} must be a JSON object")
|
||||
values: dict[str, str] = {}
|
||||
for item_key, item_value in raw_value.items():
|
||||
if not isinstance(item_key, str) or not isinstance(item_value, str):
|
||||
raise ValueError(f"{context}.{key} must contain only string keys and values")
|
||||
values[item_key] = item_value
|
||||
return values
|
||||
|
||||
|
||||
def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
"""Decode JSON-like payload into :class:`RunConfigModel`."""
|
||||
# Schema carries only minimal-safe fallbacks; operational defaults live in run_config.json.
|
||||
@@ -54,6 +84,8 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
"run.locator_server",
|
||||
)
|
||||
multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device")
|
||||
kamil_adc_payload = _as_dict(radar_payload.get("kamil_adc"), "radar.kamil_adc")
|
||||
laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control")
|
||||
|
||||
model.radar.model = str(radar_payload.get("model", model.radar.model))
|
||||
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
|
||||
@@ -93,6 +125,110 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
model.radar.multi_device.recovery_attempts,
|
||||
)
|
||||
)
|
||||
model.radar.kamil_adc.project_dir = str(
|
||||
kamil_adc_payload.get("project_dir", model.radar.kamil_adc.project_dir)
|
||||
)
|
||||
model.radar.kamil_adc.executable_path = str(
|
||||
kamil_adc_payload.get("executable_path", model.radar.kamil_adc.executable_path)
|
||||
)
|
||||
model.radar.kamil_adc.tty_path = str(
|
||||
kamil_adc_payload.get("tty_path", model.radar.kamil_adc.tty_path)
|
||||
)
|
||||
model.radar.kamil_adc.args = _load_string_list(kamil_adc_payload, "args", "radar.kamil_adc")
|
||||
model.radar.kamil_adc.env = _load_string_dict(kamil_adc_payload, "env", "radar.kamil_adc")
|
||||
model.radar.kamil_adc.startup_timeout_s = float(
|
||||
kamil_adc_payload.get("startup_timeout_s", model.radar.kamil_adc.startup_timeout_s)
|
||||
)
|
||||
model.radar.kamil_adc.sweep_timeout_s = float(
|
||||
kamil_adc_payload.get("sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s)
|
||||
)
|
||||
model.radar.kamil_adc.stop_timeout_s = float(
|
||||
kamil_adc_payload.get("stop_timeout_s", model.radar.kamil_adc.stop_timeout_s)
|
||||
)
|
||||
|
||||
model.radar.laser_control.enabled = bool(
|
||||
laser_control_payload.get("enabled", model.radar.laser_control.enabled)
|
||||
)
|
||||
model.radar.laser_control.port = str(
|
||||
laser_control_payload.get("port", model.radar.laser_control.port)
|
||||
)
|
||||
model.radar.laser_control.mode = str(
|
||||
laser_control_payload.get("mode", model.radar.laser_control.mode)
|
||||
)
|
||||
model.radar.laser_control.pi_coeff1_p = int(
|
||||
laser_control_payload.get("pi_coeff1_p", model.radar.laser_control.pi_coeff1_p)
|
||||
)
|
||||
model.radar.laser_control.pi_coeff1_i = int(
|
||||
laser_control_payload.get("pi_coeff1_i", model.radar.laser_control.pi_coeff1_i)
|
||||
)
|
||||
model.radar.laser_control.pi_coeff2_p = int(
|
||||
laser_control_payload.get("pi_coeff2_p", model.radar.laser_control.pi_coeff2_p)
|
||||
)
|
||||
model.radar.laser_control.pi_coeff2_i = int(
|
||||
laser_control_payload.get("pi_coeff2_i", model.radar.laser_control.pi_coeff2_i)
|
||||
)
|
||||
laser_manual_payload = _as_dict(laser_control_payload.get("manual"), "radar.laser_control.manual")
|
||||
model.radar.laser_control.manual.temp1 = float(
|
||||
laser_manual_payload.get("temp1", model.radar.laser_control.manual.temp1)
|
||||
)
|
||||
model.radar.laser_control.manual.temp2 = float(
|
||||
laser_manual_payload.get("temp2", model.radar.laser_control.manual.temp2)
|
||||
)
|
||||
model.radar.laser_control.manual.current1 = float(
|
||||
laser_manual_payload.get("current1", model.radar.laser_control.manual.current1)
|
||||
)
|
||||
model.radar.laser_control.manual.current2 = float(
|
||||
laser_manual_payload.get("current2", model.radar.laser_control.manual.current2)
|
||||
)
|
||||
laser_variation_payload = _as_dict(
|
||||
laser_control_payload.get("variation"),
|
||||
"radar.laser_control.variation",
|
||||
)
|
||||
model.radar.laser_control.variation.variation_type = str(
|
||||
laser_variation_payload.get(
|
||||
"variation_type",
|
||||
model.radar.laser_control.variation.variation_type,
|
||||
)
|
||||
)
|
||||
model.radar.laser_control.variation.static_temp1 = float(
|
||||
laser_variation_payload.get(
|
||||
"static_temp1",
|
||||
model.radar.laser_control.variation.static_temp1,
|
||||
)
|
||||
)
|
||||
model.radar.laser_control.variation.static_temp2 = float(
|
||||
laser_variation_payload.get(
|
||||
"static_temp2",
|
||||
model.radar.laser_control.variation.static_temp2,
|
||||
)
|
||||
)
|
||||
model.radar.laser_control.variation.static_current1 = float(
|
||||
laser_variation_payload.get(
|
||||
"static_current1",
|
||||
model.radar.laser_control.variation.static_current1,
|
||||
)
|
||||
)
|
||||
model.radar.laser_control.variation.static_current2 = float(
|
||||
laser_variation_payload.get(
|
||||
"static_current2",
|
||||
model.radar.laser_control.variation.static_current2,
|
||||
)
|
||||
)
|
||||
model.radar.laser_control.variation.min_value = float(
|
||||
laser_variation_payload.get("min_value", model.radar.laser_control.variation.min_value)
|
||||
)
|
||||
model.radar.laser_control.variation.max_value = float(
|
||||
laser_variation_payload.get("max_value", model.radar.laser_control.variation.max_value)
|
||||
)
|
||||
model.radar.laser_control.variation.step = float(
|
||||
laser_variation_payload.get("step", model.radar.laser_control.variation.step)
|
||||
)
|
||||
model.radar.laser_control.variation.time_step = int(
|
||||
laser_variation_payload.get("time_step", model.radar.laser_control.variation.time_step)
|
||||
)
|
||||
model.radar.laser_control.variation.delay_time = int(
|
||||
laser_variation_payload.get("delay_time", model.radar.laser_control.variation.delay_time)
|
||||
)
|
||||
|
||||
load_switch_payload(port1_payload, model.output_switch)
|
||||
load_switch_payload(port2_payload, model.input_switch)
|
||||
@@ -249,6 +385,43 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"force_external_reference": model.radar.multi_device.force_external_reference,
|
||||
"recovery_attempts": model.radar.multi_device.recovery_attempts,
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": model.radar.kamil_adc.project_dir,
|
||||
"executable_path": model.radar.kamil_adc.executable_path,
|
||||
"tty_path": model.radar.kamil_adc.tty_path,
|
||||
"args": list(model.radar.kamil_adc.args),
|
||||
"env": dict(model.radar.kamil_adc.env),
|
||||
"startup_timeout_s": model.radar.kamil_adc.startup_timeout_s,
|
||||
"sweep_timeout_s": model.radar.kamil_adc.sweep_timeout_s,
|
||||
"stop_timeout_s": model.radar.kamil_adc.stop_timeout_s,
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": model.radar.laser_control.enabled,
|
||||
"port": model.radar.laser_control.port,
|
||||
"mode": model.radar.laser_control.mode,
|
||||
"pi_coeff1_p": model.radar.laser_control.pi_coeff1_p,
|
||||
"pi_coeff1_i": model.radar.laser_control.pi_coeff1_i,
|
||||
"pi_coeff2_p": model.radar.laser_control.pi_coeff2_p,
|
||||
"pi_coeff2_i": model.radar.laser_control.pi_coeff2_i,
|
||||
"manual": {
|
||||
"temp1": model.radar.laser_control.manual.temp1,
|
||||
"temp2": model.radar.laser_control.manual.temp2,
|
||||
"current1": model.radar.laser_control.manual.current1,
|
||||
"current2": model.radar.laser_control.manual.current2,
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": model.radar.laser_control.variation.variation_type,
|
||||
"static_temp1": model.radar.laser_control.variation.static_temp1,
|
||||
"static_temp2": model.radar.laser_control.variation.static_temp2,
|
||||
"static_current1": model.radar.laser_control.variation.static_current1,
|
||||
"static_current2": model.radar.laser_control.variation.static_current2,
|
||||
"min_value": model.radar.laser_control.variation.min_value,
|
||||
"max_value": model.radar.laser_control.variation.max_value,
|
||||
"step": model.radar.laser_control.variation.step,
|
||||
"time_step": model.radar.laser_control.variation.time_step,
|
||||
"delay_time": model.radar.laser_control.variation.delay_time,
|
||||
},
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": model.radar.sweep.start_hz,
|
||||
"stop_hz": model.radar.sweep.stop_hz,
|
||||
|
||||
@@ -6,6 +6,10 @@ from python_app.models.run_config_schema import (
|
||||
GprModel,
|
||||
GprRxGeometryModel,
|
||||
GprTxGeometryModel,
|
||||
KamilAdcModel,
|
||||
LaserControlModel,
|
||||
LaserManualModeModel,
|
||||
LaserVariationModeModel,
|
||||
LocatorServerRuntimeModel,
|
||||
PreprocessAssetModel,
|
||||
PreprocessNotchModel,
|
||||
@@ -33,6 +37,10 @@ __all__ = [
|
||||
"GprModel",
|
||||
"GprRxGeometryModel",
|
||||
"GprTxGeometryModel",
|
||||
"KamilAdcModel",
|
||||
"LaserControlModel",
|
||||
"LaserManualModeModel",
|
||||
"LaserVariationModeModel",
|
||||
"LocatorServerRuntimeModel",
|
||||
"PreprocessAssetModel",
|
||||
"PreprocessNotchModel",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -37,6 +38,61 @@ class RadarMultiDeviceModel:
|
||||
recovery_attempts: int = 3
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KamilAdcModel:
|
||||
"""External Kamil ADC acquisition process settings."""
|
||||
|
||||
project_dir: str = ""
|
||||
executable_path: str = ""
|
||||
tty_path: str = ""
|
||||
args: list[str] = field(default_factory=list)
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
startup_timeout_s: float = 5.0
|
||||
sweep_timeout_s: float = 5.0
|
||||
stop_timeout_s: float = 2.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaserManualModeModel:
|
||||
"""Manual laser-control setpoints."""
|
||||
|
||||
temp1: float = 25.0
|
||||
temp2: float = 25.0
|
||||
current1: float = 30.0
|
||||
current2: float = 30.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaserVariationModeModel:
|
||||
"""Laser-control variation task parameters."""
|
||||
|
||||
variation_type: str = "CHANGE_CURRENT_LD1"
|
||||
static_temp1: float = 25.0
|
||||
static_temp2: float = 25.0
|
||||
static_current1: float = 30.0
|
||||
static_current2: float = 30.0
|
||||
min_value: float = 30.0
|
||||
max_value: float = 35.0
|
||||
step: float = 0.1
|
||||
time_step: int = 20
|
||||
delay_time: int = 3
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LaserControlModel:
|
||||
"""Laser-control board settings applied before Kamil ADC acquisition."""
|
||||
|
||||
enabled: bool = False
|
||||
port: str = ""
|
||||
mode: str = "manual"
|
||||
pi_coeff1_p: int = 2560
|
||||
pi_coeff1_i: int = 128
|
||||
pi_coeff2_p: int = 2560
|
||||
pi_coeff2_i: int = 128
|
||||
manual: LaserManualModeModel = field(default_factory=LaserManualModeModel)
|
||||
variation: LaserVariationModeModel = field(default_factory=LaserVariationModeModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RadarModel:
|
||||
"""Radar section of run configuration."""
|
||||
@@ -49,6 +105,8 @@ class RadarModel:
|
||||
mock_signal_hz: float = 1_000_000.0
|
||||
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
|
||||
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
|
||||
kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel)
|
||||
laser_control: LaserControlModel = field(default_factory=LaserControlModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -204,6 +262,7 @@ class RunConfigModel:
|
||||
LIBREVNA_MODEL = "librevna"
|
||||
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
||||
COMPACT_M_K209_MODEL = "compact_m_k209"
|
||||
KAMIL_ADC_MODEL = "kamil_adc"
|
||||
MULTI_DEVICE_INPUT_POSITIONS = 4
|
||||
MULTI_DEVICE_OUTPUT_POSITIONS = 2
|
||||
|
||||
@@ -229,6 +288,57 @@ class RunConfigModel:
|
||||
"""Return whether this config targets synchronized multi-device acquisition."""
|
||||
return self.radar.model == self.LIBREVNA_MULTI_MODEL
|
||||
|
||||
@property
|
||||
def is_kamil_adc(self) -> bool:
|
||||
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
||||
return self.radar.model == self.KAMIL_ADC_MODEL
|
||||
|
||||
def radar_key_extra_parts(self) -> list[str]:
|
||||
"""Return model-specific identity parts that affect captured data."""
|
||||
if self.is_multi_device:
|
||||
return list(self.radar.multi_device.slave_serials)
|
||||
if self.is_kamil_adc:
|
||||
payload = {
|
||||
"kamil_adc": {
|
||||
"project_dir": self.radar.kamil_adc.project_dir,
|
||||
"executable_path": self.radar.kamil_adc.executable_path,
|
||||
"tty_path": self.radar.kamil_adc.tty_path,
|
||||
"args": list(self.radar.kamil_adc.args),
|
||||
"env": dict(sorted(self.radar.kamil_adc.env.items())),
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": self.radar.laser_control.enabled,
|
||||
"port": self.radar.laser_control.port,
|
||||
"mode": self.radar.laser_control.mode,
|
||||
"pi_coeff1_p": self.radar.laser_control.pi_coeff1_p,
|
||||
"pi_coeff1_i": self.radar.laser_control.pi_coeff1_i,
|
||||
"pi_coeff2_p": self.radar.laser_control.pi_coeff2_p,
|
||||
"pi_coeff2_i": self.radar.laser_control.pi_coeff2_i,
|
||||
"manual": {
|
||||
"temp1": self.radar.laser_control.manual.temp1,
|
||||
"temp2": self.radar.laser_control.manual.temp2,
|
||||
"current1": self.radar.laser_control.manual.current1,
|
||||
"current2": self.radar.laser_control.manual.current2,
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": self.radar.laser_control.variation.variation_type,
|
||||
"static_temp1": self.radar.laser_control.variation.static_temp1,
|
||||
"static_temp2": self.radar.laser_control.variation.static_temp2,
|
||||
"static_current1": self.radar.laser_control.variation.static_current1,
|
||||
"static_current2": self.radar.laser_control.variation.static_current2,
|
||||
"min_value": self.radar.laser_control.variation.min_value,
|
||||
"max_value": self.radar.laser_control.variation.max_value,
|
||||
"step": self.radar.laser_control.variation.step,
|
||||
"time_step": self.radar.laser_control.variation.time_step,
|
||||
"delay_time": self.radar.laser_control.variation.delay_time,
|
||||
},
|
||||
},
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:16]
|
||||
return [f"kamil_{digest}"]
|
||||
return []
|
||||
|
||||
def apply_device_model_constraints(self) -> None:
|
||||
"""Apply only required wire-format constraints for the selected device model."""
|
||||
if not self.is_multi_device:
|
||||
|
||||
@@ -178,6 +178,14 @@ class ProcessSupervisor:
|
||||
"--config",
|
||||
str(config_path),
|
||||
]
|
||||
if radar_model == "kamil_adc":
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"python_app.scripts.kamil_adc_raw_producer",
|
||||
"--config",
|
||||
str(config_path),
|
||||
]
|
||||
return [
|
||||
str(self._project_root / "build/bin/sweep_orchestrator"),
|
||||
"--config",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Raw acquisition producer for the external Kamil ADC radar mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.kamil_adc_service import KamilAdcService
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||
from python_app.models.run_config_model import RunConfigModel, SwitchModel
|
||||
from python_app.orchestration.shm import ShmRingWriter
|
||||
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run producer process until config or signal requests exit."""
|
||||
parser = argparse.ArgumentParser(description="Publish Kamil ADC raw sweeps to SHM rings")
|
||||
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
stop_requested = threading.Event()
|
||||
|
||||
def request_stop(_signum: int, _frame: object) -> None:
|
||||
stop_requested.set()
|
||||
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
|
||||
config = RunConfigModel.load_from_path(args.config)
|
||||
if not config.is_kamil_adc:
|
||||
raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'")
|
||||
config.ensure_combos()
|
||||
|
||||
raw_writer = ShmRingWriter(
|
||||
config.rings.raw.name,
|
||||
config.rings.raw.capacity,
|
||||
config.rings.raw.slot_size_bytes,
|
||||
)
|
||||
raw_tap_writer = ShmRingWriter(
|
||||
config.rings.raw_tap.name,
|
||||
config.rings.raw_tap.capacity,
|
||||
config.rings.raw_tap.slot_size_bytes,
|
||||
)
|
||||
radar = KamilAdcService(config)
|
||||
input_switch = _switch_from_model(config.input_switch)
|
||||
output_switch = _switch_from_model(config.output_switch)
|
||||
|
||||
try:
|
||||
radar.open()
|
||||
radar.configure(config.radar.sweep)
|
||||
output_switch.open()
|
||||
input_switch.open()
|
||||
|
||||
collection_id = 1
|
||||
while not stop_requested.is_set():
|
||||
collection_start = time.monotonic()
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
traces: list[TraceData] = []
|
||||
|
||||
for combo in config.combos:
|
||||
if stop_requested.is_set():
|
||||
break
|
||||
output_switch.switch_to(combo.output)
|
||||
input_switch.switch_to(combo.input)
|
||||
if config.runtime.settling_ms > 0:
|
||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
||||
|
||||
sweep = radar.acquire()
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input_pos=combo.input, output_pos=combo.output),
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
)
|
||||
)
|
||||
|
||||
if len(traces) != len(config.combos):
|
||||
break
|
||||
|
||||
collection = SweepCollection(
|
||||
collection_id=collection_id,
|
||||
monotonic_ns=time.monotonic_ns(),
|
||||
traces=traces,
|
||||
capture_start_ns=capture_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
payload = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
if not raw_writer.push(payload):
|
||||
raise RuntimeError(
|
||||
f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}"
|
||||
)
|
||||
if not raw_tap_writer.push(payload):
|
||||
raise RuntimeError(
|
||||
f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}"
|
||||
)
|
||||
|
||||
if not config.runtime.continuous:
|
||||
break
|
||||
collection_duration_s = time.monotonic() - collection_start
|
||||
if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0:
|
||||
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
|
||||
collection_id += 1
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
output_switch.close()
|
||||
with suppress(Exception):
|
||||
input_switch.close()
|
||||
radar.close()
|
||||
raw_tap_writer.close()
|
||||
raw_writer.close()
|
||||
|
||||
logger.info("Kamil ADC raw producer stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def _switch_from_model(model: SwitchModel) -> SwitchService:
|
||||
return SwitchService(
|
||||
name=model.name,
|
||||
positions=model.positions,
|
||||
mode=model.driver_mode,
|
||||
driver=model.driver,
|
||||
gpio_chip=model.gpio_chip,
|
||||
pin_a=model.pin_a,
|
||||
pin_b=model.pin_b,
|
||||
invert_logic=model.invert_logic,
|
||||
default_position=model.default_position,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -114,11 +114,7 @@ def main() -> int:
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
extra_serials=(
|
||||
config.radar.multi_device.slave_serials
|
||||
if config.is_multi_device
|
||||
else None
|
||||
),
|
||||
extra_serials=config.radar_key_extra_parts() or None,
|
||||
)
|
||||
|
||||
s21_calibration_set = build_synthetic_collection(config, value_scale=1.0, s11_scale=0.15, s11_phase_offset=0.4)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Python unit tests for radar_system."""
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for Kamil ADC config, parser, and producer wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import tty
|
||||
import unittest
|
||||
|
||||
from python_app.hardware_full.kamil_adc_service import KamilAdcFrameParser, KamilAdcTtyReader
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
|
||||
|
||||
def _start_frame() -> bytes:
|
||||
return struct.pack("<HHHH", 0x000A, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
def _point_frame(step: int, real: int, imag: int, *, marker: int = 0x000A) -> bytes:
|
||||
return struct.pack("<HHhh", marker, step, real, imag)
|
||||
|
||||
|
||||
class KamilAdcFrameParserTest(unittest.TestCase):
|
||||
def test_parse_valid_point(self) -> None:
|
||||
value = KamilAdcFrameParser.parse_point(_point_frame(1, 123, -45), expected_step=1)
|
||||
self.assertEqual(value, complex(123, -45))
|
||||
|
||||
def test_bad_marker_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "marker mismatch"):
|
||||
KamilAdcFrameParser.parse_point(_point_frame(1, 10, 20, marker=0x001A), expected_step=1)
|
||||
|
||||
def test_wrong_step_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "step mismatch"):
|
||||
KamilAdcFrameParser.parse_point(_point_frame(2, 10, 20), expected_step=1)
|
||||
|
||||
|
||||
class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
def test_valid_stream_reads_complex_sweep(self) -> None:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
reader: KamilAdcTtyReader | None = None
|
||||
try:
|
||||
tty.setraw(slave_fd)
|
||||
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
|
||||
reader.open()
|
||||
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1) + _point_frame(2, -20, 2))
|
||||
|
||||
values = reader.read_sweep(points=2, timeout_s=1.0)
|
||||
|
||||
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
|
||||
finally:
|
||||
if reader is not None:
|
||||
reader.close()
|
||||
os.close(master_fd)
|
||||
os.close(slave_fd)
|
||||
|
||||
def test_short_stream_times_out_with_received_count(self) -> None:
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
reader: KamilAdcTtyReader | None = None
|
||||
try:
|
||||
tty.setraw(slave_fd)
|
||||
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
|
||||
reader.open()
|
||||
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1))
|
||||
|
||||
with self.assertRaisesRegex(TimeoutError, "received 1/2"):
|
||||
reader.read_sweep(points=2, timeout_s=0.05)
|
||||
finally:
|
||||
if reader is not None:
|
||||
reader.close()
|
||||
os.close(master_fd)
|
||||
os.close(slave_fd)
|
||||
|
||||
|
||||
class KamilAdcConfigTest(unittest.TestCase):
|
||||
def test_config_round_trip_preserves_kamil_sections(self) -> None:
|
||||
payload = {
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"kamil_adc": {
|
||||
"project_dir": "/home/europa/Documents/kamil_adc",
|
||||
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": ["profile:phase", "do1_pair_subtract_avg"],
|
||||
"env": {"ADC_ENV": "1"},
|
||||
"startup_timeout_s": 7.0,
|
||||
"sweep_timeout_s": 8.0,
|
||||
"stop_timeout_s": 3.0,
|
||||
},
|
||||
"laser_control": {
|
||||
"enabled": True,
|
||||
"port": "/dev/ttyUSB0",
|
||||
"mode": "variation",
|
||||
"pi_coeff1_p": 2560,
|
||||
"pi_coeff1_i": 128,
|
||||
"pi_coeff2_p": 2600,
|
||||
"pi_coeff2_i": 140,
|
||||
"manual": {
|
||||
"temp1": 26.0,
|
||||
"temp2": 27.0,
|
||||
"current1": 31.0,
|
||||
"current2": 32.0,
|
||||
},
|
||||
"variation": {
|
||||
"variation_type": "CHANGE_CURRENT_LD2",
|
||||
"static_temp1": 28.0,
|
||||
"static_temp2": 29.0,
|
||||
"static_current1": 33.0,
|
||||
"static_current2": 34.0,
|
||||
"min_value": 30.0,
|
||||
"max_value": 40.0,
|
||||
"step": 0.5,
|
||||
"time_step": 50,
|
||||
"delay_time": 10,
|
||||
},
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": 1.0,
|
||||
"stop_hz": 2.0,
|
||||
"points": 2,
|
||||
"if_bandwidth_hz": 1.0,
|
||||
"stimulus_power_dbm": -10.0,
|
||||
},
|
||||
},
|
||||
"switches": {
|
||||
"port1": {"positions": 1},
|
||||
"port2": {"positions": 1},
|
||||
},
|
||||
}
|
||||
|
||||
encoded = RunConfigModel.from_dict(payload).to_dict()
|
||||
|
||||
self.assertEqual(encoded["radar"]["model"], "kamil_adc")
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["tty_path"], "/tmp/ttyADC_data")
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"])
|
||||
self.assertEqual(encoded["radar"]["kamil_adc"]["env"], {"ADC_ENV": "1"})
|
||||
self.assertEqual(encoded["radar"]["laser_control"]["mode"], "variation")
|
||||
self.assertEqual(
|
||||
encoded["radar"]["laser_control"]["variation"]["variation_type"],
|
||||
"CHANGE_CURRENT_LD2",
|
||||
)
|
||||
|
||||
def test_supervisor_selects_kamil_adc_producer(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
config_path = Path(tmp_dir) / "run_config.json"
|
||||
config_path.write_text(json.dumps({"radar": {"model": "kamil_adc"}}), encoding="utf-8")
|
||||
|
||||
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
|
||||
|
||||
self.assertEqual(
|
||||
command,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"python_app.scripts.kamil_adc_raw_producer",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -145,11 +145,7 @@ def _load_radar_config_variant(path: Path, *, base_config: RunConfigModel) -> Ra
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
extra_serials=(
|
||||
config.radar.multi_device.slave_serials
|
||||
if config.is_multi_device
|
||||
else None
|
||||
),
|
||||
extra_serials=config.radar_key_extra_parts() or None,
|
||||
)
|
||||
return RadarConfigVariant(
|
||||
source_path=path,
|
||||
|
||||
@@ -241,11 +241,7 @@ class SequentialCaptureSession:
|
||||
sweep_points=self._config.radar.sweep.points,
|
||||
ifbw_hz=self._config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=self._config.radar.sweep.power_dbm,
|
||||
extra_serials=(
|
||||
self._config.radar.multi_device.slave_serials
|
||||
if self._config.is_multi_device
|
||||
else None
|
||||
),
|
||||
extra_serials=self._config.radar_key_extra_parts() or None,
|
||||
)
|
||||
store.save_set(self._kind, radar_key, self._set_name, collection)
|
||||
return radar_key, collection
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
numpy>=1.26,<3
|
||||
libusb1>=3.1
|
||||
pyserial>=3.5,<4
|
||||
pyvisa>=1.14
|
||||
PyQt6>=6.6
|
||||
pyqtgraph>=0.13.7
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"serial": "kamil_adc",
|
||||
"driver_mode": "native",
|
||||
"mock_signal_hz": 5000000.0,
|
||||
"multi_device": {
|
||||
"slave_serials": [],
|
||||
"force_external_reference": false,
|
||||
"recovery_attempts": 3
|
||||
},
|
||||
"kamil_adc": {
|
||||
"project_dir": "/home/europa/Documents/kamil_adc",
|
||||
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
|
||||
"tty_path": "/tmp/ttyADC_data",
|
||||
"args": [
|
||||
"profile:phase",
|
||||
"clock:internal",
|
||||
"internal_ref_hz:2000000",
|
||||
"mode:diff",
|
||||
"channels:2",
|
||||
"ch1:2",
|
||||
"ch2:3",
|
||||
"do1_toggle_per_frame",
|
||||
"do1_pair_subtract_avg"
|
||||
],
|
||||
"env": {},
|
||||
"startup_timeout_s": 5.0,
|
||||
"sweep_timeout_s": 5.0,
|
||||
"stop_timeout_s": 2.0
|
||||
},
|
||||
"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,
|
||||
"manual": {
|
||||
"temp1": 25.0,
|
||||
"temp2": 25.0,
|
||||
"current1": 30.0,
|
||||
"current2": 30.0
|
||||
},
|
||||
"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
|
||||
}
|
||||
},
|
||||
"sweep": {
|
||||
"start_hz": 1000000.0,
|
||||
"stop_hz": 6000000000.0,
|
||||
"points": 201,
|
||||
"if_bandwidth_hz": 50000.0,
|
||||
"stimulus_power_dbm": -10.0
|
||||
}
|
||||
},
|
||||
"switches": {
|
||||
"port1": {
|
||||
"name": "port1",
|
||||
"driver_mode": "native",
|
||||
"driver": "h7992",
|
||||
"radar_port": 1,
|
||||
"positions": 2,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 17,
|
||||
"pin_b": 27,
|
||||
"invert_logic": false
|
||||
},
|
||||
"port2": {
|
||||
"name": "port2",
|
||||
"driver_mode": "native",
|
||||
"driver": "h7992",
|
||||
"radar_port": 2,
|
||||
"positions": 4,
|
||||
"default_position": 0,
|
||||
"gpio_chip": "/dev/gpiochip0",
|
||||
"pin_a": 22,
|
||||
"pin_b": 23,
|
||||
"invert_logic": false
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": 0,
|
||||
"idle_sleep_ms": 2,
|
||||
"continuous": true,
|
||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||
"locator_server": {
|
||||
"device_id": 3,
|
||||
"protocol_version": 1,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8888,
|
||||
"max_payload_bytes": 65536,
|
||||
"client_queue_size": 32,
|
||||
"logger_name": "locator_runtime"
|
||||
},
|
||||
"combos": [
|
||||
{
|
||||
"input": 0,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 0
|
||||
},
|
||||
{
|
||||
"input": 0,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 1,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 2,
|
||||
"output": 1
|
||||
},
|
||||
{
|
||||
"input": 3,
|
||||
"output": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"preprocess": {
|
||||
"s21": {
|
||||
"calibration": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"s11": {
|
||||
"calibration": {
|
||||
"open": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"short": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
},
|
||||
"load": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"reference": {
|
||||
"set_name": "",
|
||||
"bundle_path": ""
|
||||
}
|
||||
},
|
||||
"notch": {
|
||||
"enabled": true,
|
||||
"bands_hz": [],
|
||||
"taper_width_hz": 40000000.0,
|
||||
"taper_type": "cosine"
|
||||
}
|
||||
},
|
||||
"gpr": {
|
||||
"relative_permittivity": 1.0,
|
||||
"tx_geometry": [
|
||||
{
|
||||
"output_pos": 0,
|
||||
"x_m": 0.905
|
||||
},
|
||||
{
|
||||
"output_pos": 1,
|
||||
"x_m": -0.905
|
||||
}
|
||||
],
|
||||
"rx_geometry": [
|
||||
{
|
||||
"input_pos": 0,
|
||||
"x_m": -0.18
|
||||
},
|
||||
{
|
||||
"input_pos": 1,
|
||||
"x_m": 0.485
|
||||
},
|
||||
{
|
||||
"input_pos": 2,
|
||||
"x_m": -0.49
|
||||
},
|
||||
{
|
||||
"input_pos": 3,
|
||||
"x_m": 0.185
|
||||
}
|
||||
]
|
||||
},
|
||||
"rings": {
|
||||
"raw": {
|
||||
"name": "/radar_raw_kamil_adc",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"raw_tap": {
|
||||
"name": "/radar_raw_tap_kamil_adc",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed": {
|
||||
"name": "/radar_preprocessed_kamil_adc",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"preprocessed_tap": {
|
||||
"name": "/radar_preprocessed_tap_kamil_adc",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
},
|
||||
"results": {
|
||||
"name": "/radar_results_kamil_adc",
|
||||
"capacity": 50,
|
||||
"slot_size_bytes": 2097152
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user