diff --git a/README.md b/README.md index 47dfe77..11eebb6 100644 --- a/README.md +++ b/README.md @@ -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 ``` - diff --git a/docs/operation_modes.md b/docs/operation_modes.md index 4215173..f837736 100644 --- a/docs/operation_modes.md +++ b/docs/operation_modes.md @@ -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:` 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 diff --git a/docs/run_config.md b/docs/run_config.md index 0845fb9..c785ea8 100644 --- a/docs/run_config.md +++ b/docs/run_config.md @@ -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:`. | +| `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" + } +} +``` diff --git a/python_app/gui/controllers/app_window_config/radar_limits_mixin.py b/python_app/gui/controllers/app_window_config/radar_limits_mixin.py index ea5076c..1e17586 100644 --- a/python_app/gui/controllers/app_window_config/radar_limits_mixin.py +++ b/python_app/gui/controllers/app_window_config/radar_limits_mixin.py @@ -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: diff --git a/python_app/gui/controllers/app_window_config/state_builders.py b/python_app/gui/controllers/app_window_config/state_builders.py index 521749c..dc91744 100644 --- a/python_app/gui/controllers/app_window_config/state_builders.py +++ b/python_app/gui/controllers/app_window_config/state_builders.py @@ -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 diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index a8c11cd..14eff84 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -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): diff --git a/python_app/hardware_full/kamil_adc_service.py b/python_app/hardware_full/kamil_adc_service.py new file mode 100644 index 0000000..5bf5fcf --- /dev/null +++ b/python_app/hardware_full/kamil_adc_service.py @@ -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(" 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:; 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), + ) diff --git a/python_app/hardware_full/laser_control/__init__.py b/python_app/hardware_full/laser_control/__init__.py new file mode 100644 index 0000000..597abdd --- /dev/null +++ b/python_app/hardware_full/laser_control/__init__.py @@ -0,0 +1,3 @@ +"""Vendored laser-control package used by the Kamil ADC hardware path.""" + +__version__ = "1.0.0" diff --git a/python_app/hardware_full/laser_control/constants.py b/python_app/hardware_full/laser_control/constants.py new file mode 100644 index 0000000..ac63179 --- /dev/null +++ b/python_app/hardware_full/laser_control/constants.py @@ -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 \ No newline at end of file diff --git a/python_app/hardware_full/laser_control/controller.py b/python_app/hardware_full/laser_control/controller.py new file mode 100644 index 0000000..1fa1f07 --- /dev/null +++ b/python_app/hardware_full/laser_control/controller.py @@ -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 \ No newline at end of file diff --git a/python_app/hardware_full/laser_control/conversions.py b/python_app/hardware_full/laser_control/conversions.py new file mode 100644 index 0000000..a0eced7 --- /dev/null +++ b/python_app/hardware_full/laser_control/conversions.py @@ -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 \ No newline at end of file diff --git a/python_app/hardware_full/laser_control/exceptions.py b/python_app/hardware_full/laser_control/exceptions.py new file mode 100644 index 0000000..fd3fad3 --- /dev/null +++ b/python_app/hardware_full/laser_control/exceptions.py @@ -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) \ No newline at end of file diff --git a/python_app/hardware_full/laser_control/models.py b/python_app/hardware_full/laser_control/models.py new file mode 100644 index 0000000..df547d5 --- /dev/null +++ b/python_app/hardware_full/laser_control/models.py @@ -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 \ No newline at end of file diff --git a/python_app/hardware_full/laser_control/protocol.py b/python_app/hardware_full/laser_control/protocol.py new file mode 100644 index 0000000..368d5d8 --- /dev/null +++ b/python_app/hardware_full/laser_control/protocol.py @@ -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(' bytes: + return struct.pack(' 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.") \ No newline at end of file diff --git a/python_app/hardware_full/laser_control/validators.py b/python_app/hardware_full/laser_control/validators.py new file mode 100644 index 0000000..98aa288 --- /dev/null +++ b/python_app/hardware_full/laser_control/validators.py @@ -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'), + } \ No newline at end of file diff --git a/python_app/hardware_full/single_radar_service.py b/python_app/hardware_full/single_radar_service.py index a2aa2c3..681b439 100644 --- a/python_app/hardware_full/single_radar_service.py +++ b/python_app/hardware_full/single_radar_service.py @@ -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}") diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index c2f5b6e..71986cf 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -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, diff --git a/python_app/models/run_config_model.py b/python_app/models/run_config_model.py index 21c62c6..cd453bc 100644 --- a/python_app/models/run_config_model.py +++ b/python_app/models/run_config_model.py @@ -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", diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 1623da2..51a3f58 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -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: diff --git a/python_app/orchestration/process_supervisor.py b/python_app/orchestration/process_supervisor.py index 2901f76..a72a97f 100644 --- a/python_app/orchestration/process_supervisor.py +++ b/python_app/orchestration/process_supervisor.py @@ -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", diff --git a/python_app/scripts/kamil_adc_raw_producer.py b/python_app/scripts/kamil_adc_raw_producer.py new file mode 100644 index 0000000..021d336 --- /dev/null +++ b/python_app/scripts/kamil_adc_raw_producer.py @@ -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()) diff --git a/python_app/scripts/manual_smoke_run.py b/python_app/scripts/manual_smoke_run.py index d03ab88..cf34cc1 100644 --- a/python_app/scripts/manual_smoke_run.py +++ b/python_app/scripts/manual_smoke_run.py @@ -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) diff --git a/python_app/tests/__init__.py b/python_app/tests/__init__.py new file mode 100644 index 0000000..0589f42 --- /dev/null +++ b/python_app/tests/__init__.py @@ -0,0 +1 @@ +"""Python unit tests for radar_system.""" diff --git a/python_app/tests/test_kamil_adc_service.py b/python_app/tests/test_kamil_adc_service.py new file mode 100644 index 0000000..c1d6415 --- /dev/null +++ b/python_app/tests/test_kamil_adc_service.py @@ -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(" bytes: + return struct.pack(" 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() diff --git a/python_app/workflows/radar_config_variants.py b/python_app/workflows/radar_config_variants.py index 0020510..6960108 100644 --- a/python_app/workflows/radar_config_variants.py +++ b/python_app/workflows/radar_config_variants.py @@ -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, diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index 3f17ba8..a499e42 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 48de90b..c14308d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/run_config_kamil_adc.example.json b/run_config_kamil_adc.example.json new file mode 100644 index 0000000..4ca5ff5 --- /dev/null +++ b/run_config_kamil_adc.example.json @@ -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 + } + } +}