Add RFG UDP source to rph_radar

This commit is contained in:
Ayzen
2026-10-06 19:36:00 +03:00
parent a185e61495
commit cfe81f782b
29 changed files with 817 additions and 279 deletions
+17 -6
View File
@@ -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:
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -11,7 +11,7 @@ class LaserControlError(Exception):
pass
class ValidationError(LaserControlError):
class ValidationError(LaserControlError, ValueError):
"""Base exception for validation errors."""
pass
@@ -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),
+78 -6
View File
@@ -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("<HHhh")
_MAX_PAYLOAD_BYTES = 65534 * 2 * _POINT.size
_RFG_HEADER = struct.Struct("<HH")
_RFG_POINT = struct.Struct("<I6i")
@dataclass(frozen=True, slots=True)
@@ -87,3 +94,68 @@ class RphRadarStreamParser:
None if inp == 65535 else (inp, out), bool(flags & 1),
))
return sweeps
class RfgSweepAssembler:
"""Collect RFG points into sweeps of exactly the laser task's ``steps``.
A sweep closes when the next sweep id appears. Ids advance by one per sweep, so an
id that is not ahead of the current one (mod 256) is a late point of an older
sweep and is ignored — unless 128 sweep periods passed, when any id is legitimate. It is kept only with steps ``0..steps-1`` in order: the
reference phase advances up to ~1.8 rad per step, so a lost, duplicate or
reordered point would break the unwrapped frequency axis. Arrival times are
kernel receive stamps (see the reader). Step k leaves the board
``(k + 1) * step_ns`` after SWEEP_START and arrives no earlier, so the lower
envelope of ``arrival - (k + 1) * step_ns`` is the sweep start.
"""
def __init__(self, steps: int, step_ns: int, period_ns: int) -> 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)),
)]
+30 -16
View File
@@ -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:
@@ -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():
if self.assembler is None:
chunk = self._read_available()
if not chunk:
continue
for sweep in parser.feed(chunk):
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
+2
View File
@@ -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
)
+30 -22
View File
@@ -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"]
@@ -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):
+4 -3
View File
@@ -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(
+3 -3
View File
@@ -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):
@@ -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})
+76 -1
View File
@@ -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("<HH", 6, q_shift) + b"".join(
struct.pack("<I6i", (sweep_id << 24) | step, *[value << (q_shift - 7) for value in values]) for step in steps)
class RfgSweepAssemblerTest(unittest.TestCase):
STEP_NS = 50_000
PERIOD_NS = 20_000_000
def feed(self, assembler, sweep_id, steps, start_ns, delay_ns=7_000):
sweeps = []
for step in steps:
sweeps += assembler.feed(rfg_datagram(sweep_id, step), start_ns + (step + 1) * self.STEP_NS + delay_ns)
return sweeps
def test_complete_sweep_maps_sync_detector_and_reference_and_its_true_window(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
self.feed(assembler, 9, [0, 1, 2], 1_000_000_000)
(sweep,) = assembler.feed(rfg_datagram(10, 0), 1_020_000_000)
self.assertFalse(sweep.dirty)
self.assertEqual(sweep.steps.tolist(), [0, 1, 2])
np.testing.assert_allclose(sweep.main, [(3 - 1 + 1j * (4 - 2)) / 128] * 3) # ch0 - ch1
np.testing.assert_allclose(sweep.reference, [(5 + 6j) / 128] * 3) # ch2
self.assertEqual(sweep.capture_start_ns, 1_000_007_000)
self.assertEqual(sweep.capture_end_ns - sweep.capture_start_ns, 3 * self.STEP_NS)
def test_window_takes_the_least_delayed_point_when_the_board_batches(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
start = 2_000_000_000
assembler.feed(rfg_datagram(1, 0, 1), start + 2 * self.STEP_NS + 10_000) # steps 0-1 batched
assembler.feed(rfg_datagram(1, 2), start + 3 * self.STEP_NS + 10_000)
(sweep,) = assembler.feed(rfg_datagram(2, 0), start + 30_000_000)
self.assertEqual(sweep.capture_start_ns, start + 10_000)
def test_points_of_older_sweeps_are_ignored_until_the_id_space_could_have_wrapped(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
sweeps = []
for sweep_id, start in ((1, 0), (3, 20_000_000), (2, 40_000_000), (4, 60_000_000), (3, 80_000_000)):
sweeps += self.feed(assembler, sweep_id, [0, 1, 2], start)
self.assertEqual([sweep.source_id for sweep in sweeps], [1, 2]) # late 2 and replayed 3 are dropped
sweeps = self.feed(assembler, 2, [0, 1, 2], 80_000_000 + 128 * self.PERIOD_NS) # 4 closes, 2 is new
sweeps += self.feed(assembler, 3, [0], 100_000_000 + 128 * self.PERIOD_NS)
self.assertEqual([(sweep.source_id, sweep.dirty) for sweep in sweeps], [(3, False), (4, False)])
def test_reset_turns_the_rest_of_the_sweep_in_progress_dirty(self):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
self.feed(assembler, 4, [0], 0)
assembler.reset()
self.feed(assembler, 4, [1, 2], 0)
(sweep,) = self.feed(assembler, 5, [0], 20_000_000)
self.assertTrue(sweep.dirty)
def test_partial_gapped_shortened_or_reordered_sweeps_are_dirty(self):
for steps in ([1, 2], [0, 2], [0, 1], [0, 2, 1], [0, 1, 1, 2]):
assembler = RfgSweepAssembler(steps=3, step_ns=self.STEP_NS, period_ns=self.PERIOD_NS)
self.feed(assembler, 255, steps, 0)
with self.subTest(steps=steps):
(sweep,) = assembler.feed(rfg_datagram(0, 0), 10_000_000) # 255 -> 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("<HH", 5, 27), struct.pack("<HH", 6, 31) + rfg_datagram(1, 0)[4:],
rfg_datagram(1, 0)[:-1]):
with self.subTest(datagram=datagram), self.assertRaisesRegex(ValueError, "protocol"):
RfgSweepAssembler(steps=3, step_ns=1, period_ns=1).feed(datagram, 1)
if __name__ == "__main__":
unittest.main()
+57 -5
View File
@@ -4,6 +4,7 @@ import json
import os
from pathlib import Path
import pty
import socket
import subprocess
import sys
import tempfile
@@ -16,14 +17,15 @@ from unittest import mock
import numpy as np
from python_app.hardware_full.rph_radar.service import RphRadarService
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
from python_app.hardware_full.rph_radar import stream_reader
from python_app.hardware_full.rph_radar.stream_reader import RphRadarStreamReader
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
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 RunConfigModel
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.scripts import rph_radar_raw_producer as producer
from python_app.tests.test_rph_radar_protocol import packet
from python_app.tests.test_rph_radar_protocol import packet, rfg_datagram
def config():
@@ -35,11 +37,61 @@ def config():
})
class RphRadarTtyReaderTest(unittest.TestCase):
class RfgStreamServiceTest(unittest.TestCase):
def test_udp_stream_delivers_complete_sweeps_of_the_laser_task_length(self):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
rfg = RunConfigModel.from_dict({
"radar": {"model": "rph_radar", "driver_mode": "native",
"rph_radar": {"source": "rfg_udp", "udp_bind": f"127.0.0.1:{port}"},
"laser_control": {"mode": "variation", "variation": {
"min_value": 30.0, "max_value": 30.15, "step": 0.05, "time_step": 50}}},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
"run": {"continuous": False, "combos": [{"input": 0, "output": 0}]},
})
service = RphRadarService(rfg)
service.open()
self.addCleanup(service.close)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as board:
for datagram in (rfg_datagram(7, 2, 3), rfg_datagram(8, 0, 1, 2, 3), rfg_datagram(9, 0)):
board.sendto(datagram, ("127.0.0.1", port))
raw = service.read_raw_sweep()
self.assertEqual(raw.steps.tolist(), [0, 1, 2, 3]) # ceil((9879 - 9830) / 16) DAC codes
self.assertFalse(raw.dirty)
self.assertEqual(raw.capture_end_ns - raw.capture_start_ns, 4 * 50_000)
class RfgClockJumpTest(unittest.TestCase):
def test_a_realtime_step_drops_queued_datagrams_and_the_sweep_in_progress(self):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
assembler = mock.Mock(feed=mock.Mock(return_value=[]))
reader = RphRadarStreamReader(f"127.0.0.1:{port}", assembler)
real_offset, step = stream_reader._clock_offset_ns, [0]
with mock.patch.object(stream_reader, "_clock_offset_ns", lambda: real_offset() + step[0]):
reader.open()
self.addCleanup(reader.close)
step[0] = 2_000_000 # REALTIME stepped 2 ms after bind (stamps stay real, so forward)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as board:
board.sendto(rfg_datagram(1, 0), ("127.0.0.1", port))
deadline = time.monotonic() + 1
while not assembler.reset.called and time.monotonic() < deadline:
time.sleep(.005)
assembler.feed.assert_not_called()
board.sendto(rfg_datagram(1, 1), ("127.0.0.1", port))
while not assembler.feed.called and time.monotonic() < deadline:
time.sleep(.005)
assembler.reset.assert_called_once()
assembler.feed.assert_called_once()
class RphRadarStreamReaderTest(unittest.TestCase):
def test_pty_delivers_latest_complete_clean_sweeps_by_pair(self):
master, slave = pty.openpty()
tty.setraw(slave)
reader = RphRadarTtyReader(os.ttyname(slave))
reader = RphRadarStreamReader(os.ttyname(slave))
reader.open()
try:
os.write(master, packet(source_id=1, combo=(0, 0))
@@ -216,7 +268,7 @@ class RphRadarConfigTest(unittest.TestCase):
with mock.patch("python_app.hardware_full.rph_radar.service._prepare_tty_path_for_collector") as unlink, \
mock.patch.object(RphRadarService, "_start_process") as start, \
mock.patch.object(RphRadarService, "_wait_for_tty"), \
mock.patch("python_app.hardware_full.rph_radar.service.RphRadarTtyReader"):
mock.patch("python_app.hardware_full.rph_radar.service.RphRadarStreamReader"):
with self.assertRaisesRegex(RuntimeError, "did not stop"):
service.open()
unlink.assert_not_called()
+32
View File
@@ -183,6 +183,38 @@ class AcquisitionValidationTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "native"):
validate_acquisition_config(self.config("rph_radar"))
def test_rfg_source_needs_a_known_laser_sweep_one_pair_and_a_bind_address(self) -> 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),
+17 -17
View File
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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
}
@@ -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
}
}
}
@@ -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": {
+17 -17
View File
@@ -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": {