From cfe81f782b6f66f4ccbd92a14e63a36c3aff21c1 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Tue, 6 Oct 2026 19:36:00 +0300 Subject: [PATCH] Add RFG UDP source to rph_radar --- README.md | 23 ++- .../rph_radar_collector/README.md | 6 +- .../hardware_full/laser_control/constants.py | 2 +- .../hardware_full/laser_control/controller.py | 6 +- .../laser_control/conversions.py | 18 ++- .../hardware_full/laser_control/exceptions.py | 2 +- .../hardware_full/laser_control/protocol.py | 6 +- .../hardware_full/rph_radar/protocol.py | 84 +++++++++- python_app/hardware_full/rph_radar/service.py | 46 ++++-- .../{tty_reader.py => stream_reader.py} | 140 ++++++++++++---- python_app/models/run_config_codec.py | 2 + python_app/models/run_config_schema.py | 52 +++--- python_app/models/run_config_validation.py | 37 +++++ python_app/scripts/rph_radar_raw_producer.py | 7 +- python_app/tests/test_hardware_lifecycle.py | 6 +- .../tests/test_laser_control_protocol.py | 15 +- python_app/tests/test_rph_radar_protocol.py | 77 ++++++++- python_app/tests/test_rph_radar_service.py | 62 +++++++- python_app/tests/test_run_config_codec.py | 32 ++++ run_config.json | 34 ++-- .../run_config_librevna.example.json | 36 ++--- .../run_config_librevna_multi.example.json | 36 ++--- .../run_config_librevna_multi.example2.json | 36 ++--- ...nfig_librevna_multi_per_point.example.json | 36 ++--- ...revna_multi_per_point_lowband.example.json | 36 ++--- .../run_config_rph_radar.example.json | 39 +++-- .../run_config_rph_radar_rfg.example.json | 150 ++++++++++++++++++ .../run_config_simulator.example.json | 36 ++--- run_configs/run_config.json | 34 ++-- 29 files changed, 817 insertions(+), 279 deletions(-) rename python_app/hardware_full/rph_radar/{tty_reader.py => stream_reader.py} (55%) create mode 100644 run_config_examples/run_config_rph_radar_rfg.example.json diff --git a/README.md b/README.md index 005435e..61b6d25 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ skips overwritten frames and continues from the retained tail: 1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps, and publish raw S21 traces. Both single and multi-device LibreVNA use - `matrix_raw_producer`; RadioPhotonic radar (RPH) uses `rph_radar_raw_producer` with its C++ collector. + `matrix_raw_producer`; RadioPhotonic radar (RPH) uses `rph_radar_raw_producer` with its C++ + collector (E-502) or the RFG board's UDP stream. 2. **Preprocessing** — `data_preprocessor` applies through calibration, reference subtraction, optional frequency-domain notch filtering, and an optional object-based MIMO correction of S21. @@ -47,9 +48,18 @@ controls, processing settings and a copy of the live plot through an embedded we - **librevna_multi** — synchronized multi-device LibreVNA. - **rph_radar** — [RadioPhotonic radar (RPH) collector](data_acq_and_processing/rph_radar_collector/README.md), with manual laser setpoints or LD1 current variation via **Apply Radar**. - The application owns the collector and exchanges complete sweeps over a PTY. - Switching to a different antenna pair starts a fresh ADC session after settling; - queued samples and an initial partial sweep are discarded before using that pair. + `radar.rph_radar.source` selects the ADC: + - `e502` (default) — the application owns the collector and exchanges complete + sweeps over a PTY. Switching to a different antenna pair starts a fresh ADC session + after settling, discarding queued samples and an initial partial sweep. + - `rfg_udp` — the RFG board sends averaged sweep points to `udp_bind` + (`run_config_rph_radar_rfg.example.json`); main = ch0 − ch1, reference = ch2. It + needs `laser_control.mode: variation` (even when the laser is started elsewhere): + a sweep must have exactly the task's step count, `ceil((max − min) / step)` in DAC + codes, with no lost points; others are dropped (the board loses a few percent). + Capture windows come from kernel receive timestamps. Only one antenna pair (both + switches with one position): without a source-side marker a sweep after a switch + cannot be proven fresh. - **mock / simulator** — driver-less mode for development without hardware. ## Processing modes @@ -123,7 +133,7 @@ python3 -m venv .venv .venv/bin/python -m pip install --upgrade pip .venv/bin/python -m pip install -r requirements.txt make # data_preprocessor and data_processor in build/bin/ -make rph_radar_collector # also required for RadioPhotonic radar (RPH) acquisition +make rph_radar_collector # RadioPhotonic radar (RPH) with radar.rph_radar.source: e502 ``` `make` builds the sar3d GPU code when it finds CUDA (see Requirements) and @@ -360,7 +370,8 @@ measurement key and are not selected by the renamed mode. ### RPH phase calibration -Use `run_config_examples/run_config_rph_radar.example.json` for **RadioPhotonic radar (RPH)**. +Use `run_config_examples/run_config_rph_radar.example.json` (E-502) or +`run_config_rph_radar_rfg.example.json` (RFG board) for **RadioPhotonic radar (RPH)**. Apply the desired laser settings with **Apply Radar**, then stop acquisition before running the calibration tool: diff --git a/data_acq_and_processing/rph_radar_collector/README.md b/data_acq_and_processing/rph_radar_collector/README.md index 6b59345..ceffbbb 100644 --- a/data_acq_and_processing/rph_radar_collector/README.md +++ b/data_acq_and_processing/rph_radar_collector/README.md @@ -5,8 +5,10 @@ dual-channel main + reference IQ stream and publishes complete `RPH1` sweeps ove a PTY. `RphRadarService` in `python_app/hardware_full/rph_radar` reads and processes that stream for `python_app.scripts.rph_radar_raw_producer`. -This is the data *source* for `radar.model == "rph_radar"`, paired with the Python -`rph_radar_raw_producer`. LibreVNA acquisition uses Python drivers. +This is the data *source* for `radar.model == "rph_radar"` with +`radar.rph_radar.source: "e502"`, paired with the Python `rph_radar_raw_producer`. +The `rfg_udp` source reads the RFG board's UDP stream directly and does not use this +binary. LibreVNA acquisition uses Python drivers. ## Layout diff --git a/python_app/hardware_full/laser_control/constants.py b/python_app/hardware_full/laser_control/constants.py index 53a270c..7559152 100644 --- a/python_app/hardware_full/laser_control/constants.py +++ b/python_app/hardware_full/laser_control/constants.py @@ -39,7 +39,7 @@ 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 +CURRENT_MAX_MA = 110.0 # Maximum allowed laser current, mA (lab sweeps reach 107 mA) # Variation step limits CURRENT_STEP_MIN_MA = 0.002 # Minimum current variation step, mA diff --git a/python_app/hardware_full/laser_control/controller.py b/python_app/hardware_full/laser_control/controller.py index 883a48b..6d4f6e9 100644 --- a/python_app/hardware_full/laser_control/controller.py +++ b/python_app/hardware_full/laser_control/controller.py @@ -8,17 +8,13 @@ from .exceptions import CommunicationError from .protocol import Protocol logger = logging.getLogger(__name__) -DEFAULT_PI_P = 2560 -DEFAULT_PI_I = 128 DEVICE_MAIN_MESSAGE_ID = 0x00FF class LaserController: """Control constant setpoints or LD1 current variation through one serial port.""" - def __init__(self, port: str, - pi_coeff1_p: int = DEFAULT_PI_P, pi_coeff1_i: int = DEFAULT_PI_I, - pi_coeff2_p: int = DEFAULT_PI_P, pi_coeff2_i: int = DEFAULT_PI_I): + def __init__(self, port: str, *, pi_coeff1_p: int, pi_coeff1_i: int, pi_coeff2_p: int, pi_coeff2_i: int): self._protocol = Protocol(port) self._pi = dict(pi_coeff1_p=pi_coeff1_p, pi_coeff1_i=pi_coeff1_i, pi_coeff2_p=pi_coeff2_p, pi_coeff2_i=pi_coeff2_i) diff --git a/python_app/hardware_full/laser_control/conversions.py b/python_app/hardware_full/laser_control/conversions.py index 2486c4d..1df3ea0 100644 --- a/python_app/hardware_full/laser_control/conversions.py +++ b/python_app/hardware_full/laser_control/conversions.py @@ -39,6 +39,18 @@ def current_ma_to_n(current_ma: float) -> int: 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 + # Rounded like the lab GUI (_device_main.py), so both send identical DAC codes. + return max(0, min(ADC_BITS_16, round(ADC_BITS_16 / 2000 * RREF * current_ma))) + + +def variation_codes(min_value: float, max_value: float, step: float) -> tuple[int, int, int]: + low, high, delta = (current_ma_to_n(value) for value in (min_value, max_value, step)) + if delta == 0 or low >= high: + raise ValueError("Current variation range and step must be representable by the DAC") + return low, high, delta + + +def variation_step_count(min_value: float, max_value: float, step: float) -> int: + """SWEEP_STEP edges per sweep: firmware loops ``while (current < max) current += delta`` on these codes.""" + low, high, delta = variation_codes(min_value, max_value, step) + return math.ceil((high - low) / delta) diff --git a/python_app/hardware_full/laser_control/exceptions.py b/python_app/hardware_full/laser_control/exceptions.py index b5cd636..249b20b 100644 --- a/python_app/hardware_full/laser_control/exceptions.py +++ b/python_app/hardware_full/laser_control/exceptions.py @@ -11,7 +11,7 @@ class LaserControlError(Exception): pass -class ValidationError(LaserControlError): +class ValidationError(LaserControlError, ValueError): """Base exception for validation errors.""" pass diff --git a/python_app/hardware_full/laser_control/protocol.py b/python_app/hardware_full/laser_control/protocol.py index 230588d..b364392 100644 --- a/python_app/hardware_full/laser_control/protocol.py +++ b/python_app/hardware_full/laser_control/protocol.py @@ -9,7 +9,7 @@ from operator import xor import serial from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC -from .conversions import current_ma_to_n, temp_c_to_n +from .conversions import current_ma_to_n, temp_c_to_n, variation_codes from .validators import validate_manual, validate_variation from .exceptions import CommunicationError, PortBusyError, ProtocolError @@ -116,9 +116,7 @@ class Protocol: raise ValueError("radar.laser_control.variation.variation_type must be CHANGE_CURRENT_LD1") validate_manual(static_temp1, static_temp2, static_current1, static_current2) validate_variation(min_value, max_value, step, time_step, delay_time) - low, high, delta = (current_ma_to_n(value) for value in (min_value, max_value, step)) - if delta == 0 or low >= high: - raise ValueError("Current variation range and step must be representable by the DAC") + low, high, delta = variation_codes(min_value, max_value, step) return _command( 0x7777, 0x37ff, 1, low, high, delta, int(time_step * 100), temp_c_to_n(static_temp1), current_ma_to_n(static_current2), temp_c_to_n(static_temp2), diff --git a/python_app/hardware_full/rph_radar/protocol.py b/python_app/hardware_full/rph_radar/protocol.py index ffcb82a..8439b73 100644 --- a/python_app/hardware_full/rph_radar/protocol.py +++ b/python_app/hardware_full/rph_radar/protocol.py @@ -1,10 +1,15 @@ -"""Whole-sweep RPH1 packets from the collector's PTY (little endian). +"""RPH sweep sources, both little endian. -The 48-byte header carries payload size, source sweep/cycle IDs, monotonic capture -window, input/output (65535 for untagged) and dirty flag. Payload points are -8-byte ``marker, step, I, Q`` frames: A for main and A8 for reference. -Only complete queued packets may be dropped; a packet already being written is -always finished. Thus overflow cannot splice two physical sweeps together. +E-502 collector PTY: whole-sweep RPH1 packets. The 48-byte header carries payload +size, source sweep/cycle IDs, monotonic capture window, input/output (65535 for +untagged) and dirty flag. Payload points are 8-byte ``marker, step, I, Q`` frames: +A for main and A8 for reference. Only complete queued packets may be dropped; a +packet already being written is always finished, so overflow cannot splice sweeps. + +RFG board UDP (``SWEEP_AVG=1``): a ``u16 values_per_point, u16 q_shift`` header and +28-byte points ``u32 (sweep << 24) | step`` plus six int32 Q(q_shift) step averages +``[a1c0, a1c1, a1c2, a2c0, a2c1, a2c2]``. Channel k is ``a1ck + j*a2ck``; the main +signal is the synchronous detector ch0 - ch1 and the reference is ch2. """ from dataclasses import dataclass @@ -18,6 +23,8 @@ REFERENCE_MARKER = 0x00A8 _HEADER = struct.Struct("<4sIQQQQHHI") _POINT = struct.Struct(" None: + self._steps, self._step_ns, self._period_ns = steps, step_ns, period_ns + self._sweep_id: int | None = None # newest sweep; its points may still be arriving + self._last_ns = 0 + self._points: list[tuple[int, complex, complex, int]] = [] + self._count = 0 + + def reset(self) -> None: + """Drop the sweep in progress; its remaining points then form a dirty sweep.""" + self._sweep_id, self._points = None, [] + + def feed(self, datagram: bytes, received_ns: int) -> list[RawSweep]: + if len(datagram) < _RFG_HEADER.size: + raise ValueError("RFG protocol violation: truncated datagram") + values, q_shift = _RFG_HEADER.unpack_from(datagram) + if values != 6 or q_shift > 30 or (len(datagram) - _RFG_HEADER.size) % _RFG_POINT.size: + raise ValueError("RFG protocol violation: unexpected datagram layout") + scale = 1.0 / (1 << q_shift) + sweeps = [] + for word, a1c0, a1c1, a1c2, a2c0, a2c1, a2c2 in _RFG_POINT.iter_unpack(datagram[_RFG_HEADER.size:]): + if word >> 24 != self._sweep_id: + if (self._sweep_id is not None and not 0 < ((word >> 24) - self._sweep_id) % 256 < 128 + and received_ns - self._last_ns < 128 * self._period_ns): + continue + sweeps.extend(self._close()) + self._sweep_id = word >> 24 + self._last_ns = received_ns + if len(self._points) <= self._steps: # A stuck id cannot grow the buffer; the sweep is dirty. + self._points.append((word & 0xFFFFFF, complex(a1c0 - a1c1, a2c0 - a2c1) * scale, + complex(a1c2, a2c2) * scale, received_ns)) + return sweeps + + def _close(self) -> list[RawSweep]: + points, self._points = self._points, [] + if not points: + return [] + steps = np.asarray([point[0] for point in points], dtype=np.int32) + starts = [received - (step + 1) * self._step_ns for step, _, _, received in points] + start = min(starts) + # Every point of one sweep implies the same start within batching/latency jitter; an + # 8-bit id reused after an outage (256 sweeps, ~5 s) would splice two sweeps otherwise. + spliced = max(starts) - start > self._steps * self._step_ns + self._count += 1 + return [RawSweep( + steps, + np.asarray([point[1] for point in points], dtype=np.complex64), + np.asarray([point[2] for point in points], dtype=np.complex64), + self._count, self._count, start, start + self._steps * self._step_ns, + dirty=spliced or not np.array_equal(steps, np.arange(self._steps)), + )] diff --git a/python_app/hardware_full/rph_radar/service.py b/python_app/hardware_full/rph_radar/service.py index 0049989..c129f3c 100644 --- a/python_app/hardware_full/rph_radar/service.py +++ b/python_app/hardware_full/rph_radar/service.py @@ -1,8 +1,9 @@ -"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed sweeps. +"""Service that serves processed RadioPhotonic radar (RPH) sweeps. -Owns the lifecycle of the external collector process and its TTY reader, and maps -each raw (main, reference) sweep to an :class:`SweepResult` on the fixed -processing grid via :class:`RphRadarSweepProcessor`. +With ``source == "e502"`` it owns the external collector process and its TTY; with +``"rfg_udp"`` it reads the RFG board's UDP stream directly. Each raw (main, +reference) sweep maps to a :class:`SweepResult` on the fixed processing grid via +:class:`RphRadarSweepProcessor`. """ from __future__ import annotations @@ -22,15 +23,13 @@ import time import numpy as np from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor -from python_app.hardware_full.rph_radar.protocol import RawSweep -from python_app.hardware_full.rph_radar.tty_reader import ( - RphRadarTtyReader, - raise_if_process_exited, -) +from python_app.hardware_full.laser_control.conversions import variation_step_count +from python_app.hardware_full.rph_radar.protocol import RawSweep, RfgSweepAssembler +from python_app.hardware_full.rph_radar.stream_reader import RphRadarStreamReader, raise_if_process_exited from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.dataset_model import ComboKey from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel -from python_app.models.run_config_validation import validate_rph_radar_model +from python_app.models.run_config_validation import validate_rfg_source, validate_rph_radar_model logger = logging.getLogger(__name__) @@ -51,12 +50,12 @@ _STOP_KILL_GRACE_S = 0.5 @dataclass(slots=True) class RphRadarService: - """Launch the external RadioPhotonic radar (RPH) collector and serve its processed sweeps.""" + """Serve processed RadioPhotonic radar (RPH) sweeps from the E-502 collector or the RFG stream.""" config: RunConfigModel collector_driven: bool = False _process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False) - _reader: RphRadarTtyReader | None = field(init=False, default=None, repr=False) + _reader: RphRadarStreamReader | None = field(init=False, default=None, repr=False) _processor: RphRadarSweepProcessor | None = field(init=False, default=None, repr=False) _rejected_count: int = field(init=False, default=0, repr=False) _prepared_combo: ComboKey | None = field(init=False, default=None, repr=False) @@ -81,13 +80,25 @@ class RphRadarService: return cmd def open(self, *, stop_event: threading.Event | None = None) -> None: - """Launch the collector and start the TTY reader thread. + """Bind the RFG UDP stream, or launch the E-502 collector and start its TTY reader. An optional ``stop_event`` lets a caller abort the TTY-wait loop promptly (e.g. on shutdown) instead of blocking for the full startup timeout. """ if self._reader is not None: return + adc = self.config.radar.rph_radar + if adc.source == "rfg_udp": + variation = self.config.radar.laser_control.variation + steps = variation_step_count(variation.min_value, variation.max_value, variation.step) + step_ns = variation.time_step * 1000 + # Lower bound of the sweep period: the firmware idles at least delay_time - 1 ms. + period_ns = steps * step_ns + (variation.delay_time - 1) * 1_000_000 + reader = RphRadarStreamReader(adc.udp_bind, RfgSweepAssembler(steps, step_ns, period_ns)) + reader.open() + self._reader = reader + logger.info("RadioPhotonic radar (RPH) RFG stream opened on %s (%d steps per sweep)", adc.udp_bind, steps) + return if self._process is not None and self._process.poll() is None: raise RuntimeError("Previous RPH collector did not stop; cannot start a fresh capture") self._stop_event = stop_event @@ -95,7 +106,7 @@ class RphRadarService: try: self._start_process() self._wait_for_tty(previous_tty_identity, stop_event=stop_event) - reader = RphRadarTtyReader(self.config.radar.rph_radar.tty_path) + reader = RphRadarStreamReader(self.config.radar.rph_radar.tty_path) reader.open() self._reader = reader logger.info("RadioPhotonic radar (RPH) service opened") @@ -105,7 +116,7 @@ class RphRadarService: raise def close(self) -> None: - """Stop the TTY reader and the external collector process.""" + """Stop the reader and, for E-502, the external collector process.""" logger.debug("Closing RadioPhotonic radar (RPH) service") self._prepared_combo = None if self._reader is not None: @@ -157,7 +168,7 @@ class RphRadarService: if self._reader is None: raise RuntimeError("RadioPhotonic radar (RPH) service is not open") process = self._process - if process is None or process.poll() is not None: + if self.config.radar.rph_radar.source == "e502" and (process is None or process.poll() is not None): return_code = None if process is None else process.poll() raise RuntimeError(f"RadioPhotonic radar (RPH) collector is not running (code={return_code})") @@ -334,6 +345,9 @@ class RphRadarService: adc = self.config.radar.rph_radar validate_rph_radar_model(adc) + if adc.source == "rfg_udp": + validate_rfg_source(self.config) + return if not adc.executable_path: raise ValueError("radar.rph_radar.executable_path is required") if not adc.tty_path: diff --git a/python_app/hardware_full/rph_radar/tty_reader.py b/python_app/hardware_full/rph_radar/stream_reader.py similarity index 55% rename from python_app/hardware_full/rph_radar/tty_reader.py rename to python_app/hardware_full/rph_radar/stream_reader.py index 6a1751e..df5feee 100644 --- a/python_app/hardware_full/rph_radar/tty_reader.py +++ b/python_app/hardware_full/rph_radar/stream_reader.py @@ -1,8 +1,9 @@ -"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep. +"""Background reader publishing the latest completed RadioPhotonic radar (RPH) sweep. -The collector emits sweeps continuously, faster than callers invoke -:meth:`RphRadarService.acquire`. A daemon thread drains the device end of the -TTY non-stop, feeds the bytes to a :class:`RphRadarStreamParser`, and stores the +The source (E-502 collector TTY or RFG board UDP socket) emits sweeps continuously, +faster than callers invoke :meth:`RphRadarService.acquire`. A daemon thread drains +the descriptor non-stop, feeds it to :class:`RphRadarStreamParser` or +:class:`RfgSweepAssembler`, and stores the most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep` returns the freshest sweep; if a newer one arrives before the consumer reads, it overwrites the previous unread value — by design, since consumers always want the @@ -12,16 +13,20 @@ thread (fail-fast; the supervisor relaunches a clean collector). from __future__ import annotations +from contextlib import suppress from dataclasses import dataclass, field import errno import logging import os import select +import socket +import struct import subprocess import threading import time -from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser, RawSweep +from python_app.hardware_full.rph_radar.protocol import RawSweep, RfgSweepAssembler, RphRadarStreamParser +from python_app.models.run_config_validation import parse_udp_bind logger = logging.getLogger(__name__) @@ -31,6 +36,13 @@ _READ_CHUNK_BYTES = 65536 # select() poll interval — short enough to react to close() promptly, long enough # that idle CPU stays near zero. _READ_POLL_INTERVAL_S = 0.1 +# Kernel receive stamps (Linux SO_TIMESTAMPNS == SCM_TIMESTAMPNS on x86_64 and aarch64, +# a 64-bit timespec) are immune to the reader thread's scheduling stalls. +_SO_TIMESTAMPNS = 35 +_TIMESPEC = struct.Struct("qq") +# A REALTIME step between the kernel stamp and the clock read would mistranslate it; +# NTP slewing moves the REALTIME-monotonic offset by microseconds only. +_CLOCK_JUMP_NS = 1_000_000 def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None: @@ -42,11 +54,18 @@ def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None: raise RuntimeError(f"RadioPhotonic radar (RPH) collector exited with code {return_code}") -@dataclass(slots=True) -class RphRadarTtyReader: - """Daemon-thread TTY reader publishing the latest completed sweep.""" +def _clock_offset_ns() -> int: + return time.clock_gettime_ns(time.CLOCK_REALTIME) - time.monotonic_ns() - tty_path: str + +@dataclass(slots=True) +class RphRadarStreamReader: + """Daemon-thread reader of the collector TTY, or of the RFG UDP stream when ``rfg`` is set.""" + + endpoint: str # TTY path, or HOST:PORT bind address for the RFG stream + assembler: RfgSweepAssembler | None = None + _socket: socket.socket | None = field(init=False, default=None, repr=False) + _clock_offset_ns: int | None = field(init=False, default=None, repr=False) _fd: int | None = field(init=False, default=None, repr=False) _thread: threading.Thread | None = field(init=False, default=None, repr=False) _stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False) @@ -58,12 +77,29 @@ class RphRadarTtyReader: _combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False) _reader_error: Exception | None = field(init=False, default=None, repr=False) _published_count: int = field(init=False, default=0, repr=False) + last_dirty_size: int | None = field(init=False, default=None) def open(self) -> None: - """Open the TTY and start the background reader thread.""" + """Open the TTY or bind the UDP socket and start the background reader thread.""" if self._fd is not None: return - self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK) + if self.assembler is None: + self._fd = os.open(self.endpoint, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # Taken before bind, so a REALTIME step while the first datagrams queue is still seen. + offset_ns = _clock_offset_ns() + try: + # ~10k datagrams/s: a deep buffer rides out receiver stalls; the kernel caps it at rmem_max. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 << 20) + sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMPNS, 1) + sock.bind(parse_udp_bind(self.endpoint)) + sock.setblocking(False) + except OSError: + sock.close() + raise + self._socket, self._fd, self._clock_offset_ns = sock, sock.fileno(), offset_ns + logger.info("RFG socket receive buffer: %d bytes", sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)) self._stop_event.clear() self._latest_sweep = None self._combo_slots = {} @@ -71,21 +107,20 @@ class RphRadarTtyReader: self._published_count = 0 self._thread = threading.Thread( target=self._reader_loop, - name=f"rph-radar-tty-reader[{self.tty_path}]", + name=f"rph-radar-reader[{self.endpoint}]", daemon=True, ) try: self._thread.start() except Exception: self._thread = None - fd, self._fd = self._fd, None - os.close(fd) + self._close_descriptor() raise - logger.info("RadioPhotonic radar (RPH) TTY reader started on %s", self.tty_path) + logger.info("RadioPhotonic radar (RPH) reader started on %s", self.endpoint) def close(self) -> None: - """Stop the reader thread and close the TTY descriptor.""" - logger.debug("Stopping RadioPhotonic radar (RPH) TTY reader on %s", self.tty_path) + """Stop the reader thread and close the descriptor.""" + logger.debug("Stopping RadioPhotonic radar (RPH) reader on %s", self.endpoint) self._stop_event.set() with self._mailbox_cv: self._mailbox_cv.notify_all() @@ -94,15 +129,18 @@ class RphRadarTtyReader: if self._thread.is_alive(): logger.warning("RadioPhotonic radar (RPH) reader thread did not stop within 1.0s") self._thread = None - if self._fd is not None: - try: - os.close(self._fd) - finally: - self._fd = None + self._close_descriptor() self._latest_sweep = None self._combo_slots = {} self._reader_error = None + def _close_descriptor(self) -> None: + fd, sock, self._fd, self._socket = self._fd, self._socket, None, None + if sock is not None: + sock.close() + elif fd is not None: + os.close(fd) + @property def published_count(self) -> int: """Total number of sweeps the reader thread has produced.""" @@ -123,12 +161,12 @@ class RphRadarTtyReader: error caught by the reader thread. """ if self._thread is None: - raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is not open") + raise RuntimeError("RadioPhotonic radar (RPH) reader is not open") deadline = time.monotonic() + float(timeout_s) with self._mailbox_cv: while True: if self._stop_event.is_set(): - raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is closed") + raise RuntimeError("RadioPhotonic radar (RPH) reader is closed") # Deliver a pending sweep first: if the reader both published a # sweep and then died, the consumer still sees the good data and # only meets the error on the next call. @@ -142,23 +180,27 @@ class RphRadarTtyReader: raise_if_process_exited(process) remaining_s = deadline - time.monotonic() if remaining_s <= 0.0: + rejected = "" if self.last_dirty_size is None else f"; last rejected sweep had {self.last_dirty_size} points" raise TimeoutError( - f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s" + f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s{rejected}" ) self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s)) def _reader_loop(self) -> None: - """Drain the TTY, parse sweeps, and publish each completed one until stop.""" + """Drain the descriptor, parse sweeps, and publish each completed one until stop.""" parser = RphRadarStreamParser() try: while not self._stop_event.is_set(): - chunk = self._read_available() - if not chunk: - continue - for sweep in parser.feed(chunk): + if self.assembler is None: + chunk = self._read_available() + sweeps = parser.feed(chunk) if chunk else [] + else: + datagram = self._receive_datagram() + sweeps = self.assembler.feed(*datagram) if datagram is not None else [] + for sweep in sweeps: self._publish_sweep(sweep) except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep - logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.tty_path) + logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.endpoint) self._publish_error(exc) def _read_available(self) -> bytes: @@ -183,15 +225,44 @@ class RphRadarTtyReader: except OSError as exc: if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}: return b"" - raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) TTY `{self.tty_path}`: {exc}") from exc + raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) source `{self.endpoint}`: {exc}") from exc if not chunk: - raise RuntimeError(f"RadioPhotonic radar (RPH) TTY `{self.tty_path}` closed while reading") + raise RuntimeError(f"RadioPhotonic radar (RPH) source `{self.endpoint}` closed while reading") return chunk + def _receive_datagram(self) -> tuple[bytes, int] | None: + """Return one RFG datagram with its kernel receive time on the monotonic clock.""" + sock = self._socket + if sock is None or self._stop_event.is_set(): + return None + try: + readable, _, _ = select.select([sock], [], [], _READ_POLL_INTERVAL_S) + if not readable: + return None + datagram, ancillary, _, _ = sock.recvmsg(_READ_CHUNK_BYTES, socket.CMSG_SPACE(_TIMESPEC.size)) + except (BlockingIOError, InterruptedError): + return None + offset_ns, now_ns = _clock_offset_ns(), time.monotonic_ns() + stamps = [data for level, kind, data in ancillary if level == socket.SOL_SOCKET and kind == _SO_TIMESTAMPNS] + if not stamps: + raise RuntimeError("RFG datagram arrived without a kernel receive timestamp") + seconds, nanoseconds = _TIMESPEC.unpack(stamps[0]) + received_ns = seconds * 1_000_000_000 + nanoseconds - offset_ns + previous, self._clock_offset_ns = self._clock_offset_ns, offset_ns + if previous is None or abs(offset_ns - previous) > _CLOCK_JUMP_NS or received_ns > now_ns: + # Queued stamps may predate the step: drop them and the sweep in progress. + with suppress(BlockingIOError): + while True: + sock.recv(_READ_CHUNK_BYTES) + assert self.assembler is not None + self.assembler.reset() + return None + return datagram, received_ns + def _publish_sweep(self, sweep: RawSweep) -> None: """Publish a completed sweep to the mailbox(es), waking any waiter. - Dirty sweeps (those that straddled a switch transition) are counted but not + Dirty sweeps (straddling a switch, or incomplete RFG sweeps) are counted but not delivered: the collector re-takes that combination on the next sweep. Clean tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode) only update the single newest-sweep mailbox that read_sweep() serves. @@ -199,6 +270,7 @@ class RphRadarTtyReader: with self._mailbox_cv: self._published_count += 1 if sweep.dirty: + self.last_dirty_size = sweep.size self._mailbox_cv.notify_all() return self._latest_sweep = sweep diff --git a/python_app/models/run_config_codec.py b/python_app/models/run_config_codec.py index 2068bd7..cf9496e 100644 --- a/python_app/models/run_config_codec.py +++ b/python_app/models/run_config_codec.py @@ -165,6 +165,8 @@ def run_config_from_dict(payload: object) -> RunConfigModel: "stimulus_switch_positions", model.radar.multi_device.stimulus_switch_positions, ) + model.radar.rph_radar.source = _read_str(rph_radar_payload, "source", model.radar.rph_radar.source) + model.radar.rph_radar.udp_bind = _read_str(rph_radar_payload, "udp_bind", model.radar.rph_radar.udp_bind) model.radar.rph_radar.project_dir = _read_str( rph_radar_payload, "project_dir", model.radar.rph_radar.project_dir ) diff --git a/python_app/models/run_config_schema.py b/python_app/models/run_config_schema.py index 3f645ad..cb86731 100644 --- a/python_app/models/run_config_schema.py +++ b/python_app/models/run_config_schema.py @@ -89,8 +89,12 @@ class RphRadarBandModel: @dataclass(slots=True) class RphRadarModel: - """External RadioPhotonic radar (RPH) acquisition process settings.""" + """RadioPhotonic radar (RPH) acquisition: E-502 collector process or RFG board UDP stream.""" + # "e502": the C++ collector streams over tty_path (project_dir..stop_timeout_s are its + # settings); "rfg_udp": the RFG ADC board sends averaged sweep points to udp_bind. + source: str = "e502" + udp_bind: str = "0.0.0.0:5000" project_dir: str = "" executable_path: str = "" tty_path: str = "" @@ -107,12 +111,12 @@ class RphRadarModel: @dataclass(slots=True) class LaserManualModeModel: - """Manual laser-control setpoints.""" + """Manual laser-control setpoints (defaults: the lab GUI, RadioPhotonic_PCB_PC_software).""" - temp1: float = 25.0 - temp2: float = 25.0 - current1: float = 30.0 - current2: float = 30.0 + temp1: float = 24.0 + temp2: float = 24.9 + current1: float = 97.3 + current2: float = 102.0 @dataclass(slots=True) @@ -120,15 +124,15 @@ 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 + static_temp1: float = 24.0 + static_temp2: float = 24.9 + static_current1: float = 97.3 + static_current2: float = 102.0 + min_value: float = 97.3 + max_value: float = 107.0 + step: float = 0.05 + time_step: int = 50 + delay_time: int = 10 @dataclass(slots=True) @@ -138,10 +142,10 @@ class LaserControlModel: 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 + pi_coeff1_p: int = 1280 + pi_coeff1_i: int = 25 + pi_coeff2_p: int = 1280 + pi_coeff2_i: int = 179 manual: LaserManualModeModel = field(default_factory=LaserManualModeModel) variation: LaserVariationModeModel = field(default_factory=LaserVariationModeModel) @@ -410,10 +414,14 @@ class RunConfigModel: laser = radar.laser_control mode = laser.mode.strip().lower() optical = asdict(laser.variation if mode == "variation" else laser.manual) - physical = (asdict(radar.rph_radar.band), asdict(radar.rph_radar.phase_calibration), - radar.rph_radar.args, (self.input_switch.positions, self.output_switch.positions), + rph = radar.rph_radar + # The RFG source always runs the profile's sweep (its length and step timing), even when + # the laser is started elsewhere; E-502 keys stay as they were before the source field. + rfg = rph.source == "rfg_udp" + physical = (asdict(rph.band), asdict(rph.phase_calibration), rph.source if rfg else rph.args, + (self.input_switch.positions, self.output_switch.positions), (mode, optical, laser.pi_coeff1_p, laser.pi_coeff1_i, - laser.pi_coeff2_p, laser.pi_coeff2_i) if laser.enabled else None) + laser.pi_coeff2_p, laser.pi_coeff2_i) if laser.enabled or rfg else None) digest = hashlib.sha256(json.dumps(physical, sort_keys=True).encode()).hexdigest()[:16] return f"{radar.model}_{digest}" serials = [radar.serial or "no_serial"] diff --git a/python_app/models/run_config_validation.py b/python_app/models/run_config_validation.py index 34e8565..7d67ee1 100644 --- a/python_app/models/run_config_validation.py +++ b/python_app/models/run_config_validation.py @@ -4,11 +4,14 @@ from __future__ import annotations from collections.abc import Iterable from dataclasses import asdict +import ipaddress import logging import math import re from typing import Any +from python_app.hardware_full.laser_control.conversions import variation_step_count +from python_app.hardware_full.laser_control.validators import validate_variation from python_app.models.json_fields import ( finite_number, read_bool as _require_bool, read_int as _require_int, read_str as _require_str, @@ -138,6 +141,8 @@ def validate_acquisition_config(config: RunConfigModel) -> None: if config.is_rph_radar: validate_rph_radar_model(config.radar.rph_radar) + if config.radar.rph_radar.source == "rfg_udp": + validate_rfg_source(config) for switch, physical_positions in ( (config.input_switch, config.matrix_input_switch_positions), @@ -170,8 +175,40 @@ def validate_acquisition_config(config: RunConfigModel) -> None: raise ValueError(f"rings.{name}.slot_size_bytes must be at least {minimum} for this sweep") +def parse_udp_bind(text: str) -> tuple[str, int]: + """``HOST:PORT`` (empty host = all interfaces) -> socket address.""" + host, colon, port = text.rpartition(":") + try: + ipaddress.IPv4Address(host or "0.0.0.0") + except ValueError: + colon = "" + if not colon or not port.isdigit() or not 1 <= int(port) <= 65535: + raise ValueError(f"radar.rph_radar.udp_bind must be IPV4:PORT, got {text!r}") + return host or "0.0.0.0", int(port) + + +def validate_rfg_source(config: RunConfigModel) -> None: + """The RFG board numbers steps of the laser current sweep; its exact length must be known.""" + laser = config.radar.laser_control + if laser.mode.strip().lower() != "variation": + raise ValueError("radar.rph_radar.source rfg_udp requires radar.laser_control.mode 'variation'") + variation = laser.variation + validate_variation(variation.min_value, variation.max_value, variation.step, + variation.time_step, variation.delay_time) + if variation_step_count(variation.min_value, variation.max_value, variation.step) < 2: + raise ValueError("The laser current sweep must have at least two steps") + # Without a source-side marker a sweep after a switch cannot be proven fresh, so every + # capture path (run combos and the full calibration matrix) must see a single pair. + if config.input_switch.positions != 1 or config.output_switch.positions != 1: + raise ValueError("radar.rph_radar.source rfg_udp supports one antenna pair: both switches need 1 position") + + def validate_rph_radar_model(adc: RphRadarModel) -> None: """Reject phase/grid errors before stopping a running pipeline.""" + if adc.source not in ("e502", "rfg_udp"): + raise ValueError("radar.rph_radar.source must be e502 or rfg_udp") + if adc.source == "rfg_udp": + parse_udp_bind(adc.udp_bind) phase, band = adc.phase_calibration, adc.band for name, value in {**asdict(phase), **asdict(band)}.items(): if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): diff --git a/python_app/scripts/rph_radar_raw_producer.py b/python_app/scripts/rph_radar_raw_producer.py index f5ab460..d3623cd 100644 --- a/python_app/scripts/rph_radar_raw_producer.py +++ b/python_app/scripts/rph_radar_raw_producer.py @@ -58,11 +58,12 @@ def main() -> int: cleanup = ExitStack() try: publisher = RawPublisher(config.rings, cleanup) - # Switch-aware mode: with native switches the collector drives the RF switches + # Switch-aware mode: with native switches the E-502 collector drives the RF switches # itself and tags each sweep with its combo and source cycle. Otherwise Python - # owns the switches and starts a fresh collector session after each changed pair. + # owns the switches and starts a fresh source session after each changed pair. collector_driven = ( - config.input_switch.driver_mode == "native" + config.radar.rph_radar.source == "e502" + and config.input_switch.driver_mode == "native" and config.output_switch.driver_mode == "native" ) radar = RphRadarService( diff --git a/python_app/tests/test_hardware_lifecycle.py b/python_app/tests/test_hardware_lifecycle.py index 167e611..08fdc99 100644 --- a/python_app/tests/test_hardware_lifecycle.py +++ b/python_app/tests/test_hardware_lifecycle.py @@ -16,7 +16,7 @@ from unittest.mock import Mock, patch import numpy as np -from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader +from python_app.hardware_full.rph_radar.stream_reader import RphRadarStreamReader from python_app.hardware_full.laser_control.protocol import Protocol from python_app.hardware_full.librevna_service import LibreVnaService from python_app.hardware_full.librevna_driver.api.vna import VNAController @@ -252,7 +252,7 @@ class TtyLifecycleTest(unittest.TestCase): with self.subTest(combination=combination): master, slave = pty.openpty() tty.setraw(slave) - reader = RphRadarTtyReader(os.ttyname(slave)) + reader = RphRadarStreamReader(os.ttyname(slave)) errors = [] started = threading.Event() reader.open() @@ -280,7 +280,7 @@ class TtyLifecycleTest(unittest.TestCase): def test_failed_reader_thread_start_closes_descriptor(self) -> None: read_fd, write_fd = os.pipe() - reader = RphRadarTtyReader("/dev/test") + reader = RphRadarStreamReader("/dev/test") try: with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")): with self.assertRaises(RuntimeError): diff --git a/python_app/tests/test_laser_control_protocol.py b/python_app/tests/test_laser_control_protocol.py index 157639e..b60fd75 100644 --- a/python_app/tests/test_laser_control_protocol.py +++ b/python_app/tests/test_laser_control_protocol.py @@ -10,13 +10,15 @@ from unittest.mock import Mock, patch from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController from python_app.hardware_full.laser_control.exceptions import CommunicationError, ValidationError from python_app.hardware_full.laser_control.protocol import Protocol +from python_app.hardware_full.laser_control.conversions import variation_step_count from python_app.hardware_full.rph_radar.laser import apply_rph_radar_laser_control from python_app.models.run_config_schema import RunConfigModel -DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc2c163f" +# Generated by the lab GUI encoder (RadioPhotonic_PCB_PC_software device_commands) for these inputs. +DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acd2c173f" DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = ( - "7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600" + "7777ff3701003d2acd2c10008813ffa5cd2c18ab0a00000a8000000a8000b600" ) @@ -82,6 +84,9 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): ) self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX) + def test_sweep_step_count_follows_the_firmware_loop(self) -> None: + self.assertEqual(variation_step_count(97.3, 107, 0.05), 199) # 199 steps seen on the RFG stand + def test_start_sequence_matches_device_main_order(self) -> None: cleanup = ExitStack() self.addCleanup(cleanup.close) @@ -198,7 +203,7 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): apply_rph_radar_laser_control(config) def test_state_failure_cannot_report_command_success(self) -> None: - controller = LaserController("/dev/ttyLASER") + controller = LaserController("/dev/ttyLASER", pi_coeff1_p=1280, pi_coeff1_i=25, pi_coeff2_p=1280, pi_coeff2_i=179) for reply in (b"", b"\x00", b"\x04\x00", b"\x03\x00"): with self.subTest(reply=reply), patch.object(controller, "_protocol") as protocol: protocol.receive_raw.return_value = reply @@ -208,9 +213,9 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase): def test_invalid_settings_are_rejected_before_connect_or_reset(self) -> None: for mode, field, value in (("manual", "temp1", 100.0), - ("variation", "static_current2", 100.0), + ("variation", "static_current2", 150.0), ("variation", "step", 0.0), - ("variation", "step", 0.002), + ("variation", "max_value", 97.3001), # same DAC code as min ("variation", "variation_type", "CHANGE_TEMPERATURE_LD1"), ("pi", "pi_coeff1_p", 65536)): config = self._rph_config({"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual" if mode == "pi" else mode}) diff --git a/python_app/tests/test_rph_radar_protocol.py b/python_app/tests/test_rph_radar_protocol.py index ad3f74f..1c8f688 100644 --- a/python_app/tests/test_rph_radar_protocol.py +++ b/python_app/tests/test_rph_radar_protocol.py @@ -5,7 +5,7 @@ import unittest import numpy as np -from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser +from python_app.hardware_full.rph_radar.protocol import RfgSweepAssembler, RphRadarStreamParser def packet(*, source_id=1, cycle_id=1, start=100, end=200, combo=(1, 2), dirty=0, points=None): @@ -65,5 +65,80 @@ class RphRadarStreamParserTest(unittest.TestCase): self.assertTrue(sweep.dirty) +def rfg_datagram(sweep_id, *steps, values=(3, 1, 5, 4, 2, 6), q_shift=27): + """RFG SWEEP_AVG datagram; ``values`` are in units of 2**(q_shift - 7).""" + return struct.pack(" 0 wraps normally + self.assertTrue(sweep.dirty) + + def test_a_reused_id_after_an_outage_does_not_splice_two_sweeps(self): + assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS) + self.feed(assembler, 5, [0], 0) + self.feed(assembler, 5, [1, 2], 4_920_000_000) # 256 sweep periods later + (sweep,) = self.feed(assembler, 6, [0], 4_940_000_000) + self.assertTrue(sweep.dirty) + + def test_unexpected_layout_fails_fast(self): + for datagram in (b"\x06", struct.pack(" None: + config = self.config("rph_radar") + config.radar.driver_mode = "native" + config.radar.rph_radar.source = "rfg_udp" + config.radar.laser_control.mode = "variation" + config.input_switch.positions = config.output_switch.positions = 1 + config.combos = config.combos[:1] + validate_acquisition_config(config) + restored = RunConfigModel.from_dict(config.to_dict()) + self.assertEqual((restored.radar.rph_radar.source, restored.radar.rph_radar.udp_bind), + ("rfg_udp", "0.0.0.0:5000")) + e502 = RunConfigModel.from_dict(config.to_dict()) + e502.radar.rph_radar.source = "e502" + self.assertNotEqual(config.radar_key(), e502.radar_key()) + for field, value, message in (("source", "pcie", "source"), ("udp_bind", "5000", "udp_bind"), + ("udp_bind", "192.168.2.1:70000", "udp_bind"), + ("udp_bind", "192.168.2.l:5000", "udp_bind")): + broken = RunConfigModel.from_dict(config.to_dict()) + setattr(broken.radar.rph_radar, field, value) + with self.subTest(field=field, value=value), self.assertRaisesRegex(ValueError, message): + validate_acquisition_config(broken) + for change, message in ( + (lambda c: setattr(c.radar.laser_control, "mode", "manual"), "variation"), + (lambda c: setattr(c.input_switch, "positions", 2), "one antenna pair"), + (lambda c: setattr(c.radar.laser_control.variation, "time_step", 0), "time_step"), + (lambda c: setattr(c.radar.laser_control.variation, "max_value", 97.32), "two steps"), + ): + broken = RunConfigModel.from_dict(config.to_dict()) + change(broken) + with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): + validate_acquisition_config(broken) + def test_adc_phase_band_and_timeouts_fail_preflight(self) -> None: for section, field, value in ( ("phase_calibration", "phase1_rad", 0.0), diff --git a/run_config.json b/run_config.json index bd42668..38a241b 100644 --- a/run_config.json +++ b/run_config.json @@ -18,27 +18,27 @@ "enabled": false, "port": "", "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": { diff --git a/run_config_examples/run_config_librevna.example.json b/run_config_examples/run_config_librevna.example.json index 0429877..9fdaf42 100644 --- a/run_config_examples/run_config_librevna.example.json +++ b/run_config_examples/run_config_librevna.example.json @@ -11,28 +11,28 @@ "laser_control": { "enabled": false, "port": "", - "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "mode": "variation", + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": { diff --git a/run_config_examples/run_config_librevna_multi.example.json b/run_config_examples/run_config_librevna_multi.example.json index 8853898..703f024 100644 --- a/run_config_examples/run_config_librevna_multi.example.json +++ b/run_config_examples/run_config_librevna_multi.example.json @@ -14,28 +14,28 @@ "laser_control": { "enabled": false, "port": "", - "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "mode": "variation", + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": { diff --git a/run_config_examples/run_config_librevna_multi.example2.json b/run_config_examples/run_config_librevna_multi.example2.json index 7c48253..9d48a62 100644 --- a/run_config_examples/run_config_librevna_multi.example2.json +++ b/run_config_examples/run_config_librevna_multi.example2.json @@ -14,28 +14,28 @@ "laser_control": { "enabled": false, "port": "", - "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "mode": "variation", + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": { diff --git a/run_config_examples/run_config_librevna_multi_per_point.example.json b/run_config_examples/run_config_librevna_multi_per_point.example.json index dae99c4..4411b64 100644 --- a/run_config_examples/run_config_librevna_multi_per_point.example.json +++ b/run_config_examples/run_config_librevna_multi_per_point.example.json @@ -17,28 +17,28 @@ "laser_control": { "enabled": false, "port": "", - "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "mode": "variation", + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": { diff --git a/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json b/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json index 992ef24..e671c66 100644 --- a/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json +++ b/run_config_examples/run_config_librevna_multi_per_point_lowband.example.json @@ -17,28 +17,28 @@ "laser_control": { "enabled": false, "port": "", - "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "mode": "variation", + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": { diff --git a/run_config_examples/run_config_rph_radar.example.json b/run_config_examples/run_config_rph_radar.example.json index 5be3752..1891de0 100644 --- a/run_config_examples/run_config_rph_radar.example.json +++ b/run_config_examples/run_config_rph_radar.example.json @@ -1,7 +1,6 @@ { "radar": { "model": "rph_radar", - "serial": "kamil_adc", "driver_mode": "native", "multi_device": { "slave_serials": [], @@ -47,24 +46,24 @@ "enabled": true, "port": "/dev/ttyUSB0", "mode": "variation", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.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, + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, "step": 0.05, "time_step": 50, "delay_time": 10 @@ -220,27 +219,27 @@ }, "rings": { "raw": { - "name": "/radar_raw_kamil_adc", + "name": "/radar_raw_rph_radar", "capacity": 50, "slot_size_bytes": 2097152 }, "raw_tap": { - "name": "/radar_raw_tap_kamil_adc", + "name": "/radar_raw_tap_rph_radar", "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed": { - "name": "/radar_preprocessed_kamil_adc", + "name": "/radar_preprocessed_rph_radar", "capacity": 50, "slot_size_bytes": 2097152 }, "preprocessed_tap": { - "name": "/radar_preprocessed_tap_kamil_adc", + "name": "/radar_preprocessed_tap_rph_radar", "capacity": 50, "slot_size_bytes": 2097152 }, "results": { - "name": "/radar_results_kamil_adc", + "name": "/radar_results_rph_radar", "capacity": 50, "slot_size_bytes": 2097152 } diff --git a/run_config_examples/run_config_rph_radar_rfg.example.json b/run_config_examples/run_config_rph_radar_rfg.example.json new file mode 100644 index 0000000..20ecda8 --- /dev/null +++ b/run_config_examples/run_config_rph_radar_rfg.example.json @@ -0,0 +1,150 @@ +{ + "radar": { + "model": "rph_radar", + "driver_mode": "native", + "rph_radar": { + "source": "rfg_udp", + "udp_bind": "192.168.2.1:5000", + "sweep_timeout_s": 15.0, + "phase_calibration": { + "phase0_rad": -1.6053902786733596, + "freq0_hz": 2046000000.0, + "phase1_rad": 197.7844181612878, + "freq1_hz": 5612000000.0 + }, + "band": { + "start_hz": 2100000000.0, + "stop_hz": 5500000000.0, + "points": 2048 + } + }, + "laser_control": { + "enabled": true, + "port": "/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0", + "mode": "variation", + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, + "manual": { + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 + }, + "variation": { + "variation_type": "CHANGE_CURRENT_LD1", + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 + } + } + }, + "switches": { + "port1": { + "name": "port1", + "driver_mode": "mock", + "radar_port": 1, + "positions": 1, + "default_position": 0 + }, + "port2": { + "name": "port2", + "driver_mode": "mock", + "radar_port": 2, + "positions": 1, + "default_position": 0 + } + }, + "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 + } + ] + }, + "preprocess": { + "s21": { + "calibration": { + "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, + "y_m": 0.0, + "z_m": 0.0 + } + ], + "rx_geometry": [ + { + "input_pos": 0, + "x_m": -0.18, + "y_m": 0.0, + "z_m": 0.0 + } + ] + }, + "rings": { + "raw": { + "name": "/radar_raw_rph_radar", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "raw_tap": { + "name": "/radar_raw_tap_rph_radar", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed": { + "name": "/radar_preprocessed_rph_radar", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "preprocessed_tap": { + "name": "/radar_preprocessed_tap_rph_radar", + "capacity": 50, + "slot_size_bytes": 2097152 + }, + "results": { + "name": "/radar_results_rph_radar", + "capacity": 50, + "slot_size_bytes": 2097152 + } + } +} diff --git a/run_config_examples/run_config_simulator.example.json b/run_config_examples/run_config_simulator.example.json index 8c51651..b7aef83 100644 --- a/run_config_examples/run_config_simulator.example.json +++ b/run_config_examples/run_config_simulator.example.json @@ -11,28 +11,28 @@ "laser_control": { "enabled": false, "port": "", - "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "mode": "variation", + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": { diff --git a/run_configs/run_config.json b/run_configs/run_config.json index f0f8cb2..6924e91 100644 --- a/run_configs/run_config.json +++ b/run_configs/run_config.json @@ -12,27 +12,27 @@ "enabled": false, "port": "", "mode": "manual", - "pi_coeff1_p": 2560, - "pi_coeff1_i": 128, - "pi_coeff2_p": 2560, - "pi_coeff2_i": 128, + "pi_coeff1_p": 1280, + "pi_coeff1_i": 25, + "pi_coeff2_p": 1280, + "pi_coeff2_i": 179, "manual": { - "temp1": 25.0, - "temp2": 25.0, - "current1": 30.0, - "current2": 30.0 + "temp1": 24.0, + "temp2": 24.9, + "current1": 97.3, + "current2": 102.0 }, "variation": { "variation_type": "CHANGE_CURRENT_LD1", - "static_temp1": 25.0, - "static_temp2": 25.0, - "static_current1": 30.0, - "static_current2": 30.0, - "min_value": 30.0, - "max_value": 35.0, - "step": 0.1, - "time_step": 20, - "delay_time": 3 + "static_temp1": 24.0, + "static_temp2": 24.9, + "static_current1": 97.3, + "static_current2": 102.0, + "min_value": 97.3, + "max_value": 107.0, + "step": 0.05, + "time_step": 50, + "delay_time": 10 } }, "sweep": {