Rename kamil_adc to rph_radar and fix its data path

This commit is contained in:
Ayzen
2026-10-06 18:15:18 +03:00
parent aef549ea76
commit a185e61495
94 changed files with 2851 additions and 9485 deletions
@@ -31,14 +31,14 @@ _SWEEP_FIELDS = (
("radar.sweep.power_dbm", "_power_input"),
)
_ADC_FIELDS = (
("radar.kamil_adc.project_dir", "_adc_project_dir_input"),
("radar.kamil_adc.executable_path", "_adc_executable_path_input"),
("radar.kamil_adc.tty_path", "_adc_tty_path_input"),
("radar.kamil_adc.args", "_adc_args_input"),
("radar.kamil_adc.env", "_adc_env_input"),
("radar.kamil_adc.startup_timeout_s", "_adc_startup_timeout_s_input"),
("radar.kamil_adc.sweep_timeout_s", "_adc_sweep_timeout_s_input"),
("radar.kamil_adc.stop_timeout_s", "_adc_stop_timeout_s_input"),
("radar.rph_radar.project_dir", "_adc_project_dir_input"),
("radar.rph_radar.executable_path", "_adc_executable_path_input"),
("radar.rph_radar.tty_path", "_adc_tty_path_input"),
("radar.rph_radar.args", "_adc_args_input"),
("radar.rph_radar.env", "_adc_env_input"),
("radar.rph_radar.startup_timeout_s", "_adc_startup_timeout_s_input"),
("radar.rph_radar.sweep_timeout_s", "_adc_sweep_timeout_s_input"),
("radar.rph_radar.stop_timeout_s", "_adc_stop_timeout_s_input"),
("radar.laser_control.enabled", "_optical_enabled_checkbox"),
("radar.laser_control.port", "_optical_port_input"),
("radar.laser_control.mode", "_optical_mode_combo"),
@@ -50,7 +50,6 @@ _ADC_FIELDS = (
("radar.laser_control.manual.temp2", "_optical_manual_temp2_input"),
("radar.laser_control.manual.current1", "_optical_manual_current1_input"),
("radar.laser_control.manual.current2", "_optical_manual_current2_input"),
("radar.laser_control.variation.variation_type", "_optical_variation_type_combo"),
("radar.laser_control.variation.static_temp1", "_optical_static_temp1_input"),
("radar.laser_control.variation.static_temp2", "_optical_static_temp2_input"),
("radar.laser_control.variation.static_current1", "_optical_static_current1_input"),
@@ -137,7 +136,7 @@ class ConfigController:
return Path(str(self._scalar("_save_path_input"))).expanduser(), str(self._scalar("_save_name_input")), prefix
def _read_radar(self, config: RunConfigModel) -> None:
for path, widget_id in (*_SWEEP_FIELDS, *(_ADC_FIELDS if config.is_kamil_adc else ())):
for path, widget_id in (*_SWEEP_FIELDS, *(_ADC_FIELDS if config.is_rph_radar else ())):
owner, name = _model_field(config, path)
original = getattr(owner, name)
value = self._scalar(widget_id)
@@ -21,7 +21,7 @@ from python_app.gui.runtime.constraints import validate_processing_mode_constrai
from python_app.gui.runtime.history import build_processor_run_signature, build_run_history_signature
from python_app.gui.runtime.pipeline_pause import PipelinePause
from python_app.gui.settings_defaults import env_enabled
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
from python_app.hardware_full.rph_radar.laser import apply_rph_radar_laser_control
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.collection_identity import collection_key, index_collections
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
@@ -331,7 +331,7 @@ class PipelineController:
self._prepare_radar(config)
logger.info("Radar settings applied: start=%g Hz, stop=%g Hz, points=%s, ifbw=%g Hz, power=%g dBm",
config.radar.sweep.start_hz, config.radar.sweep.stop_hz,
"from Kamil ADC stream" if config.is_kamil_adc else config.radar.sweep.points,
"from RadioPhotonic radar (RPH) stream" if config.is_rph_radar else config.radar.sweep.points,
config.radar.sweep.if_bandwidth_hz, config.radar.sweep.power_dbm)
except InterruptedError:
self.operation_cancelled()
@@ -344,9 +344,9 @@ class PipelineController:
logger.info("Radar pre-configuration skipped (mock mode)")
elif config.is_multi_device:
logger.info("Multi-device raw producer will configure all LibreVNA devices")
elif config.is_kamil_adc:
applied = apply_kamil_adc_laser_control(config)
logger.info("Kamil ADC laser_control %s", "applied via Apply Radar" if applied else "disabled")
elif config.is_rph_radar:
applied = apply_rph_radar_laser_control(config)
logger.info("RadioPhotonic radar (RPH) laser_control %s", "applied via Apply Radar" if applied else "disabled")
else:
service = create_single_radar_service(config)
try:
@@ -134,7 +134,7 @@ class ProfileController:
self.state.gui = profile.gui or default_gui_state_for_config(profile.run_config, self._project_root)
_remember(self.state, path, self._session, self._log)
config, sweep = profile.run_config, profile.run_config.radar.sweep
points = "from Kamil ADC stream" if config.is_kamil_adc else str(sweep.points)
points = "from RadioPhotonic radar (RPH) stream" if config.is_rph_radar else str(sweep.points)
self._log.info(
f"Config profile saved: path={path}, combos={len(config.combos)}, "
f"sweep={sweep.start_hz:g}..{sweep.stop_hz:g} Hz, points={points}, "
@@ -48,7 +48,7 @@ class RadarSettingsController:
def sync_profile(self, combo_mode: str) -> None:
self.set_combo_mode(combo_mode)
adc = self._defaults().is_kamil_adc
adc = self._defaults().is_rph_radar
self._radar.vna_panel.setVisible(not adc)
self._radar.adc_panel.setVisible(adc)
self.sync_adc_controls()
@@ -79,7 +79,7 @@ class RadarSettingsController:
def refresh_limits(self) -> bool:
config = self._defaults()
if config.is_kamil_adc:
if config.is_rph_radar:
return self.apply_limits(None)
service = (LibreVnaService(serial=config.radar.serial or None) if config.is_multi_device
else create_single_radar_service(config))
@@ -136,7 +136,10 @@ class RadarSettingsController:
def sync_frequency_limits(self) -> bool:
try:
bounds = sorted(float(widget.text()) / 1e6 for widget in self._radar.sweep_edits[:2])
config = self._defaults()
band = config.radar.rph_radar.band
bounds = ([band.start_hz / 1e6, band.stop_hz / 1e6] if config.is_rph_radar else
sorted(float(widget.text()) / 1e6 for widget in self._radar.sweep_edits[:2]))
except ValueError:
return False
changed = False
@@ -348,6 +348,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
form.addRow(processing_mode_pages)
form.addRow(gpr_common_page)
band = config.radar.rph_radar.band if config.is_rph_radar else config.radar.sweep
for binding in GUI_FIELDS:
if binding.path[0] == "processing":
widget = view.fields[binding.spec.widget]
@@ -355,7 +356,7 @@ def build_processing_group(config: RunConfigModel, gui: GuiStateModel) -> Proces
raise TypeError("Geometry is not a scalar GUI setting")
if binding.path[-1] in {"start_freq_mhz", "stop_freq_mhz"}:
assert isinstance(widget, QDoubleSpinBox)
widget.setRange(config.radar.sweep.start_hz / 1e6, config.radar.sweep.stop_hz / 1e6)
widget.setRange(band.start_hz / 1e6, band.stop_hz / 1e6)
if binding.spec.live in {"gpr_input_positions", "gpr_output_positions"}:
assert isinstance(widget, QLineEdit)
widget.setValidator(QRegularExpressionValidator(QRegularExpression(POSITION_CSV_PATTERN), widget))
@@ -62,8 +62,8 @@ def build_radar_group(config: RunConfigModel) -> RadarView:
layout.addWidget(vna_radar_settings_panel)
layout.addWidget(adc_radar_settings_panel)
vna_radar_settings_panel.setVisible(not config.is_kamil_adc)
adc_radar_settings_panel.setVisible(config.is_kamil_adc)
vna_radar_settings_panel.setVisible(not config.is_rph_radar)
adc_radar_settings_panel.setVisible(config.is_rph_radar)
return RadarView(group=group, fields=view.fields,
sweep_edits=(start_hz_input, stop_hz_input, points_input, ifbw_input, power_input),
sweep_labels=radar_labels,
@@ -80,7 +80,7 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
adc = config.radar.kamil_adc
adc = config.radar.rph_radar
optical = config.radar.laser_control
manual = optical.manual
variation = optical.variation
@@ -110,16 +110,6 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
optical_manual_current1_input = view.add("_optical_manual_current1_input", QLineEdit(f"{manual.current1:g}"))
optical_manual_current2_input = view.add("_optical_manual_current2_input", QLineEdit(f"{manual.current2:g}"))
optical_variation_type_combo = view.add("_optical_variation_type_combo", QComboBox())
optical_variation_type_combo.addItems(
[
"CHANGE_CURRENT_LD1",
"CHANGE_CURRENT_LD2",
"CHANGE_TEMPERATURE_LD1",
"CHANGE_TEMPERATURE_LD2",
]
)
write_setting(optical_variation_type_combo, variation.variation_type)
optical_static_temp1_input = view.add("_optical_static_temp1_input", QLineEdit(f"{variation.static_temp1:g}"))
optical_static_temp2_input = view.add("_optical_static_temp2_input", QLineEdit(f"{variation.static_temp2:g}"))
optical_static_current1_input = view.add("_optical_static_current1_input", QLineEdit(f"{variation.static_current1:g}"))
@@ -166,7 +156,6 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
optical_variation_panel = build_two_column_form_widget(
panel,
[
("Variation type", optical_variation_type_combo),
("Static temp 1", optical_static_temp1_input),
("Static temp 2", optical_static_temp2_input),
("Static current 1", optical_static_current1_input),
@@ -186,7 +175,7 @@ def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
(optical_port_input, optical_mode_combo, optical_pi_coeff1_p_input, optical_pi_coeff1_i_input,
optical_pi_coeff2_p_input, optical_pi_coeff2_i_input),
(optical_manual_temp1_input, optical_manual_temp2_input, optical_manual_current1_input, optical_manual_current2_input),
(optical_variation_type_combo, optical_static_temp1_input, optical_static_temp2_input,
(optical_static_temp1_input, optical_static_temp2_input,
optical_static_current1_input, optical_static_current2_input, optical_min_value_input,
optical_max_value_input, optical_step_input, optical_time_step_input, optical_delay_time_input),
optical_manual_panel, optical_variation_panel)
+2 -10
View File
@@ -60,18 +60,10 @@ def build_processor_run_signature(
locator settings, live-file path and polling interval require a restart.
"""
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points)
return (
str(config.radar.model),
config.radar_key(),
config.is_multi_device and config.radar.multi_device.force_external_reference,
str(config.radar.driver_mode),
str(config.radar.serial),
tuple(str(value) for value in config.radar.multi_device.slave_serials),
bool(config.radar.multi_device.force_external_reference),
float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_hz),
sweep_points_signature,
float(config.radar.sweep.if_bandwidth_hz),
float(config.radar.sweep.power_dbm),
str(config.input_switch.driver_mode),
int(config.input_switch.positions),
str(config.output_switch.driver_mode),
@@ -1,36 +0,0 @@
"""Kamil ADC reference-channel acquisition.
Layered into small, single-responsibility modules:
* :mod:`~python_app.hardware_full.kamil_adc.protocol` — TTY wire format → aligned
:class:`RawSweep` (pure, incremental parser).
* :mod:`~python_app.hardware_full.kamil_adc.processing` — reference-phase
frequency axis, amplitude normalization, crop + resample to a fixed grid (pure).
* :mod:`~python_app.hardware_full.kamil_adc.tty_reader` — background thread that
drains the TTY and publishes the latest sweep.
* :mod:`~python_app.hardware_full.kamil_adc.service` — collector process lifecycle
and ``acquire() -> SweepResult``.
* :mod:`~python_app.hardware_full.kamil_adc.laser` — pre-acquisition laser setup.
"""
from python_app.hardware_full.kamil_adc.laser import apply_kamil_adc_laser_control
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.kamil_adc.protocol import (
KamilAdcStreamParser,
RawSweep,
)
from python_app.hardware_full.kamil_adc.service import KamilAdcService
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
__all__ = [
"KamilAdcProcessingParams",
"KamilAdcService",
"KamilAdcStreamParser",
"KamilAdcSweepProcessor",
"KamilAdcTtyReader",
"RawSweep",
"apply_kamil_adc_laser_control",
]
-129
View File
@@ -1,129 +0,0 @@
"""Laser-controller setup applied before Kamil ADC acquisition.
This mirrors the legacy ``device_main`` command sequence: connect to the laser
controller, reset it, and apply either manual or variation mode per
``radar.laser_control``. The wire protocol is unchanged from the standalone tool;
only its home moved into the project.
"""
from __future__ import annotations
import logging
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger(__name__)
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"""Apply configured laser settings via the device_main command sequence.
Returns ``True`` when settings were applied, ``False`` when laser control is
disabled. The controller is always disconnected before returning.
"""
laser = config.radar.laser_control
if not laser.enabled:
logger.debug("Kamil ADC laser control disabled; skipping")
return False
_validate_laser_control_config(config)
from python_app.hardware_full.laser_control.controller import (
DEVICE_MAIN_MESSAGE_ID,
LaserController,
)
from python_app.hardware_full.laser_control.models import VariationType
controller = LaserController(
port=laser.port,
pi_coeff1_p=laser.pi_coeff1_p,
pi_coeff1_i=laser.pi_coeff1_i,
pi_coeff2_p=laser.pi_coeff2_p,
pi_coeff2_i=laser.pi_coeff2_i,
)
try:
controller.connect()
controller.reset()
mode = laser.mode.strip().lower()
logger.info("Applying Kamil ADC laser control in %s mode", mode)
if mode == "manual":
manual = laser.manual
controller.set_manual_mode(
temp1=manual.temp1,
temp2=manual.temp2,
current1=manual.current1,
current2=manual.current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
return True
if mode == "variation":
variation = laser.variation
try:
variation_type = VariationType[variation.variation_type]
except KeyError as exc:
raise ValueError(
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
) from exc
controller.set_manual_mode(
temp1=variation.static_temp1,
temp2=variation.static_temp2,
current1=variation.static_current1,
current2=variation.static_current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=variation_type,
params={
"static_temp1": variation.static_temp1,
"static_temp2": variation.static_temp2,
"static_current1": variation.static_current1,
"static_current2": variation.static_current2,
"min_value": variation.min_value,
"max_value": variation.max_value,
"step": variation.step,
"time_step": variation.time_step,
"delay_time": variation.delay_time,
},
)
_write_variation_session(variation)
return True
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
finally:
controller.disconnect()
def _write_variation_session(variation) -> None:
"""Freeze the variation's static temperature targets for the checker.
Best-effort: a failure to write the session snapshot must never abort the
acquisition setup, so any error is logged and swallowed.
"""
from datetime import datetime
try:
from python_app.hardware_full.laser_control.monitoring.session import (
LaserVariationSession,
)
LaserVariationSession(
variation_type=variation.variation_type,
target_temp1=variation.static_temp1,
target_temp2=variation.static_temp2,
tolerance_c=variation.temp_tolerance_c,
started_at_iso=datetime.now().isoformat(timespec="seconds"),
).save()
logger.debug("Wrote laser variation session snapshot for the temperature checker")
except Exception: # noqa: BLE001 — session snapshot is auxiliary, never fatal
logger.warning("Failed to write laser variation session snapshot", exc_info=True)
def _validate_laser_control_config(config: RunConfigModel) -> None:
laser = config.radar.laser_control
if not laser.port:
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
mode = laser.mode.strip().lower()
if mode not in {"manual", "variation"}:
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
if mode == "variation" and not laser.variation.variation_type:
raise ValueError("radar.laser_control.variation.variation_type is required")
@@ -1,171 +0,0 @@
"""Wire protocol for the Kamil ADC collector's TTY stream.
The collector publishes a stream of 8-byte little-endian frames, four 16-bit
words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame
kinds appear in ``do8_freq_ref`` mode:
* **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps.
* **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``.
* **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with
the main sample of the same ``step`` (emitted only where the DI8 loopback
settled, so reference points are sparser than main points).
:class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one
:class:`RawSweep` per completed sweep. Main and reference points are aligned by
step index; only steps carrying *both* survive (a step needs its reference for
the frequency axis and its main for the signal). Anything other than the three
known frame kinds is a protocol violation and raises — the reader fails fast and
lets the supervisor relaunch a clean collector rather than silently resyncing.
"""
from __future__ import annotations
from dataclasses import dataclass
import struct
import numpy as np
from numpy.typing import NDArray
# Wire-format constants.
FRAME_BYTES = 8
MAIN_MARKER = 0x000A
REFERENCE_MARKER = 0x00A8
# Combo tag — ``0x00C0, input_pos, output_pos, dirty``. Emitted by the switch-aware
# collector right after a sweep boundary to label the upcoming sweep with the RF
# switch combination it was captured under (``dirty != 0`` means the sweep straddled
# a switch transition and must be dropped). Absent in the standalone/calibration
# collector, where every sweep carries no combo (``combo is None``).
COMBO_MARKER = 0x00C0
_BOUNDARY_STEP = 0xFFFF
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
_FRAME = struct.Struct("<HHhh")
# The last three words of a boundary frame are all 0xFFFF; a real step is
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
# (profile-dependent) start marker.
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
@dataclass(frozen=True, slots=True)
class RawSweep:
"""One sweep's main and reference samples, aligned and ordered by step.
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
samples at those steps. All three arrays share the same length.
"""
steps: NDArray[np.int32]
main: NDArray[np.complex64]
reference: NDArray[np.complex64]
combo: tuple[int, int] | None = None
dirty: bool = False
@property
def size(self) -> int:
return int(self.steps.size)
class KamilAdcStreamParser:
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
sweeps completed by that chunk (usually zero or one). The parser is stateful
but holds no I/O and is cheap to unit-test.
"""
__slots__ = ("_buffer", "_aligned", "_main", "_reference", "_pending_combo", "_pending_dirty")
def __init__(self) -> None:
self._buffer = bytearray()
self._aligned = False
self._main: dict[int, complex] = {}
self._reference: dict[int, complex] = {}
# Combo tag for the sweep currently being accumulated (set by the combo
# frame right after each boundary; ``None`` in non-switch collector modes).
self._pending_combo: tuple[int, int] | None = None
self._pending_dirty = False
def feed(self, data: bytes) -> list[RawSweep]:
"""Append ``data`` and return any sweeps completed by it."""
self._buffer.extend(data)
if not self._aligned and not self._align():
return []
sweeps: list[RawSweep] = []
buffer = self._buffer
while len(buffer) >= FRAME_BYTES:
frame = bytes(buffer[:FRAME_BYTES])
del buffer[:FRAME_BYTES]
if frame[2:] == _BOUNDARY_TAIL:
sweep = self._take_sweep()
if sweep is not None:
sweeps.append(sweep)
continue
marker, step, real, imag = _FRAME.unpack(frame)
if marker == MAIN_MARKER:
self._main[step] = complex(real, imag)
elif marker == REFERENCE_MARKER:
self._reference[step] = complex(real, imag)
elif marker == COMBO_MARKER:
# step = input_pos, real = output_pos, imag = dirty flag.
self._pending_combo = (int(step), int(real))
self._pending_dirty = imag != 0
else:
raise ValueError(
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
)
return sweeps
def reset(self) -> None:
"""Drop all buffered state (e.g. after the collector is relaunched)."""
self._buffer.clear()
self._aligned = False
self._main.clear()
self._reference.clear()
self._pending_combo = None
self._pending_dirty = False
def _align(self) -> bool:
"""Discard pre-roll up to and including the first sweep boundary.
Returns ``True`` once frame-aligned. Keeps a short tail so a boundary
split across two feeds can still be found on the next chunk.
"""
index = self._buffer.find(_START_FRAME)
if index < 0:
if len(self._buffer) >= FRAME_BYTES:
del self._buffer[: -(FRAME_BYTES - 1)]
return False
del self._buffer[: index + FRAME_BYTES]
self._main.clear()
self._reference.clear()
self._pending_combo = None
self._pending_dirty = False
self._aligned = True
return True
def _take_sweep(self) -> RawSweep | None:
"""Assemble the buffered points into a sweep and reset for the next one."""
shared = sorted(self._main.keys() & self._reference.keys())
main = self._main
reference = self._reference
combo = self._pending_combo
dirty = self._pending_dirty
self._main = {}
self._reference = {}
# The next sweep's combo is set by its own combo frame (right after this
# boundary); clear so a sweep without one reports combo=None rather than
# inheriting a stale tag.
self._pending_combo = None
self._pending_dirty = False
if not shared:
return None
return RawSweep(
steps=np.asarray(shared, dtype=np.int32),
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
combo=combo,
dirty=dirty,
)
@@ -1,3 +1 @@
"""Vendored laser-control package used by the Kamil ADC hardware path."""
__version__ = "1.0.0"
"""Vendored laser-control package used by the RadioPhotonic radar (RPH) hardware path."""
@@ -9,43 +9,8 @@ laser control board.
BAUDRATE = 115200
SERIAL_TIMEOUT_SEC = 1.0
GET_DATA_TOTAL_LENGTH = 30 # bytes in device DATA response
SEND_PARAMS_TOTAL_LENGTH = 30 # bytes in DECODE_ENABLE command
TASK_ENABLE_COMMAND_LENGTH = 32 # bytes in TASK_ENABLE command
WAIT_AFTER_SEND_SEC = 0.15 # delay after sending a command
# ---- Command codes (as sent to device, already flipped to LE)
CMD_DECODE_ENABLE = 0x1111 # Set control parameters
CMD_DEFAULT_ENABLE = 0x2222 # Reset device
CMD_TRANS_ENABLE = 0x4444 # Request last data
CMD_REMOVE_FILE = 0x5555 # Delete saved data
CMD_STATE = 0x6666 # Request state
CMD_TASK_ENABLE = 0x7777 # Start a task
# ---- Error codes from device STATE response (after flipfour)
STATE_OK = '0000'
STATE_SD_ERR = '0001' # SD Card read/write error
STATE_UART_ERR = '0002' # Command (UART) error
STATE_UART_DECODE_ERR = '0004' # Wrong parameter value
STATE_TEC1_ERR = '0008' # Laser 1 TEC driver overheat
STATE_TEC2_ERR = '0010' # Laser 2 TEC driver overheat
STATE_DEFAULT_ERR = '0020' # System reset error
STATE_REMOVE_ERR = '0040' # File deletion error
STATE_DESCRIPTIONS = {
STATE_OK: "All ok.",
STATE_SD_ERR: "SD Card reading/writing error (SD_ERR).",
STATE_UART_ERR: "Command error (UART_ERR).",
STATE_UART_DECODE_ERR:"Wrong parameter value error (UART_DECODE_ERR).",
STATE_TEC1_ERR: "Laser 1: TEC driver overheat (TEC1_ERR).",
STATE_TEC2_ERR: "Laser 2: TEC driver overheat (TEC2_ERR).",
STATE_DEFAULT_ERR: "Resetting system error (DEFAULT_ERR).",
STATE_REMOVE_ERR: "File deletion error (REMOVE_ERR).",
}
# ---- Physical / hardware constants (from device_conversion.py)
VREF = 2.5 # Reference voltage, Volts
@@ -60,26 +25,13 @@ R6 = 56000 # Ohm
RREF = 10 # Current-setting resistor, Ohm
# (@1550 nm – 28.7 Ohm; @840 nm – 10 Ohm)
# External thermistor divider resistors
R7 = 22000 # Ohm
R8 = 22000 # Ohm
R9 = 5100 # Ohm
R10 = 180000 # Ohm
# Thermistor Steinhart–Hart B-coefficient (internal / external)
# Thermistor Steinhart–Hart B-coefficient
BETA_INTERNAL = 3900 # K
BETA_EXTERNAL = 3455 # K
T0_K = 298 # Kelvin (25 °C reference)
R0 = 10000 # Ohm (thermistor nominal at 25 °C)
# ADC resolution
# DAC resolution
ADC_BITS_16 = 65535 # 2^16 - 1
ADC_BITS_12 = 4095 # 2^12 - 1
# Voltage conversion coefficients
U3V3_COEFF = 1.221e-3 # counts → Volts for 3.3V rail
U5V_COEFF = 1.8315e-3 # counts → Volts for 5V rails
U7V_COEFF = 6.72e-3 # counts → Volts for 7V rail
# ---- Operational limits (validated in validators.py)
@@ -93,9 +45,6 @@ CURRENT_MAX_MA = 60.0 # Maximum allowed laser current, mA
CURRENT_STEP_MIN_MA = 0.002 # Minimum current variation step, mA
CURRENT_STEP_MAX_MA = 0.5 # Maximum current variation step, mA
TEMP_STEP_MIN_C = 0.05 # Minimum temperature variation step, °C
TEMP_STEP_MAX_C = 1.0 # Maximum temperature variation step, °C
# Time parameter limits
TIME_STEP_MIN_US = 20 # Minimum time step, microseconds
TIME_STEP_MAX_US = 100 # Maximum time step, microseconds
@@ -1,355 +1,60 @@
"""
Main laser controller for the laser control module.
"""Board commands with mandatory acknowledgements."""
Provides a high-level API for controlling dual laser systems.
All input parameters are validated before being sent to the device.
Can be embedded in any Python application without GUI dependencies.
"""
import time
import logging
from typing import Optional, Callable
import time
from .protocol import Protocol, TaskType as ProtoTaskType
from .validators import ParameterValidator
from .models import (
VariationType,
Measurements,
)
from .exceptions import (
CommunicationError,
DeviceNotRespondingError,
)
from .constants import WAIT_AFTER_SEND_SEC
from .exceptions import CommunicationError
from .protocol import Protocol
logger = logging.getLogger(__name__)
# Default PI regulator coefficients (match firmware defaults)
DEFAULT_PI_P = 2560 # 10 * 256
DEFAULT_PI_I = 128 # 0.5 * 256
DEFAULT_PI_P = 2560
DEFAULT_PI_I = 128
DEVICE_MAIN_MESSAGE_ID = 0x00FF
class LaserController:
"""
High-level controller for the dual laser board.
"""Control constant setpoints or LD1 current variation through one serial port."""
Usage example::
ctrl = LaserController(port='/dev/ttyUSB0')
ctrl.connect()
ctrl.set_manual_mode(temp1=25.0, temp2=30.0,
current1=40.0, current2=35.0)
data = ctrl.get_measurements()
print(data.voltage_3v3)
ctrl.disconnect()
All public methods raise :class:`ValidationError` for bad parameters
and :class:`CommunicationError` for transport-level problems.
"""
def __init__(
self,
port: Optional[str] = None,
pi_coeff1_p: int = DEFAULT_PI_P,
pi_coeff1_i: int = DEFAULT_PI_I,
pi_coeff2_p: int = DEFAULT_PI_P,
pi_coeff2_i: int = DEFAULT_PI_I,
on_data: Optional[Callable[[Measurements], None]] = None,
):
"""
Args:
port: Serial port (e.g. '/dev/ttyUSB0'). None = auto-detect.
pi_coeff1_p: Proportional coefficient for laser 1 PI regulator.
pi_coeff1_i: Integral coefficient for laser 1 PI regulator.
pi_coeff2_p: Proportional coefficient for laser 2 PI regulator.
pi_coeff2_i: Integral coefficient for laser 2 PI regulator.
on_data: Optional callback called whenever new measurements
are received. Signature: ``callback(Measurements)``.
"""
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):
self._protocol = Protocol(port)
self._pi1_p = pi_coeff1_p
self._pi1_i = pi_coeff1_i
self._pi2_p = pi_coeff2_p
self._pi2_i = pi_coeff2_i
self._on_data = on_data
self._message_id = 0
self._last_measurements: Optional[Measurements] = None
# Last manual-mode params, used to restore state after stop_task()
self._last_temp1: float = 25.0
self._last_temp2: float = 25.0
self._last_current1: float = 30.0
self._last_current2: float = 30.0
# ---- Connection -------------------------------------------------------
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)
def connect(self) -> bool:
"""
Open connection to the device.
Returns:
True if connection succeeded.
Raises:
CommunicationError: If the port cannot be opened.
"""
self._protocol.connect()
logger.info("Connected to laser controller on port %s",
self._protocol._port_name or "auto")
logger.info("Connected to laser controller on port %s", self._protocol.port)
return True
def disconnect(self) -> None:
"""Close the serial port gracefully."""
self._protocol.disconnect()
logger.info("Disconnected from laser controller")
@property
def is_connected(self) -> bool:
"""True if the serial port is open."""
return self._protocol.is_connected
def set_manual_mode(self, temp1: float, temp2: float, current1: float, current2: float,
message_id: int) -> None:
manual = dict(temp1=temp1, temp2=temp2, current1=current1, current2=current2)
self._send_and_read_state(Protocol.encode_decode_enable(**manual, **self._pi, message_id=message_id))
logger.debug("Manual setpoints: %s", manual)
# ---- Public API -------------------------------------------------------
def set_manual_mode(
self,
temp1: float,
temp2: float,
current1: float,
current2: float,
message_id: Optional[int] = None,
) -> None:
"""
Set manual control parameters for both lasers.
Args:
temp1: Setpoint temperature for laser 1, °C.
Valid range: [15.0 … 40.0] °C.
temp2: Setpoint temperature for laser 2, °C.
Valid range: [15.0 … 40.0] °C.
current1: Drive current for laser 1, mA.
Valid range: [15.0 … 60.0] mA.
current2: Drive current for laser 2, mA.
Valid range: [15.0 … 60.0] mA.
message_id: Optional fixed DECODE_ENABLE message id. When
omitted, the id is incremented for backward
compatibility with the refactored API.
Raises:
ValidationError: If any parameter is out of range.
CommunicationError: If the command cannot be sent.
"""
validated = ParameterValidator.validate_manual_mode_params(
temp1, temp2, current1, current2
)
if message_id is None:
self._message_id = (self._message_id + 1) & 0xFFFF
else:
self._message_id = int(message_id) & 0xFFFF
cmd = Protocol.encode_decode_enable(
temp1=validated['temp1'],
temp2=validated['temp2'],
current1=validated['current1'],
current2=validated['current2'],
pi_coeff1_p=self._pi1_p,
pi_coeff1_i=self._pi1_i,
pi_coeff2_p=self._pi2_p,
pi_coeff2_i=self._pi2_i,
message_id=self._message_id,
)
self._send_and_read_state(cmd)
self._last_temp1 = validated['temp1']
self._last_temp2 = validated['temp2']
self._last_current1 = validated['current1']
self._last_current2 = validated['current2']
logger.debug("Manual mode set: T1=%.2f T2=%.2f I1=%.2f I2=%.2f",
validated['temp1'], validated['temp2'],
validated['current1'], validated['current2'])
def start_variation(
self,
variation_type: VariationType,
params: dict,
) -> None:
"""
Start a parameter variation task.
Args:
variation_type: Which parameter to vary
(:class:`VariationType.CHANGE_CURRENT_LD1` or
:class:`VariationType.CHANGE_CURRENT_LD2`).
params: Dictionary with the following keys:
- ``min_value`` – minimum value of the varied parameter.
- ``max_value`` – maximum value of the varied parameter.
- ``step`` – step size.
- ``time_step`` – discretisation time step, µs [20 … 100].
- ``delay_time``– delay between pulses, ms [3 … 10].
- ``static_temp1`` – fixed temperature for laser 1, °C.
- ``static_temp2`` – fixed temperature for laser 2, °C.
- ``static_current1`` – fixed current for laser 1, mA.
- ``static_current2`` – fixed current for laser 2, mA.
Raises:
ValidationError: If any parameter fails validation.
CommunicationError: If the command cannot be sent.
"""
# Validate variation-specific params
validated = ParameterValidator.validate_variation_params(
params, variation_type
)
# Validate static parameters
static_temp1 = ParameterValidator.validate_temperature(
params.get('static_temp1', 25.0), 'static_temp1'
)
static_temp2 = ParameterValidator.validate_temperature(
params.get('static_temp2', 25.0), 'static_temp2'
)
static_current1 = ParameterValidator.validate_current(
params.get('static_current1', 30.0), 'static_current1'
)
static_current2 = ParameterValidator.validate_current(
params.get('static_current2', 30.0), 'static_current2'
)
# Map VariationType → protocol TaskType
task_type_map = {
VariationType.CHANGE_CURRENT_LD1: ProtoTaskType.CHANGE_CURRENT_LD1,
VariationType.CHANGE_CURRENT_LD2: ProtoTaskType.CHANGE_CURRENT_LD2,
VariationType.CHANGE_TEMPERATURE_LD1: ProtoTaskType.CHANGE_TEMPERATURE_LD1,
VariationType.CHANGE_TEMPERATURE_LD2: ProtoTaskType.CHANGE_TEMPERATURE_LD2,
}
proto_task = task_type_map[validated['variation_type']]
cmd = Protocol.encode_task_enable(
task_type=proto_task,
static_temp1=static_temp1,
static_temp2=static_temp2,
static_current1=static_current1,
static_current2=static_current2,
min_value=validated['min_value'],
max_value=validated['max_value'],
step=validated['step'],
time_step=validated['time_step'],
delay_time=validated['delay_time'],
message_id=self._message_id,
pi_coeff1_p=self._pi1_p,
pi_coeff1_i=self._pi1_i,
pi_coeff2_p=self._pi2_p,
pi_coeff2_i=self._pi2_i,
)
self._send_and_read_state(cmd)
logger.info("Variation task started: type=%s min=%.3f max=%.3f step=%.3f",
validated['variation_type'].name,
validated['min_value'],
validated['max_value'],
validated['step'])
def stop_task(self, restore_message_id: Optional[int] = None) -> None:
"""Stop the current task and restore manual mode.
Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last
known manual-mode parameters. This two-step sequence matches the
original firmware protocol: after DEFAULT_ENABLE the board is in a
reset state and must receive DECODE_ENABLE before it can respond to
TRANS_ENABLE data requests again.
"""
cmd_reset = Protocol.encode_default_enable()
self._send_and_read_state(cmd_reset)
logger.info("Task stopped (DEFAULT_ENABLE sent)")
self.set_manual_mode(
temp1=self._last_temp1,
temp2=self._last_temp2,
current1=self._last_current1,
current2=self._last_current2,
message_id=restore_message_id,
)
logger.info("Manual mode restored after task stop")
def get_measurements(self) -> Optional[Measurements]:
"""
Request and return the latest measurements from the device.
Returns:
:class:`Measurements` dataclass, or None if no data available.
Raises:
CommunicationError: On transport errors.
"""
cmd = Protocol.encode_trans_enable()
self._send(cmd)
raw = self._protocol.receive_raw(30)
if not raw or len(raw) != 30:
logger.warning("No data received from device")
return None
response = Protocol.decode_response(raw)
measurements = response.to_measurements()
self._last_measurements = measurements
if self._on_data:
self._on_data(measurements)
return measurements
def start_variation(self, params: dict) -> None:
self._send_and_read_state(Protocol.encode_task_enable(
**params, pi_coeff1_p=self._pi["pi_coeff1_p"], pi_coeff1_i=self._pi["pi_coeff1_i"],
pi_coeff2_p=self._pi["pi_coeff2_p"], pi_coeff2_i=self._pi["pi_coeff2_i"],
))
logger.info("LD1 current variation started: %s", params)
def reset(self) -> None:
"""Send a hardware reset command to the device."""
cmd = Protocol.encode_default_enable()
self._send_and_read_state(cmd)
self._send_and_read_state(b"\x22\x22")
logger.info("Device reset command sent")
# ---- Internal helpers -------------------------------------------------
def _send(self, cmd: bytes) -> None:
"""Send command bytes and wait for the device to process."""
if not self.is_connected:
raise CommunicationError("Not connected to device. Call connect() first.")
self._protocol.send_raw(cmd)
def _send(self, command: bytes) -> None:
self._protocol.send_raw(command)
time.sleep(WAIT_AFTER_SEND_SEC)
def _send_and_read_state(self, cmd: bytes) -> int:
"""Send command and read the 2-byte STATE response the device always returns.
Commands DECODE_ENABLE, TASK_ENABLE and DEFAULT_ENABLE each trigger a
STATE reply from the firmware. If we don't consume those bytes here,
they accumulate in the serial buffer and corrupt the next DATA read.
Returns the decoded state code (0x0000 = OK).
"""
self._send(cmd)
raw = self._protocol.receive_raw(2)
if raw and len(raw) == 2:
state = Protocol.decode_state(raw)
if state != 0:
# Surface a device-reported non-OK STATE instead of silently
# treating a board-rejected command as success.
logger.warning(
"Device returned non-OK STATE 0x%04x after command: %s",
state,
Protocol.state_to_description(f"{state:04x}"),
)
else:
logger.debug("STATE response after command: 0x%04x", state)
return state
return 0
# ---- Context manager support -----------------------------------------
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# Always try to stop any running task before closing the port.
# If we don't, the board stays in TASK state and ignores all future
# commands until its power is cycled.
if self.is_connected:
try:
self.stop_task()
except Exception:
logger.warning("Failed to stop laser task on exit; closing port anyway", exc_info=True)
self.disconnect()
return False
def _send_and_read_state(self, command: bytes) -> None:
self._send(command)
state = Protocol.decode_state(self._protocol.receive_raw(2))
if state:
raise CommunicationError(f"Device rejected command: STATE 0x{state:04x}")
@@ -5,14 +5,7 @@ values used by the device firmware, using the hardware's bridge/divider formulas
"""
import math
from .constants import (
VREF, R1, R3, R4, R5, R6,
R7, R8, R9, R10,
RREF,
BETA_INTERNAL, BETA_EXTERNAL, T0_K, R0,
ADC_BITS_16, ADC_BITS_12,
U3V3_COEFF, U5V_COEFF, U7V_COEFF,
)
from .constants import ADC_BITS_16, BETA_INTERNAL, R0, R1, R3, R4, R5, R6, RREF, T0_K, VREF
def temp_c_to_n(temp_c: float) -> int:
@@ -34,38 +27,6 @@ def temp_c_to_n(temp_c: float) -> int:
return n
def temp_n_to_c(n: int) -> float:
"""
Convert 16-bit ADC integer to temperature (°C).
Args:
n: Raw ADC value in [0, 65535].
Returns:
Temperature in degrees Celsius.
"""
u = n * VREF / ADC_BITS_16
rt = R1 * (VREF * R4 * (R5 + R6) - u * R5 * (R3 + R4)) / (
u * R5 * (R3 + R4) + VREF * R3 * R6 - VREF * R4 * R5
)
t = 1 / (1 / T0_K + 1 / BETA_INTERNAL * math.log(rt / R0)) - 273
return t
def temp_ext_n_to_c(n: int) -> float:
"""
Convert 12-bit ADC integer to external thermistor temperature (°C).
Args:
n: Raw 12-bit ADC value in [0, 4095].
Returns:
Temperature in degrees Celsius.
"""
u = n * VREF / ADC_BITS_12 * 1 / (1 + 100000 / R10) + VREF * R9 / (R8 + R9)
rt = R7 * u / (VREF - u)
t = 1 / (1 / T0_K + 1 / BETA_EXTERNAL * math.log(rt / R0)) - 273
return t
def current_ma_to_n(current_ma: float) -> int:
@@ -81,31 +42,3 @@ def current_ma_to_n(current_ma: float) -> int:
n = int(ADC_BITS_16 / 2000 * RREF * current_ma)
n = max(0, min(ADC_BITS_16, n))
return n
def current_n_to_ma(n: int) -> float:
"""
Convert raw ADC integer to photodiode current (mA).
Args:
n: Raw ADC value in [0, 65535].
Returns:
Current in milliamps.
"""
return n * 2.5 / (ADC_BITS_16 * 4.4) - 1 / 20.4
def voltage_3v3_n_to_v(n: int) -> float:
"""Convert 3.3V rail ADC count to volts."""
return n * U3V3_COEFF
def voltage_5v_n_to_v(n: int) -> float:
"""Convert 5V rail ADC count to volts (both 5V1 and 5V2)."""
return n * U5V_COEFF
def voltage_7v_n_to_v(n: int) -> float:
"""Convert 7V rail ADC count to volts."""
return n * U7V_COEFF
@@ -16,58 +16,11 @@ class ValidationError(LaserControlError):
pass
class TemperatureOutOfRangeError(ValidationError):
"""Exception raised when temperature is outside valid range."""
def __init__(self, param_name: str, value: float, min_val: float, max_val: float):
self.param_name = param_name
self.value = value
self.min_val = min_val
self.max_val = max_val
super().__init__(
f"{param_name}: Temperature {value}°C is out of range "
f"[{min_val}°C - {max_val}°C]"
)
class CurrentOutOfRangeError(ValidationError):
"""Exception raised when current is outside valid range."""
def __init__(self, param_name: str, value: float, min_val: float, max_val: float):
self.param_name = param_name
self.value = value
self.min_val = min_val
self.max_val = max_val
super().__init__(
f"{param_name}: Current {value}mA is out of range "
f"[{min_val}mA - {max_val}mA]"
)
class InvalidParameterError(ValidationError):
"""Exception raised for invalid parameter types or values."""
def __init__(self, param_name: str, message: str):
self.param_name = param_name
super().__init__(f"{param_name}: {message}")
class CommunicationError(LaserControlError):
"""Base exception for communication errors."""
pass
class PortNotFoundError(CommunicationError):
"""Exception raised when serial port cannot be found."""
def __init__(self, port: str | None = None):
if port:
message = f"Serial port '{port}' not found"
else:
message = "No suitable serial port found for device connection"
super().__init__(message)
class PortBusyError(CommunicationError):
"""Raised when the serial port is already held by another program.
@@ -86,28 +39,6 @@ class PortBusyError(CommunicationError):
)
class DeviceNotRespondingError(CommunicationError):
"""Exception raised when device doesn't respond to commands."""
def __init__(self, timeout: float | None = None):
if timeout:
message = f"Device did not respond within {timeout} seconds"
else:
message = "Device is not responding to commands"
super().__init__(message)
class CRCError(CommunicationError):
"""Exception raised when CRC check fails."""
def __init__(self, expected: int | None = None, received: int | None = None):
if expected is not None and received is not None:
message = f"CRC check failed. Expected: 0x{expected:04X}, Received: 0x{received:04X}"
else:
message = "CRC check failed on received data"
super().__init__(message)
class ProtocolError(CommunicationError):
"""Exception raised for protocol-level errors."""
@@ -1,65 +0,0 @@
"""
Data models for laser control module.
Provides dataclasses and enums for structured data representation
throughout the laser control system.
"""
from dataclasses import dataclass
from enum import IntEnum
from typing import Optional, Dict, Any
from datetime import datetime
class VariationType(IntEnum):
"""Types of parameter variation modes."""
MANUAL = 0x00
CHANGE_CURRENT_LD1 = 0x01
CHANGE_CURRENT_LD2 = 0x02
CHANGE_TEMPERATURE_LD1 = 0x03
CHANGE_TEMPERATURE_LD2 = 0x04
@dataclass
class Measurements:
"""Real-time measurements from the device."""
# Photodiode currents
current1: float # Photodiode current for laser 1 (mA)
current2: float # Photodiode current for laser 2 (mA)
# Temperatures
temp1: float # Temperature of laser 1 (°C)
temp2: float # Temperature of laser 2 (°C)
temp_ext1: Optional[float] = None # External thermistor 1 temperature (°C)
temp_ext2: Optional[float] = None # External thermistor 2 temperature (°C)
# Power supply voltages
voltage_3v3: float = 0.0 # 3.3V rail voltage
voltage_5v1: float = 0.0 # 5V rail 1 voltage
voltage_5v2: float = 0.0 # 5V rail 2 voltage
voltage_7v0: float = 0.0 # 7V rail voltage
# Metadata
timestamp: Optional[datetime] = None
message_id: Optional[int] = None
to6_counter_lsb: Optional[int] = None
to6_counter_msb: Optional[int] = None
def __post_init__(self):
"""Set timestamp if not provided."""
if self.timestamp is None:
self.timestamp = datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation."""
return {
'current1': self.current1,
'current2': self.current2,
'temp1': self.temp1,
'temp2': self.temp2,
'temp_ext1': self.temp_ext1,
'temp_ext2': self.temp_ext2,
'voltage_3v3': self.voltage_3v3,
'voltage_5v1': self.voltage_5v1,
'voltage_5v2': self.voltage_5v2,
'voltage_7v0': self.voltage_7v0,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'message_id': self.message_id
}
@@ -1,259 +0,0 @@
# Контроль температуры при вариации тока лазера
Набор из трёх развязанных компонентов для автоматизации измерений в режиме
**вариации тока лазера 1** (`CHANGE_CURRENT_LD1`). Пока плата гоняет свип тока,
температуры лазеров должны оставаться на заданных статичных уставках. Эти модули
раз в свип считывают реальную температуру и предупреждают, если она разошлась с
целью.
## Зачем это нужно
При запуске вариации тока из GUI изменённые значения температуры могут фактически
не дойти до цели — реальная температура остаётся прежней, и измерение становится
некорректным. Плата после старта задачи гоняет свип **автономно** и никак не
сигнализирует, что уставка не достигнута. Эти модули закрывают пробел: независимо
опрашивают плату и валидируют температуру относительно уставок, зафиксированных
**в момент старта вариации**.
> ⚠️ В прошивке реализована только **вариация тока** (`CHANGE_CURRENT_LD1`).
> Вариация температуры не поддерживается и в этот API не заложена.
## Архитектура
```
[starter] ── TASK_ENABLE ──► плата запускает вариацию
│ пишет session.json (target temp1/2, tolerance, variation_type)
▼
[monitor] ── TRANS_ENABLE ──► плата владеет портом всё время работы
│ раз в свип: get_measurements()
│ дописывает строку в readings.jsonl (seq, temp1, temp2, temp_ext, I1, I2)
▼
[checker] читает session.json + tail readings.jsonl
сверяет temp1↔target_temp1 и temp2↔target_temp2, |Δ|>tol ─► WARNING в консоль
```
- **Порт лазера эксклюзивен.** При `--start` запуск и мониторинг используют одно
соединение. Штатный пайплайн закрывает порт после запуска вариации, и его
может открыть отдельный монитор. `checker` читает только файлы.
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
останавливаются и перезапускаются независимо, без рукопожатия.
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
термисторам `temp_ext*`), с общим допуском (по умолчанию `0.03 °C`).
## Быстрый старт (CLI, два процесса)
Команды запускаются из корня проекта. Терминал 1 — стартовать вариацию и
мониторить температуру:
```bash
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
--config run_config.json \
--start
```
Терминал 2 — валидировать температуру и печатать предупреждения:
```bash
.venv/bin/python -m python_app.scripts.laser_temp_checker
```
Пример вывода чекера при расхождении и возврате в допуск:
```
WARNING python_app.hardware_full.laser_control.monitoring.checker: Laser 1 temperature off target: measured 28.100 °C,
target 28.000 °C, Δ=+0.100 °C exceeds tolerance ±0.030 °C [seq=1]
INFO python_app.hardware_full.laser_control.monitoring.checker: Laser 1 temperature back within tolerance:
28.000 °C (target 28.000, |Δ|=0.000 ≤ 0.030) [seq=2]
```
Остановка — `Ctrl+C` (SIGINT) в любом из процессов.
## Конфигурация
Параметры берутся из `run_config.json`, секция `radar.laser_control`. Мониторинг
использует блок `variation`, включая `temp_tolerance_c`:
```json
{
"radar": {
"model": "kamil_adc",
"laser_control": {
"enabled": true,
"port": "/dev/ttyUSB0",
"mode": "variation",
"pi_coeff1_p": 2560,
"pi_coeff1_i": 128,
"pi_coeff2_p": 2560,
"pi_coeff2_i": 128,
"variation": {
"variation_type": "CHANGE_CURRENT_LD1",
"static_temp1": 28.0,
"static_temp2": 28.9,
"static_current1": 33.0,
"static_current2": 35.0,
"min_value": 33.0,
"max_value": 60.0,
"step": 0.05,
"time_step": 50,
"delay_time": 10,
"temp_tolerance_c": 0.03
}
}
}
}
```
Ключевые поля для мониторинга:
| Поле | Смысл |
|---|---|
| `port` | Серийный порт лазерной платы; CLI-монитор допускает пустое значение для автоопределения, пайплайн требует явный порт |
| `static_temp1` / `static_temp2` | Целевые статичные температуры лазеров 1/2, °C |
| `min_value` / `max_value` / `step` | Диапазон и шаг свипа тока, мА — из них считается период свипа |
| `time_step` / `delay_time` | Тайминги точки (мкс / мс) — тоже входят в период свипа |
| `temp_tolerance_c` | Допуск сверки, °C (по умолчанию `0.03`) |
## Опции CLI
### `laser_temp_monitor`
| Аргумент | По умолчанию | Назначение |
|---|---|---|
| `--config` | — (обязателен) | Путь к `run_config.json` |
| `--start` | выкл. | Послать `CHANGE_CURRENT_LD1` перед мониторингом и записать сессию |
| `--strategy` | `computed` | `computed` (раз в свип) или `interval:<ms>` (фикс. период) |
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Куда дописывать показания |
| `--session` | `<tmp>/laser_variation_session.json` | Куда писать снимок сессии (с `--start`) |
Мониторить уже запущенную из GUI/пайплайна вариацию (без повторного старта):
```bash
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config run_config.json
```
Фиксированный период вместо расчётного (напр. раз в 500 мс):
```bash
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
--config run_config.json --strategy interval:500
```
### `laser_temp_checker`
| Аргумент | По умолчанию | Назначение |
|---|---|---|
| `--session` | `<tmp>/laser_variation_session.json` | Снимок с целями и допуском |
| `--readings` | `<tmp>/laser_temp_readings.jsonl` | Какой канал показаний тайлить |
| `--tolerance` | из сессии | Переопределить допуск, °C |
| `--reminder-every` | `0` (выкл.) | Повторять предупреждение каждые N показаний, пока вне допуска |
| `--from-start` | выкл. | Проверить весь файл показаний, а не только новые строки |
Разные пути для нескольких одновременных прогонов:
```bash
# монитор
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
# чекер
.venv/bin/python -m python_app.scripts.laser_temp_checker \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json --reminder-every 20
```
## Интеграция с пайплайном Kamil ADC
Когда вариацию стартует штатный пайплайн
([`apply_kamil_adc_laser_control`](../../kamil_adc/laser.py)), снимок сессии
`<tmp>/laser_variation_session.json` пишется автоматически. Для предупреждений
во время захвата нужны монитор без `--start`, который создаёт показания, и чекер.
Если монитор уже работает, достаточно добавить чекер. Он загружает сессию
один раз при запуске; после запуска новой вариации чекер нужно перезапустить.
## Встраивание в свой код (без CLI)
```python
import threading
from python_app.hardware_full.laser_control.controller import LaserController
from python_app.hardware_full.laser_control.monitoring import (
LaserTemperatureMonitor, LaserTemperatureChecker, LaserVariationSession,
ReadingWriter, ReadingReader, resolve_period_s,
)
# 1. Зафиксировать цели при старте вариации
session = LaserVariationSession(
variation_type="CHANGE_CURRENT_LD1",
target_temp1=28.0, target_temp2=28.9, tolerance_c=0.03,
)
# 2. Монитор (в проде controller — реальный LaserController)
period = resolve_period_s("computed", min_value=33.0, max_value=60.0, step=0.05,
time_step_us=50, delay_time_ms=10)
stop = threading.Event()
with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl") as w:
monitor = LaserTemperatureMonitor(ctrl, w, period_s=period)
worker = threading.Thread(target=monitor.run, args=(stop,))
worker.start()
# 3. Чекер: тайлить показания и валидировать оба лазера
checker = LaserTemperatureChecker.from_session(session)
reader = ReadingReader("readings.jsonl")
try:
while not stop.is_set():
for reading in reader.poll():
checker.process(reading) # печатает WARNING при |Δ| > tolerance
stop.wait(0.2)
except KeyboardInterrupt:
pass
finally:
stop.set()
worker.join() # завершить опрос до закрытия порта и файла
```
`LaserTemperatureChecker.evaluate(reading)` возвращает список
`LaserDeviation` (по лазеру: измеренное, цель, Δ, в допуске ли) без логирования —
удобно для собственной обработки/накопления статистики.
## Формат IPC-файлов
`session.json`:
```json
{
"variation_type": "CHANGE_CURRENT_LD1",
"target_temp1": 28.0,
"target_temp2": 28.9,
"tolerance_c": 0.03,
"started_at_iso": "2026-07-27T12:00:00"
}
```
`readings.jsonl` (по одной строке-объекту на успешный опрос):
```json
{"seq":0,"mono_ns":123456789,"temp1":28.0,"temp2":28.9,"temp_ext1":22.0,"temp_ext2":23.0,"current1":33.0,"current2":35.0}
```
## Как определяется «раз в свип»
Плата не отдаёт явную границу свипа, поэтому период оценивается из параметров:
```
num_steps = round(|max_value - min_value| / step) + 1
per_point_s = delay_time / 1000 + time_step / 1_000_000
sweep_period = num_steps × per_point_s
```
Монитор опрашивает плату сразу после запуска, затем ждёт такой период после
каждого опроса; это оценка, без синхронизации с границами аппаратных свипов.
Если нужен другой темп —
`--strategy interval:<ms>`. (Внутренний счётчик `TO6` платы существует, но его
семантика не гарантирована, поэтому для тайминга он не используется.)
## Тесты
```bash
.venv/bin/python -m unittest python_app.tests.test_laser_temp_monitoring
```
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому
лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление).
@@ -1,36 +0,0 @@
"""Laser current-variation temperature monitoring and validation.
Three decoupled pieces connected via IPC files:
- :class:`LaserVariationSession` — target setpoints + tolerance frozen at start.
- :class:`LaserTemperatureMonitor` — polls the board once per sweep, publishes.
- :class:`LaserTemperatureChecker` — validates published readings, warns.
"""
from .checker import LaserDeviation, LaserTemperatureChecker
from .monitor import (
LaserTemperatureMonitor,
compute_sweep_period_s,
resolve_period_s,
)
from .readings_channel import ReadingReader, ReadingWriter, TemperatureReading
from .session import (
DEFAULT_READINGS_PATH,
DEFAULT_SESSION_PATH,
DEFAULT_TOLERANCE_C,
LaserVariationSession,
)
__all__ = [
"LaserDeviation",
"LaserTemperatureChecker",
"LaserTemperatureMonitor",
"compute_sweep_period_s",
"resolve_period_s",
"ReadingReader",
"ReadingWriter",
"TemperatureReading",
"DEFAULT_READINGS_PATH",
"DEFAULT_SESSION_PATH",
"DEFAULT_TOLERANCE_C",
"LaserVariationSession",
]
@@ -1,139 +0,0 @@
"""Independent laser temperature checker.
Reads the target setpoints frozen at variation start (:class:`LaserVariationSession`)
and validates each published :class:`TemperatureReading` against them. Both lasers
are checked independently: ``temp1`` against ``target_temp1`` and ``temp2`` against
``target_temp2``. When a laser's measured temperature deviates from its target by
more than the tolerance (default 0.03 °C), a warning is printed to the console.
Runs as its own process (see ``scripts/laser_temp_checker.py``), reading the JSONL
readings channel — it never touches the serial port, so it is fully independent of
the monitor and can be started, stopped, or restarted at any time.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import List
from .readings_channel import TemperatureReading
from .session import DEFAULT_TOLERANCE_C, LaserVariationSession
logger = logging.getLogger(__name__)
# Only a deviation strictly greater than the tolerance warns; this epsilon keeps a
# value the user intends to be exactly at the tolerance from tripping on float error
# (e.g. 28.03 - 28.00 == 0.030000000000001 in IEEE-754).
_FLOAT_EPS = 1e-9
@dataclass(slots=True)
class LaserDeviation:
"""Result of comparing one laser's measured temperature to its target."""
laser: int # 1 or 2
seq: int
measured: float
target: float
delta: float # measured - target, °C
within_tolerance: bool
class LaserTemperatureChecker:
"""Validates readings against per-laser targets and warns on mismatch.
Anti-spam: a laser's ok↔mismatch transitions are logged once; while a laser
stays out of tolerance, a reminder is emitted only every ``reminder_every``
readings (0 disables reminders). State is tracked independently per laser, so
a persistent laser-1 fault never suppresses a fresh laser-2 warning.
"""
def __init__(
self,
target_temp1: float,
target_temp2: float,
tolerance_c: float = DEFAULT_TOLERANCE_C,
reminder_every: int = 0,
) -> None:
self.target_temp1 = float(target_temp1)
self.target_temp2 = float(target_temp2)
self.tolerance_c = float(tolerance_c)
self.reminder_every = int(reminder_every)
# Per-laser state: mismatch flag + readings seen since the last log.
self._mismatch = {1: False, 2: False}
self._since_log = {1: 0, 2: 0}
@classmethod
def from_session(
cls, session: LaserVariationSession, reminder_every: int = 0
) -> "LaserTemperatureChecker":
return cls(
target_temp1=session.target_temp1,
target_temp2=session.target_temp2,
tolerance_c=session.tolerance_c,
reminder_every=reminder_every,
)
def evaluate(self, reading: TemperatureReading) -> List[LaserDeviation]:
"""Compute per-laser deviations without logging (pure)."""
return [
self._deviation(1, reading.seq, reading.temp1, self.target_temp1),
self._deviation(2, reading.seq, reading.temp2, self.target_temp2),
]
def process(self, reading: TemperatureReading) -> List[LaserDeviation]:
"""Evaluate a reading and emit console warnings, honouring anti-spam.
Returns the deviations for which a warning/reminder was emitted this call
(empty when both lasers are within tolerance and unchanged).
"""
warned: List[LaserDeviation] = []
for dev in self.evaluate(reading):
if self._should_warn(dev):
self._warn(dev)
warned.append(dev)
return warned
def _deviation(self, laser: int, seq: int, measured: float, target: float) -> LaserDeviation:
delta = measured - target
return LaserDeviation(
laser=laser,
seq=seq,
measured=measured,
target=target,
delta=delta,
within_tolerance=abs(delta) <= self.tolerance_c + _FLOAT_EPS,
)
def _should_warn(self, dev: LaserDeviation) -> bool:
laser = dev.laser
if not dev.within_tolerance:
if not self._mismatch[laser]:
# Fresh ok -> mismatch transition: always warn.
self._mismatch[laser] = True
self._since_log[laser] = 0
return True
# Still out of tolerance: warn again only every reminder_every readings.
self._since_log[laser] += 1
if self.reminder_every > 0 and self._since_log[laser] >= self.reminder_every:
self._since_log[laser] = 0
return True
return False
# Within tolerance: log a recovery once, then stay quiet.
if self._mismatch[laser]:
self._mismatch[laser] = False
self._since_log[laser] = 0
logger.info(
"Laser %d temperature back within tolerance: %.3f °C "
"(target %.3f, |Δ|=%.3f ≤ %.3f) [seq=%d]",
laser, dev.measured, dev.target, abs(dev.delta), self.tolerance_c, dev.seq,
)
return False
def _warn(self, dev: LaserDeviation) -> None:
logger.warning(
"Laser %d temperature off target: measured %.3f °C, target %.3f °C, "
"Δ=%+.3f °C exceeds tolerance ±%.3f °C [seq=%d]",
dev.laser, dev.measured, dev.target, dev.delta, self.tolerance_c, dev.seq,
)
@@ -1,147 +0,0 @@
"""Independent laser temperature monitor.
Owns a :class:`LaserController` connection and, once per current-variation sweep,
polls the board for a measurement and publishes it to a JSONL readings channel.
The board runs the current sweep autonomously after ``TASK_ENABLE``; the monitor
only reads the "last data point" via ``TRANS_ENABLE`` — exactly like the original
RadioPhotonic PC software's polling loop, but decoupled and headless.
Runs as its own process (see ``scripts/laser_temp_monitor.py``) so it is fully
independent of both the acquisition pipeline and the temperature checker.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Optional, Protocol
from .readings_channel import ReadingWriter, TemperatureReading
logger = logging.getLogger(__name__)
class _Temperatures(Protocol):
temp1: float
temp2: float
class _MeasurementSource(Protocol):
"""Minimal controller surface the monitor depends on (eases testing)."""
def get_measurements(self) -> _Temperatures | None: ...
def compute_sweep_period_s(
min_value: float,
max_value: float,
step: float,
time_step_us: float,
delay_time_ms: float,
) -> float:
"""Estimate the duration of one min→max current sweep, in seconds.
``num_steps = round(|max - min| / step) + 1`` points, each taking roughly the
inter-point delay plus the discretisation time. The board gives no explicit
end-of-sweep marker, so this computed period is how "once per sweep" is timed
by default.
"""
if step <= 0:
raise ValueError(f"step must be > 0, got {step}")
span = abs(max_value - min_value)
num_steps = round(span / step) + 1
per_point_s = delay_time_ms / 1000.0 + time_step_us / 1_000_000.0
return num_steps * per_point_s
def resolve_period_s(
strategy: str,
*,
min_value: float,
max_value: float,
step: float,
time_step_us: float,
delay_time_ms: float,
) -> float:
"""Turn a strategy string into a concrete per-reading period in seconds.
Supported strategies:
- ``"computed"`` — one reading per estimated sweep duration (default).
- ``"interval:<ms>"`` — a fixed period of ``<ms>`` milliseconds.
"""
if strategy == "computed":
return compute_sweep_period_s(min_value, max_value, step, time_step_us, delay_time_ms)
if strategy.startswith("interval:"):
try:
ms = float(strategy.split(":", 1)[1])
except ValueError as exc:
raise ValueError(f"Invalid interval strategy {strategy!r}") from exc
if ms <= 0:
raise ValueError(f"interval must be > 0 ms, got {ms}")
return ms / 1000.0
raise ValueError(
f"Unknown strategy {strategy!r}; expected 'computed' or 'interval:<ms>'"
)
@dataclass(slots=True)
class LaserTemperatureMonitor:
"""Polls a laser board once per sweep and publishes temperature readings.
Args:
controller: object exposing ``get_measurements()`` (a real
:class:`LaserController` in production, a fake in tests).
writer: destination channel implementing ``write(TemperatureReading)``.
period_s: seconds between readings (see :func:`resolve_period_s`).
"""
controller: _MeasurementSource
writer: ReadingWriter
period_s: float
def read_once(self, seq: int) -> Optional[TemperatureReading]:
"""Poll one measurement and turn it into a reading, or None if no data."""
measurements = self.controller.get_measurements()
if measurements is None:
logger.warning("No measurement returned from laser board (seq=%d)", seq)
return None
return TemperatureReading(
seq=seq,
mono_ns=time.monotonic_ns(),
temp1=float(measurements.temp1),
temp2=float(measurements.temp2),
temp_ext1=_opt(getattr(measurements, "temp_ext1", None)),
temp_ext2=_opt(getattr(measurements, "temp_ext2", None)),
current1=_opt(getattr(measurements, "current1", None)),
current2=_opt(getattr(measurements, "current2", None)),
)
def run(self, stop_event: Optional[threading.Event] = None) -> None:
"""Poll-and-publish until ``stop_event`` is set (runs forever if None).
Each iteration reads once, publishes, then waits one period. The wait is
interruptible via ``stop_event`` for a prompt clean shutdown.
"""
stop = stop_event or threading.Event()
seq = 0
logger.info("Temperature monitor started: period=%.3fs", self.period_s)
while not stop.is_set():
try:
reading = self.read_once(seq)
except Exception: # noqa: BLE001 — a transient read error must not kill the monitor
logger.warning("Measurement read failed; continuing", exc_info=True)
reading = None
if reading is not None:
self.writer.write(reading)
logger.debug(
"Published reading seq=%d T1=%.3f T2=%.3f", seq, reading.temp1, reading.temp2
)
seq += 1
stop.wait(self.period_s)
logger.info("Temperature monitor stopped after %d readings", seq)
def _opt(value: float | None) -> Optional[float]:
return None if value is None else float(value)
@@ -1,131 +0,0 @@
"""JSONL append/tail channel carrying per-sweep temperature readings.
The monitor process appends one JSON object per line; the checker process tails
the file from its end and parses each newly-appended line. A newline-delimited
file is used (rather than a socket) so the monitor and checker can start, stop,
and restart on independent lifecycles without a handshake — the checker simply
resumes tailing wherever the file currently ends.
A reader only ever consumes lines terminated by ``\\n``; a partially-written last
line is left buffered until its newline arrives, so a reading is never parsed
half-written.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterator, Optional, Union
_PathLike = Union[str, os.PathLike[str]]
@dataclass(slots=True)
class TemperatureReading:
"""One temperature/current snapshot published once per sweep.
``temp1``/``temp2`` are the internal laser temperatures (the values validated
against the setpoints); ``temp_ext1``/``temp_ext2`` are the external
thermistors, carried for diagnostics only.
"""
seq: int
mono_ns: int
temp1: float
temp2: float
temp_ext1: Optional[float] = None
temp_ext2: Optional[float] = None
current1: Optional[float] = None
current2: Optional[float] = None
def to_json_line(self) -> str:
return json.dumps(asdict(self), separators=(",", ":"))
@classmethod
def from_json_line(cls, line: str) -> "TemperatureReading":
payload = json.loads(line)
return cls(
seq=int(payload["seq"]),
mono_ns=int(payload["mono_ns"]),
temp1=float(payload["temp1"]),
temp2=float(payload["temp2"]),
temp_ext1=_opt_float(payload.get("temp_ext1")),
temp_ext2=_opt_float(payload.get("temp_ext2")),
current1=_opt_float(payload.get("current1")),
current2=_opt_float(payload.get("current2")),
)
def _opt_float(value: str | float | None) -> Optional[float]:
return None if value is None else float(value)
class ReadingWriter:
"""Appends :class:`TemperatureReading` objects to a JSONL file.
Each write is a single line flushed to the OS so a tailing reader sees it
promptly. Use as a context manager or call :meth:`close` explicitly.
"""
def __init__(self, path: _PathLike) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
# Line-buffered append; each reading is one line.
self._fh = self.path.open("a", encoding="utf-8", buffering=1)
def write(self, reading: TemperatureReading) -> None:
self._fh.write(reading.to_json_line() + "\n")
self._fh.flush()
def close(self) -> None:
if not self._fh.closed:
self._fh.close()
def __enter__(self) -> "ReadingWriter":
return self
def __exit__(self, *_exc: object) -> None:
self.close()
class ReadingReader:
"""Tails a JSONL readings file, yielding complete lines as they appear.
``from_start=False`` (default) begins at the current end of file, so the
checker validates readings produced from the moment it starts. Partial
trailing lines are buffered until their newline arrives.
"""
def __init__(self, path: _PathLike, *, from_start: bool = False) -> None:
self.path = Path(path)
self._buffer = ""
self._pos = 0
if not from_start and self.path.exists():
self._pos = self.path.stat().st_size
def poll(self) -> Iterator[TemperatureReading]:
"""Yield every complete reading appended since the last poll.
Malformed lines are skipped silently (a truncated/legacy line must not
crash a long-running checker); callers that care can validate seq gaps.
"""
if not self.path.exists():
return
with self.path.open("r", encoding="utf-8") as fh:
fh.seek(self._pos)
chunk = fh.read()
self._pos = fh.tell()
if not chunk:
return
self._buffer += chunk
*complete, self._buffer = self._buffer.split("\n")
for line in complete:
line = line.strip()
if not line:
continue
try:
yield TemperatureReading.from_json_line(line)
except (ValueError, KeyError, TypeError):
continue
@@ -1,72 +0,0 @@
"""Variation-session snapshot shared between the temperature monitor and checker.
When a current-variation task is started, the target static laser temperatures
(``static_temp1``/``static_temp2``) and the acceptable tolerance are frozen into a
small JSON file. The temperature checker reads this file to know what "correct"
means for the run, so it validates against the setpoints that were in effect *at
variation start* — independent of any later edits to the run config.
Only current variation of laser 1 (``CHANGE_CURRENT_LD1``) is supported by the
firmware today; the session still records both laser targets because both
temperatures are held static during that task and both are validated.
"""
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Union
from python_app.file_io import atomic_write_json
_PathLike = Union[str, os.PathLike[str]]
# Default IPC locations. Both are overridable via CLI/API so several runs can use
# distinct files. Kept in the system temp dir so no project state is polluted.
DEFAULT_SESSION_PATH = Path(tempfile.gettempdir()) / "laser_variation_session.json"
DEFAULT_READINGS_PATH = Path(tempfile.gettempdir()) / "laser_temp_readings.jsonl"
DEFAULT_TOLERANCE_C = 0.03
@dataclass(slots=True)
class LaserVariationSession:
"""Target setpoints and tolerance frozen at variation start."""
variation_type: str
target_temp1: float
target_temp2: float
tolerance_c: float = DEFAULT_TOLERANCE_C
started_at_iso: str = ""
def save(self, path: _PathLike = DEFAULT_SESSION_PATH) -> Path:
"""Atomically write the session snapshot to ``path`` and return it.
Writes to a temp file in the same directory then renames, so a concurrent
checker never observes a half-written file.
"""
return atomic_write_json(Path(path), asdict(self))
@classmethod
def load(cls, path: _PathLike = DEFAULT_SESSION_PATH) -> "LaserVariationSession":
"""Load a session snapshot from ``path``.
Raises FileNotFoundError if the file is absent and ValueError if it is not
a valid session object.
"""
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Session file must be a JSON object: {path}")
try:
return cls(
variation_type=str(payload["variation_type"]),
target_temp1=float(payload["target_temp1"]),
target_temp2=float(payload["target_temp2"]),
tolerance_c=float(payload.get("tolerance_c", DEFAULT_TOLERANCE_C)),
started_at_iso=str(payload.get("started_at_iso", "")),
)
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"Malformed session file {path}: {exc}") from exc
@@ -1,156 +1,39 @@
"""Communication protocol for the laser control module.
Encodes commands to wire bytes, decodes device responses, and manages the
serial port connection to the laser control board.
"""
"""Serial transport and little-endian commands for the dual-laser board."""
import errno
import logging
from typing import Optional
from enum import IntEnum
from datetime import datetime
import struct
from functools import reduce
from operator import xor
import serial
import serial.tools.list_ports
from .constants import (
BAUDRATE, SERIAL_TIMEOUT_SEC,
GET_DATA_TOTAL_LENGTH,
SEND_PARAMS_TOTAL_LENGTH,
TASK_ENABLE_COMMAND_LENGTH,
CMD_DECODE_ENABLE, CMD_DEFAULT_ENABLE,
CMD_TRANS_ENABLE, CMD_REMOVE_FILE,
CMD_STATE, CMD_TASK_ENABLE,
STATE_DESCRIPTIONS, STATE_OK,
)
from .conversions import (
temp_c_to_n, temp_n_to_c,
temp_ext_n_to_c,
current_ma_to_n, current_n_to_ma,
voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v,
)
from .models import Measurements
from .exceptions import (
CommunicationError,
PortNotFoundError,
PortBusyError,
CRCError,
ProtocolError,
)
from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC
from .conversions import current_ma_to_n, temp_c_to_n
from .validators import validate_manual, validate_variation
from .exceptions import CommunicationError, PortBusyError, ProtocolError
logger = logging.getLogger(__name__)
class TaskType(IntEnum):
MANUAL = 0x00
CHANGE_CURRENT_LD1 = 0x01
CHANGE_CURRENT_LD2 = 0x02
CHANGE_TEMPERATURE_LD1 = 0x03
CHANGE_TEMPERATURE_LD2 = 0x04
def _command(header: int, *words: int) -> bytes:
"""Append XOR checksum; header is excluded, every word is uint16 LE."""
if any(not isinstance(word, int) or not 0 <= word <= 65535 for word in words):
raise ValueError("Laser command words must be uint16 integers")
return struct.pack(f"<{len(words) + 2}H", header, *words, reduce(xor, words, 0))
# ---- Low-level helpers --------------------------------------------------
def _int_to_hex4(value: int) -> str:
"""Return 4-character lowercase hex string (0–65535)."""
if value < 0 or value > 65535:
raise ValueError(f"Value {value} out of uint16 range [0, 65535]")
return f"{value:04x}"
def _flipfour(hex_word: str) -> str:
"""Swap the two byte-pairs of a 4-char hex word: 'aabb' -> 'bbaa' (little-endian)."""
if len(hex_word) != 4:
raise ValueError(f"Expected 4-char hex string, got '{hex_word}'")
return hex_word[2:4] + hex_word[0:2]
def _xor_crc(words: list) -> str:
"""XOR all 16-bit hex words and return 4-char hex CRC."""
result = int(words[0], 16)
for w in words[1:]:
result ^= int(w, 16)
return _int_to_hex4(result)
def _build_crc(data_hex: str) -> str:
"""Calculate XOR CRC over words 1..N of a hex string (skip word 0)."""
words = [data_hex[i:i+4] for i in range(0, len(data_hex), 4)]
return _xor_crc(words[1:])
def _encode_setup() -> str:
"""Build the 16-bit setup word (all subsystems enabled, SD save off)."""
bits = ['0'] * 16
bits[15] = '1' # enable work
bits[14] = '1' # enable 5v1
bits[13] = '1' # enable 5v2
bits[12] = '1' # enable LD1
bits[11] = '1' # enable LD2
bits[10] = '1' # enable REF1
bits[9] = '1' # enable REF2
bits[8] = '1' # enable TEC1
bits[7] = '1' # enable TEC2
bits[6] = '1' # enable temp stab 1
bits[5] = '1' # enable temp stab 2
bits[4] = '0' # enable sd save (disabled)
bits[3] = '1' # enable PI1 coef read
bits[2] = '1' # enable PI2 coef read
bits[1] = '0' # reserved
bits[0] = '0' # reserved
return f"{int(''.join(bits), 2):04x}"
# ---- Response dataclass --------------------------------------------------
class Response:
"""Decoded device DATA response."""
__slots__ = [
'current1', 'current2',
'temp1', 'temp2',
'temp_ext1', 'temp_ext2',
'voltage_3v3', 'voltage_5v1', 'voltage_5v2', 'voltage_7v0',
'to6_lsb', 'to6_msb',
'message_id',
'header',
]
def to_measurements(self) -> Measurements:
return Measurements(
current1=self.current1,
current2=self.current2,
temp1=self.temp1,
temp2=self.temp2,
temp_ext1=self.temp_ext1,
temp_ext2=self.temp_ext2,
voltage_3v3=self.voltage_3v3,
voltage_5v1=self.voltage_5v1,
voltage_5v2=self.voltage_5v2,
voltage_7v0=self.voltage_7v0,
timestamp=datetime.now(),
message_id=self.message_id,
to6_counter_lsb=self.to6_lsb,
to6_counter_msb=self.to6_msb,
)
# ---- Protocol class --------------------------------------------------
class Protocol:
"""
Encodes commands and decodes responses for the laser control board.
"""Exclusive serial connection and the commands used by the application."""
Can also manage a serial port connection when port is provided.
"""
def __init__(self, port: Optional[str] = None):
self._port_name = port
self._serial: Optional[serial.Serial] = None
def __init__(self, port: str):
self.port = port
self._serial: serial.Serial | None = None
# ---- Connection management
def connect(self) -> None:
"""Open the serial port. Auto-detects the device path when port is None.
"""Open the serial port.
The port is opened with ``exclusive=True`` so only one program can own the
laser controller at a time. This is required because the board is a single
@@ -162,7 +45,7 @@ class Protocol:
"""
if self.is_connected:
return
port = self._port_name or self._detect_port()
port = self.port
try:
self._serial = serial.Serial(
port=port,
@@ -194,13 +77,6 @@ class Protocol:
def is_connected(self) -> bool:
return self._serial is not None and self._serial.is_open
def _detect_port(self) -> str:
"""Return first available serial port device path."""
ports = list(serial.tools.list_ports.comports())
if not ports:
raise PortNotFoundError()
return ports[0].device
# ---- Raw I/O
def send_raw(self, data: bytes) -> None:
@@ -213,199 +89,45 @@ class Protocol:
raise CommunicationError("Serial port is not connected")
return self._serial.read(length)
# ---- Command encoders -----------------------------------------------
@staticmethod
def encode_decode_enable(
temp1: float,
temp2: float,
current1: float,
current2: float,
pi_coeff1_p: int,
pi_coeff1_i: int,
pi_coeff2_p: int,
pi_coeff2_i: int,
temp1: float, temp2: float, current1: float, current2: float,
pi_coeff1_p: int, pi_coeff1_i: int, pi_coeff2_p: int, pi_coeff2_i: int,
message_id: int,
) -> bytes:
"""
Build DECODE_ENABLE command (0x1111).
Sets temperature and current setpoints for both lasers.
Returns 30-byte bytearray.
"""
if current1 < 0 or current2 < 0:
raise ValueError("Current values must not be negative")
data = _flipfour(_int_to_hex4(CMD_DECODE_ENABLE)) # Word 0
data += _flipfour(_encode_setup()) # Word 1
data += _flipfour(_int_to_hex4(temp_c_to_n(temp1))) # Word 2
data += _flipfour(_int_to_hex4(temp_c_to_n(temp2))) # Word 3
data += _flipfour('0000') * 3 # Words 4-6
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 7
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 8
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 9
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 10
data += _flipfour(_int_to_hex4(message_id & 0xFFFF)) # Word 11
data += _flipfour(_int_to_hex4(current_ma_to_n(current1))) # Word 12
data += _flipfour(_int_to_hex4(current_ma_to_n(current2))) # Word 13
data += _build_crc(data) # Word 14
result = bytearray.fromhex(data)
assert len(result) == SEND_PARAMS_TOTAL_LENGTH, \
f"DECODE_ENABLE length mismatch: {len(result)}"
return bytes(result)
"""DECODE_ENABLE: constant currents, temperatures and PI coefficients."""
validate_manual(temp1, temp2, current1, current2)
# 0x37ff enables both channels and their regulators; SD recording is off.
return _command(
0x1111, 0x37ff, temp_c_to_n(temp1), temp_c_to_n(temp2), 0, 0, 0,
pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i, message_id & 0xffff,
current_ma_to_n(current1), current_ma_to_n(current2),
)
@staticmethod
def encode_task_enable(
task_type: TaskType,
static_temp1: float,
static_temp2: float,
static_current1: float,
static_current2: float,
min_value: float,
max_value: float,
step: float,
time_step: int,
delay_time: int,
message_id: int,
pi_coeff1_p: int = 1,
pi_coeff1_i: int = 1,
pi_coeff2_p: int = 1,
pi_coeff2_i: int = 1,
static_temp1: float, static_temp2: float, static_current1: float, static_current2: float,
min_value: float, max_value: float, step: float, time_step: int, delay_time: int,
pi_coeff1_p: int = 1, pi_coeff1_i: int = 1, pi_coeff2_p: int = 1, pi_coeff2_i: int = 1,
variation_type: str = "CHANGE_CURRENT_LD1",
) -> bytes:
"""
Build TASK_ENABLE command (0x7777).
Starts a measurement task (current or temperature variation).
Returns 32-byte bytearray.
"""
if not isinstance(task_type, TaskType):
try:
task_type = TaskType(task_type)
except ValueError:
raise ValueError(f"Invalid task_type: {task_type}")
data = _flipfour(_int_to_hex4(CMD_TASK_ENABLE)) # Word 0
data += _flipfour(_encode_setup()) # Word 1
data += _flipfour(_int_to_hex4(task_type.value)) # Word 2
match task_type:
case TaskType.CHANGE_CURRENT_LD1:
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 7
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current2)))# Word 8
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 9
case TaskType.CHANGE_CURRENT_LD2:
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
# Word 5: current step uses the same current_ma_to_n scaling as
# min/max (and as LD1) so equal physical steps map to equal wire values.
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp1))) # Word 9
case TaskType.CHANGE_TEMPERATURE_LD1 | TaskType.CHANGE_TEMPERATURE_LD2:
raise NotImplementedError("Temperature variation is not yet implemented in firmware")
case _:
raise ValueError(f"Unsupported task type: {task_type}")
data += _flipfour(_int_to_hex4(int(delay_time))) # Word 10: Tau in ms (3-10)
data += _flipfour(_int_to_hex4(pi_coeff1_p)) # Word 11
data += _flipfour(_int_to_hex4(pi_coeff1_i)) # Word 12
data += _flipfour(_int_to_hex4(pi_coeff2_p)) # Word 13
data += _flipfour(_int_to_hex4(pi_coeff2_i)) # Word 14
data += _build_crc(data) # Word 15
result = bytearray.fromhex(data)
assert len(result) == TASK_ENABLE_COMMAND_LENGTH, \
f"TASK_ENABLE length mismatch: {len(result)}"
return bytes(result)
@staticmethod
def encode_trans_enable(message_id: int = 0) -> bytes:
"""Build TRANS_ENABLE command (0x4444) — request last data."""
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE)))
@staticmethod
def encode_state(message_id: int = 0) -> bytes:
"""Build STATE command (0x6666) — request device state."""
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_STATE)))
@staticmethod
def encode_default_enable(message_id: int = 0) -> bytes:
"""Build DEFAULT_ENABLE command (0x2222) — reset device."""
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
# ---- Response decoders -----------------------------------------------
@staticmethod
def decode_response(data: bytes) -> Response:
"""
Decode a 30-byte DATA response from the device.
Raises:
ProtocolError: If data length is wrong.
CRCError: If CRC check fails.
"""
if len(data) != GET_DATA_TOTAL_LENGTH:
raise ProtocolError(
f"Expected {GET_DATA_TOTAL_LENGTH} bytes, got {len(data)} bytes"
)
hex_str = data.hex()
def get_word(num: int) -> str:
return _flipfour(hex_str[num*4: num*4+4])
def get_int_word(num: int) -> int:
return int(get_word(num), 16)
# CRC check: XOR over words 1..13 (wire order), compare with word 14 (wire order)
crc_words = [hex_str[i:i+4] for i in range(4, len(hex_str)-4, 4)]
computed = int(crc_words[0], 16)
for w in crc_words[1:]:
computed ^= int(w, 16)
stored = int(hex_str[56:60], 16)
if computed != stored:
raise CRCError(expected=computed, received=stored)
resp = Response()
resp.header = get_word(0)
resp.current1 = current_n_to_ma(get_int_word(1))
resp.current2 = current_n_to_ma(get_int_word(2))
resp.to6_lsb = get_int_word(3)
resp.to6_msb = get_int_word(4)
resp.temp1 = temp_n_to_c(get_int_word(5))
resp.temp2 = temp_n_to_c(get_int_word(6))
resp.temp_ext1 = temp_ext_n_to_c(get_int_word(7))
resp.temp_ext2 = temp_ext_n_to_c(get_int_word(8))
resp.voltage_3v3 = voltage_3v3_n_to_v(get_int_word(9))
resp.voltage_5v1 = voltage_5v_n_to_v(get_int_word(10))
resp.voltage_5v2 = voltage_5v_n_to_v(get_int_word(11))
resp.voltage_7v0 = voltage_7v_n_to_v(get_int_word(12))
resp.message_id = get_int_word(13)
return resp
"""TASK_ENABLE: sweep LD1 current while LD2 and both temperatures stay fixed."""
if variation_type != "CHANGE_CURRENT_LD1":
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")
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),
int(delay_time), pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i,
)
@staticmethod
def decode_state(data: bytes) -> int:
"""
Decode a 2-byte STATE response from the device.
Returns:
Integer state code; see STATE_* constants.
"""
if len(data) < 2:
raise ProtocolError(f"STATE response too short: {len(data)} bytes")
hex_str = data.hex()
state_hex = _flipfour(hex_str[0:4])
return int(state_hex, 16)
@staticmethod
def state_to_description(state_hex_str: str) -> str:
"""Return human-readable description for a state hex string."""
return STATE_DESCRIPTIONS.get(state_hex_str, "Unknown or reserved error.")
"""STATE is a two-byte error bitmask; zero acknowledges the command."""
if len(data) != 2:
raise ProtocolError(f"Expected 2 STATE bytes, got {len(data)}")
return int.from_bytes(data, "little")
@@ -1,257 +1,33 @@
"""
Parameter validation for laser control module.
Validates all input parameters against physical constraints
and protocol limits before sending to device.
"""
"""Physical limits checked before command encoding or any board reset."""
import math
from typing import Dict, Any, Tuple
from .constants import (
TEMP_MIN_C, TEMP_MAX_C,
CURRENT_MIN_MA, CURRENT_MAX_MA,
TEMP_MIN_C, TEMP_MAX_C, CURRENT_MIN_MA, CURRENT_MAX_MA,
CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA,
TEMP_STEP_MIN_C, TEMP_STEP_MAX_C,
TIME_STEP_MIN_US, TIME_STEP_MAX_US,
DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS,
TIME_STEP_MIN_US, TIME_STEP_MAX_US, DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS,
)
from .exceptions import (
ValidationError,
TemperatureOutOfRangeError,
CurrentOutOfRangeError,
InvalidParameterError,
)
from .models import VariationType
from .exceptions import ValidationError
class ParameterValidator:
"""Validates all input parameters for the laser controller."""
def _range(value: float, name: str, low: float, high: float) -> None:
if not isinstance(value, (int, float)) or not math.isfinite(value) or not low <= value <= high:
raise ValidationError(f"{name} must be a finite number in [{low}, {high}], got {value!r}")
@staticmethod
def _check_numeric(value: Any, param_name: str) -> float:
"""Check that value is a valid finite number. Returns float."""
if value is None:
raise InvalidParameterError(param_name, "Value must not be None")
if not isinstance(value, (int, float)):
raise InvalidParameterError(param_name, "Value must be a number")
if math.isnan(value):
raise InvalidParameterError(param_name, "Value must not be NaN")
if math.isinf(value):
raise InvalidParameterError(param_name, "Value must not be infinite")
return float(value)
@staticmethod
def validate_temperature(value: Any, param_name: str) -> float:
"""
Validate a laser temperature value.
def validate_manual(temp1: float, temp2: float, current1: float, current2: float) -> None:
for name, value in (("temp1", temp1), ("temp2", temp2)):
_range(value, name, TEMP_MIN_C, TEMP_MAX_C)
for name, value in (("current1", current1), ("current2", current2)):
_range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA)
Args:
value: Temperature in °C.
param_name: Parameter name for error messages.
Returns:
Validated temperature as float.
Raises:
InvalidParameterError: If value is not a valid number.
TemperatureOutOfRangeError: If value is outside [TEMP_MIN_C, TEMP_MAX_C].
"""
value = ParameterValidator._check_numeric(value, param_name)
if value < TEMP_MIN_C or value > TEMP_MAX_C:
raise TemperatureOutOfRangeError(
param_name, value, TEMP_MIN_C, TEMP_MAX_C
)
return value
@staticmethod
def validate_current(value: Any, param_name: str) -> float:
"""
Validate a laser drive current value.
Args:
value: Current in mA.
param_name: Parameter name for error messages.
Returns:
Validated current as float.
Raises:
InvalidParameterError: If value is not a valid number.
CurrentOutOfRangeError: If value is outside [CURRENT_MIN_MA, CURRENT_MAX_MA].
"""
value = ParameterValidator._check_numeric(value, param_name)
if value < CURRENT_MIN_MA or value > CURRENT_MAX_MA:
raise CurrentOutOfRangeError(
param_name, value, CURRENT_MIN_MA, CURRENT_MAX_MA
)
return value
@staticmethod
def validate_time_params(time_step: Any, delay_time: Any) -> Tuple[int, int]:
"""
Validate time parameters for variation mode.
Args:
time_step: Discretisation time step in microseconds.
delay_time: Delay between pulses in milliseconds.
Returns:
Tuple (time_step, delay_time) as integers.
Raises:
InvalidParameterError: If values are not numeric.
ValidationError: If values are outside allowed ranges.
"""
if not isinstance(time_step, (int, float)):
raise InvalidParameterError("time_step", "Value must be a number")
if not isinstance(delay_time, (int, float)):
raise InvalidParameterError("delay_time", "Value must be a number")
time_step_int = int(time_step)
delay_time_int = int(delay_time)
if time_step_int < TIME_STEP_MIN_US or time_step_int > TIME_STEP_MAX_US:
raise ValidationError(
f"time step {time_step_int} µs is out of range "
f"[{TIME_STEP_MIN_US} - {TIME_STEP_MAX_US}] µs"
)
if delay_time_int < DELAY_TIME_MIN_MS or delay_time_int > DELAY_TIME_MAX_MS:
raise ValidationError(
f"delay time {delay_time_int} ms is out of range "
f"[{DELAY_TIME_MIN_MS} - {DELAY_TIME_MAX_MS}] ms"
)
return time_step_int, delay_time_int
@staticmethod
def validate_variation_params(
params: Dict[str, Any],
variation_type: Any
) -> Dict[str, Any]:
"""
Validate parameters for variation mode.
Args:
params: Dictionary with keys:
min_value, max_value, step, time_step, delay_time.
variation_type: A VariationType enum value.
Returns:
Dictionary with validated and type-coerced values.
Raises:
ValidationError: For any constraint violation.
InvalidParameterError: For wrong types.
"""
# Validate variation type
if not isinstance(variation_type, VariationType):
try:
variation_type = VariationType(variation_type)
except (ValueError, KeyError):
raise ValidationError(
f"Invalid variation type '{variation_type}'. "
f"Must be one of {[e.name for e in VariationType]}"
)
# Check required keys
required_keys = {'min_value', 'max_value', 'step', 'time_step', 'delay_time'}
missing = required_keys - params.keys()
if missing:
raise ValidationError(
f"Missing required parameters: {sorted(missing)}"
)
# Validate min/max
min_val = ParameterValidator._check_numeric(params['min_value'], 'min_value')
max_val = ParameterValidator._check_numeric(params['max_value'], 'max_value')
if min_val >= max_val:
raise ValidationError(
f"min_value ({min_val}) must be less than max_value ({max_val})"
)
# Validate step based on variation type
step = ParameterValidator._check_numeric(params['step'], 'step')
is_current_variation = variation_type in (
VariationType.CHANGE_CURRENT_LD1,
VariationType.CHANGE_CURRENT_LD2
)
is_temp_variation = variation_type in (
VariationType.CHANGE_TEMPERATURE_LD1,
VariationType.CHANGE_TEMPERATURE_LD2
)
if is_current_variation:
step_min, step_max = CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA
unit = "mA"
# Also validate range against current limits
ParameterValidator.validate_current(min_val, 'min_value')
ParameterValidator.validate_current(max_val, 'max_value')
elif is_temp_variation:
step_min, step_max = TEMP_STEP_MIN_C, TEMP_STEP_MAX_C
unit = "°C"
# Also validate range against temperature limits
ParameterValidator.validate_temperature(min_val, 'min_value')
ParameterValidator.validate_temperature(max_val, 'max_value')
else:
raise ValidationError(
f"Variation type {variation_type.name} cannot be used in variation mode"
)
if step <= 0:
raise ValidationError(
f"step must be positive, got {step} {unit}"
)
if step < step_min:
raise ValidationError(
f"step {step} {unit} is too small (minimum {step_min} {unit})"
)
if step > step_max:
raise ValidationError(
f"step {step} {unit} is too large (maximum {step_max} {unit})"
)
# Validate time parameters
time_step, delay_time = ParameterValidator.validate_time_params(
params['time_step'], params['delay_time']
)
return {
'variation_type': variation_type,
'min_value': min_val,
'max_value': max_val,
'step': step,
'time_step': time_step,
'delay_time': delay_time,
}
@staticmethod
def validate_manual_mode_params(
temp1: Any,
temp2: Any,
current1: Any,
current2: Any,
) -> Dict[str, float]:
"""
Validate all four manual mode parameters.
Args:
temp1: Laser 1 temperature, °C.
temp2: Laser 2 temperature, °C.
current1: Laser 1 current, mA.
current2: Laser 2 current, mA.
Returns:
Dict with validated floats: temp1, temp2, current1, current2.
Raises:
ValidationError: For any out-of-range value.
InvalidParameterError: For wrong types.
"""
return {
'temp1': ParameterValidator.validate_temperature(temp1, 'temp1'),
'temp2': ParameterValidator.validate_temperature(temp2, 'temp2'),
'current1': ParameterValidator.validate_current(current1, 'current1'),
'current2': ParameterValidator.validate_current(current2, 'current2'),
}
def validate_variation(min_value: float, max_value: float, step: float,
time_step: int, delay_time: int) -> None:
for name, value in (("min_value", min_value), ("max_value", max_value)):
_range(value, name, CURRENT_MIN_MA, CURRENT_MAX_MA)
if min_value >= max_value:
raise ValidationError("min_value must be less than max_value")
_range(step, "step", CURRENT_STEP_MIN_MA, CURRENT_STEP_MAX_MA)
_range(time_step, "time_step (us)", TIME_STEP_MIN_US, TIME_STEP_MAX_US)
_range(delay_time, "delay_time (ms)", DELAY_TIME_MIN_MS, DELAY_TIME_MAX_MS)
@@ -36,7 +36,7 @@ class MultiDeviceVnaController:
self,
master_serial_number: str,
slave_serial_numbers: Sequence[str] = (),
force_external_reference: bool = True,
force_external_reference: bool = False,
switch_substage_count: int = 1,
) -> None:
"""Open all configured devices and initialize controller state.
@@ -51,7 +51,7 @@ class MultiDeviceLibreVnaService:
master_serial: str
slave_serials: list[str]
force_external_reference: bool = True
force_external_reference: bool = False
recovery_attempts: int = 3
# Stimulus switch positions stepped through WITHIN one frequency point. 1 keeps the
# stock 2-output matrix; 2 doubles the outputs (4) without a second sweep, at the
@@ -0,0 +1 @@
"""L-Card acquisition, reference-phase processing and laser configuration."""
@@ -0,0 +1,37 @@
"""Apply constant laser settings or LD1 current variation before ADC acquisition."""
from dataclasses import asdict
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
from python_app.hardware_full.laser_control.protocol import Protocol
from python_app.models.run_config_schema import RunConfigModel
def apply_rph_radar_laser_control(config: RunConfigModel) -> bool:
"""Validate every command before connecting/resetting; leave a started sweep running."""
laser = config.radar.laser_control
if not laser.enabled:
return False
if not laser.port:
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
mode = laser.mode.strip().lower()
if mode not in {"manual", "variation"}:
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
pi = {name: getattr(laser, name) for name in ("pi_coeff1_p", "pi_coeff1_i", "pi_coeff2_p", "pi_coeff2_i")}
manual = (asdict(laser.manual) if mode == "manual" else
{name: getattr(laser.variation, "static_" + name) for name in ("temp1", "temp2", "current1", "current2")})
variation = asdict(laser.variation)
Protocol.encode_decode_enable(**manual, **pi, message_id=DEVICE_MAIN_MESSAGE_ID)
if mode == "variation":
Protocol.encode_task_enable(**variation, **pi)
controller = LaserController(port=laser.port, **pi)
try:
controller.connect()
controller.reset()
controller.set_manual_mode(**manual, message_id=DEVICE_MAIN_MESSAGE_ID)
if mode == "variation":
controller.start_variation(params=variation)
return True
finally:
controller.disconnect()
@@ -1,8 +1,8 @@
"""Signal processing for the Kamil ADC reference-channel acquisition.
"""Signal processing for the RadioPhotonic radar (RPH) reference-channel acquisition.
Each sweep arrives as two aligned complex arrays — the *main* signal and a
*reference* signal — sampled at the same step indices (see
:mod:`python_app.hardware_full.kamil_adc.protocol`). Turning that into a stable,
:mod:`python_app.hardware_full.rph_radar.protocol`). Turning that into a stable,
comparable S21 trace is a fixed three-stage pipeline:
1. **Frequency axis from the reference phase.** The reference arm has a constant
@@ -41,12 +41,13 @@ Everything here is pure and free of I/O so it can be unit-tested in isolation.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import replace
import numpy as np
from numpy.typing import NDArray
from python_app.models.run_config_schema import KamilAdcModel
from python_app.models.run_config_schema import RphRadarModel
from python_app.models.run_config_validation import validate_rph_radar_model
# A reference sample whose magnitude falls to (effectively) zero carries no usable
# amplitude or phase; such points are dropped before normalization rather than
@@ -61,94 +62,28 @@ _MIN_USABLE_POINTS = 2
# One full turn of reference phase. ``np.unwrap`` anchors each sweep's phase ramp
# to the raw angle of the first sample on the (-pi, pi] branch, so a stray sweep
# whose anchor crossed the +/-pi cut is offset by exactly this; the cross-sweep
# anchor tracking snaps it back (see ``KamilAdcSweepProcessor._align_phase_branch``).
# anchor tracking snaps it back (see ``RphRadarSweepProcessor._align_phase_branch``).
_PHASE_BRANCH_PERIOD_RAD = 2.0 * np.pi
@dataclass(frozen=True, slots=True)
class KamilAdcProcessingParams:
"""Immutable phase→frequency calibration and output-grid definition.
Built from :class:`~python_app.models.run_config_schema.KamilAdcModel` via
:meth:`from_kamil_model`, but kept decoupled from the config types so the
processing can be exercised with plain numbers in tests.
"""
phase0_rad: float
freq0_hz: float
phase1_rad: float
freq1_hz: float
band_start_hz: float
band_stop_hz: float
band_points: int
reference_amplitude_floor: float = _REFERENCE_AMPLITUDE_FLOOR
def __post_init__(self) -> None:
if not np.isfinite([self.phase0_rad, self.phase1_rad, self.freq0_hz, self.freq1_hz]).all():
raise ValueError("Kamil ADC phase calibration anchors must be finite")
if self.phase0_rad == self.phase1_rad:
raise ValueError("Kamil ADC phase calibration anchors must use distinct phases")
if self.freq0_hz == self.freq1_hz:
raise ValueError("Kamil ADC phase calibration anchors must map to distinct frequencies")
if not (np.isfinite(self.band_start_hz) and np.isfinite(self.band_stop_hz)):
raise ValueError("Kamil ADC band edges must be finite")
if self.band_stop_hz <= self.band_start_hz:
raise ValueError("Kamil ADC band stop_hz must be greater than start_hz")
if self.band_points < 2:
raise ValueError("Kamil ADC band points must be >= 2")
if self.reference_amplitude_floor <= 0.0:
raise ValueError("Kamil ADC reference amplitude floor must be > 0")
@classmethod
def from_kamil_model(cls, kamil_adc: KamilAdcModel) -> KamilAdcProcessingParams:
"""Build parameters from the ``radar.kamil_adc`` config section."""
calibration = kamil_adc.phase_calibration
band = kamil_adc.band
return cls(
phase0_rad=float(calibration.phase0_rad),
freq0_hz=float(calibration.freq0_hz),
phase1_rad=float(calibration.phase1_rad),
freq1_hz=float(calibration.freq1_hz),
band_start_hz=float(band.start_hz),
band_stop_hz=float(band.stop_hz),
band_points=int(band.points),
)
@property
def hz_per_rad(self) -> float:
"""Frequency change per radian of reference phase (the calibration slope)."""
return (self.freq1_hz - self.freq0_hz) / (self.phase1_rad - self.phase0_rad)
class KamilAdcSweepProcessor:
class RphRadarSweepProcessor:
"""Turns aligned (main, reference) sweeps into S21 traces on a fixed grid.
The output grid is computed once from the parameters and reused for every
sweep, so all traces this processor emits share one identical frequency axis.
"""
__slots__ = ("_params", "_grid_hz", "_previous_anchor_rad")
def __init__(self, params: KamilAdcProcessingParams) -> None:
self._params = params
self._grid_hz = np.linspace(
params.band_start_hz, params.band_stop_hz, params.band_points, dtype=np.float64
)
# Absolute (branch-tracked) reference phase of the last ACCEPTED sweep's
# first sample, carried across sweeps so a +/-pi anchor wrap can be undone.
# ``None`` until the first sweep is accepted; reset by building a new
# processor (i.e. on reconfigure).
def __init__(self, config: RphRadarModel) -> None:
validate_rph_radar_model(config)
self._calibration = replace(config.phase_calibration)
self._band = replace(config.band)
self._hz_per_rad = ((self._calibration.freq1_hz - self._calibration.freq0_hz)
/ (self._calibration.phase1_rad - self._calibration.phase0_rad))
self._grid_hz = np.linspace(self._band.start_hz, self._band.stop_hz, self._band.points)
self.grid_hz = self._grid_hz.astype(np.float32)
self.grid_hz.setflags(write=False)
self._previous_anchor_rad: float | None = None
@property
def params(self) -> KamilAdcProcessingParams:
return self._params
@property
def grid_hz(self) -> np.ndarray:
"""The fixed output frequency axis (float32), identical for every sweep."""
return self._grid_hz.astype(np.float32)
def reference_frequency_axis(self, reference: np.ndarray) -> np.ndarray:
"""Map a reference signal's absolute unwrapped phase to frequency (Hz).
@@ -161,7 +96,7 @@ class KamilAdcSweepProcessor:
def _phase_to_frequency(self, phase: np.ndarray) -> np.ndarray:
"""Apply the affine ``phase -> frequency`` calibration law."""
return self._params.freq0_hz + (phase - self._params.phase0_rad) * self._params.hz_per_rad
return self._calibration.freq0_hz + (phase - self._calibration.phase0_rad) * self._hz_per_rad
def _align_phase_branch(self, phase: np.ndarray) -> tuple[np.ndarray, float]:
"""Undo a stray +/-pi anchor wrap by unwrapping the anchor across sweeps.
@@ -182,7 +117,7 @@ class KamilAdcSweepProcessor:
reference_anchor = (
self._previous_anchor_rad
if self._previous_anchor_rad is not None
else self._params.phase0_rad
else self._calibration.phase0_rad
)
turns = round((reference_anchor - anchor) / _PHASE_BRANCH_PERIOD_RAD)
if turns:
@@ -213,7 +148,7 @@ class KamilAdcSweepProcessor:
with np.errstate(divide="ignore", invalid="ignore"):
s21 = main / reference_amplitude
usable = (
(reference_amplitude > self._params.reference_amplitude_floor)
(reference_amplitude > _REFERENCE_AMPLITUDE_FLOOR)
& np.isfinite(freqs)
& np.isfinite(s21.real)
& np.isfinite(s21.imag)
@@ -232,7 +167,7 @@ class KamilAdcSweepProcessor:
# Reject sweeps that do not span the whole band: interpolating past the
# measured edge would inject flat, non-physical points.
if freqs[0] > self._params.band_start_hz or freqs[-1] < self._params.band_stop_hz:
if freqs[0] > self._band.start_hz or freqs[-1] < self._band.stop_hz:
return None
# The sweep is accepted: commit its branch-aligned anchor so the next
@@ -0,0 +1,89 @@
"""Whole-sweep RPH1 packets from the collector's PTY (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.
"""
from dataclasses import dataclass
import struct
import numpy as np
from numpy.typing import NDArray
MAIN_MARKER = 0x000A
REFERENCE_MARKER = 0x00A8
_HEADER = struct.Struct("<4sIQQQQHHI")
_POINT = struct.Struct("<HHhh")
_MAX_PAYLOAD_BYTES = 65534 * 2 * _POINT.size
@dataclass(frozen=True, slots=True)
class RawSweep:
steps: NDArray[np.int32]
main: NDArray[np.complex64]
reference: NDArray[np.complex64]
source_id: int
cycle_id: int
capture_start_ns: int
capture_end_ns: int
combo: tuple[int, int] | None = None
dirty: bool = False
@property
def size(self) -> int:
return int(self.steps.size)
class RphRadarStreamParser:
"""Incremental packet parser; corrupt headers or point markers fail closed."""
def __init__(self) -> None:
self._buffer = bytearray()
self._aligned = False
def reset(self) -> None:
self._buffer.clear()
self._aligned = False
def feed(self, data: bytes) -> list[RawSweep]:
self._buffer.extend(data)
if not self._aligned:
index = self._buffer.find(b"RPH1")
if index < 0:
del self._buffer[:-3]
return []
del self._buffer[:index]
self._aligned = True
sweeps = []
while len(self._buffer) >= _HEADER.size:
magic, size, source_id, cycle_id, start, end, inp, out, flags = _HEADER.unpack_from(self._buffer)
if (magic != b"RPH1" or size > _MAX_PAYLOAD_BYTES or size % _POINT.size
or not source_id or not cycle_id or not 0 < start <= end
or flags & ~1 or (inp == 65535) != (out == 65535)):
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid sweep header")
packet_size = _HEADER.size + size
if len(self._buffer) < packet_size:
break
main: dict[int, complex] = {}
reference: dict[int, complex] = {}
for marker, step, real, imag in _POINT.iter_unpack(self._buffer[_HEADER.size:packet_size]):
if marker not in (MAIN_MARKER, REFERENCE_MARKER) or not 0 < step < 65535:
raise ValueError("RadioPhotonic radar (RPH) protocol violation: invalid point frame")
target = main if marker == MAIN_MARKER else reference
if step in target:
raise ValueError("RadioPhotonic radar (RPH) protocol violation: duplicate point")
target[step] = complex(real, imag)
del self._buffer[:packet_size]
shared = sorted(main.keys() & reference.keys())
if shared:
sweeps.append(RawSweep(
np.asarray(shared, dtype=np.int32),
np.asarray([main[step] for step in shared], dtype=np.complex64),
np.asarray([reference[step] for step in shared], dtype=np.complex64),
source_id, cycle_id, start, end,
None if inp == 65535 else (inp, out), bool(flags & 1),
))
return sweeps
@@ -1,14 +1,15 @@
"""Service that launches the Kamil ADC collector and serves processed sweeps.
"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed 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:`KamilAdcSweepProcessor`.
processing grid via :class:`RphRadarSweepProcessor`.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
import json
import logging
import os
from pathlib import Path
@@ -20,23 +21,21 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.kamil_adc.protocol import RawSweep
from python_app.hardware_full.kamil_adc.tty_reader import (
KamilAdcTtyReader,
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.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
logger = logging.getLogger(__name__)
# Project root, used to resolve relative collector paths (e.g.
# ``build/bin/kamil_adc_collector``) independent of the launching CWD.
# ``build/bin/rph_radar_collector``) independent of the launching CWD.
_REPO_ROOT = Path(__file__).resolve().parents[3]
# Default home of the proprietary L-Card runtime libraries the collector loads
# via dlopen. Prepended to LD_LIBRARY_PATH unless the config pins it explicitly.
@@ -48,41 +47,36 @@ _REJECT_LOG_EVERY = 50
# brief window to release the device cleanly before escalating to SIGKILL. Caps
# the configured stop_timeout_s so a stop can never hang.
_STOP_KILL_GRACE_S = 0.5
# Sweeps to skip after a Python-driven switch change before trusting a capture: one
# for the pre-switch sweep still in the mailbox, one for a possible transition
# straddler in flight. Calibration speed is not critical, so we err on safety.
_SWITCH_DRAIN_SWEEPS = 2
@dataclass(slots=True)
class KamilAdcService:
"""Launch the external Kamil ADC collector and serve its processed sweeps."""
class RphRadarService:
"""Launch the external RadioPhotonic radar (RPH) collector and serve its processed sweeps."""
config: RunConfigModel
# When set, the collector is launched with ``config:<path>`` so it drives the RF
# switches itself (switch-aware mode) from this run_config.json. ``None`` keeps
# the standalone collector that streams a single channel (calibration / mock).
switch_config_path: str | None = None
collector_driven: bool = False
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
_processor: KamilAdcSweepProcessor | None = field(init=False, default=None, repr=False)
_reader: RphRadarTtyReader | 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)
last_cycle_id: int = field(init=False, default=0)
_output_thread: threading.Thread | None = field(init=False, default=None, repr=False)
_stop_event: threading.Event | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@property
def command(self) -> list[str]:
"""External collector command, including the generated ``tty:`` argument.
In switch-aware mode (``switch_config_path`` set) the collector also gets
``config:<path>`` so it reads the switch/combo configuration and drives the
switches in lock-step with the sweeps.
"""
adc = self.config.radar.kamil_adc
"""Pass the current typed settings once; the collector never rereads the profile."""
adc = self.config.radar.rph_radar
cmd = [str(self._resolve_executable()), *adc.args]
if self.switch_config_path is not None:
cmd.append(f"config:{self.switch_config_path}")
if self.collector_driven:
switching = {"input": asdict(self.config.input_switch), "output": asdict(self.config.output_switch),
"combos": [[combo.input, combo.output] for combo in self.config.combos],
"settling_ms": self.config.runtime.settling_ms}
cmd.append("switching:" + json.dumps(switching, separators=(",", ":")))
cmd.append(f"tty:{adc.tty_path}")
return cmd
@@ -94,51 +88,57 @@ class KamilAdcService:
"""
if self._reader is not None:
return
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
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
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.rph_radar.tty_path)
try:
self._start_process()
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
reader = RphRadarTtyReader(self.config.radar.rph_radar.tty_path)
reader.open()
self._reader = reader
logger.info("Kamil ADC service opened")
logger.info("RadioPhotonic radar (RPH) service opened")
except Exception:
logger.exception("Kamil ADC service failed to open; cleaning up")
logger.exception("RadioPhotonic radar (RPH) service failed to open; cleaning up")
self.close()
raise
def close(self) -> None:
"""Stop the TTY reader and the external collector process."""
logger.debug("Closing Kamil ADC service")
logger.debug("Closing RadioPhotonic radar (RPH) service")
self._prepared_combo = None
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
if self._output_thread is not None:
self._output_thread.join(timeout=1.0)
self._output_thread = None
def configure(self, sweep: RadarSweepModel) -> None:
"""Build the sweep processor from the ``radar.kamil_adc`` calibration/band.
"""Build the sweep processor from the ``radar.rph_radar`` calibration/band.
The generic ``sweep`` argument is accepted for interface parity with the
other radar services but is not used: the Kamil ADC frequency axis comes
other radar services but is not used: the RadioPhotonic radar (RPH) frequency axis comes
from the reference-phase calibration, and the output grid from
``radar.kamil_adc.band`` — never from the nominal sweep bounds.
``radar.rph_radar.band`` — never from the nominal sweep bounds.
"""
self._processor = KamilAdcSweepProcessor(
KamilAdcProcessingParams.from_kamil_model(self.config.radar.kamil_adc)
)
adc = self.config.radar.rph_radar
self._processor = RphRadarSweepProcessor(adc)
self._rejected_count = 0
params = self._processor.params
phase, band = adc.phase_calibration, adc.band
logger.debug(
"Kamil ADC configured: band %.6g-%.6g Hz, %d points; calibration "
"RadioPhotonic radar (RPH) configured: band %.6g-%.6g Hz, %d points; calibration "
"(%.6g rad -> %.6g Hz, %.6g rad -> %.6g Hz)",
params.band_start_hz, params.band_stop_hz, params.band_points,
params.phase0_rad, params.freq0_hz, params.phase1_rad, params.freq1_hz,
band.start_hz, band.stop_hz, band.points,
phase.phase0_rad, phase.freq0_hz, phase.phase1_rad, phase.freq1_hz,
)
def read_device_limits(self) -> dict[str, float | int]:
"""Kamil ADC has no runtime-readable sweep-limit API."""
raise RuntimeError("Kamil ADC device limits are not available")
"""RadioPhotonic radar (RPH) has no runtime-readable sweep-limit API."""
raise RuntimeError("RadioPhotonic radar (RPH) device limits are not available")
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
"""Return the next sweep that covers the band, as S21 on the fixed grid.
@@ -153,65 +153,45 @@ class KamilAdcService:
single newest sweep is returned regardless of combination.
"""
if self._processor is None:
raise RuntimeError("Kamil ADC service is not configured")
raise RuntimeError("RadioPhotonic radar (RPH) service is not configured")
if self._reader is None:
raise RuntimeError("Kamil ADC service is not open")
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
process = self._process
if process is None or process.poll() is not None:
return_code = None if process is None else process.poll()
raise RuntimeError(f"Kamil ADC collector is not running (code={return_code})")
raise RuntimeError(f"RadioPhotonic radar (RPH) collector is not running (code={return_code})")
grid = self._processor.grid_hz
points = int(grid.size)
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
deadline = time.monotonic() + self.config.radar.rph_radar.sweep_timeout_s
while True:
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
"Timed out waiting for a Kamil ADC sweep covering the configured band"
"Timed out waiting for a RadioPhotonic radar (RPH) sweep covering the configured band"
)
if combo is None:
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
else:
raw = self._reader.read_sweep_for(combo, timeout_s=remaining_s, process=process)
raw = self._reader.read_sweep(timeout_s=remaining_s, combo=combo, process=process)
s21 = self._processor.process(raw.main, raw.reference)
if s21 is not None:
self.last_cycle_id = raw.cycle_id
return SweepResult(
x=grid.copy(),
x=grid,
traces={
"s21": s21,
},
capture_start_ns=raw.capture_start_ns,
capture_end_ns=raw.capture_end_ns,
)
self._log_rejected_sweep(raw)
def prepare_combo(self, combo: ComboKey) -> None:
"""Discard buffered sweeps that predate or straddle the physical switch."""
self.drain_after_switch()
def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None:
"""Discard sweeps captured before / across a just-applied switch change.
The collector free-runs, so right after the RF switches move the reader
still holds a sweep captured in the *previous* combination, and a sweep that
straddles the transition may still be in flight. Without this, the next
:meth:`acquire` would return that stale data and the capture would be
attributed to the wrong combination (an off-by-one across the sequence).
Block until ``sweeps`` freshly-published sweeps have gone by, so the next
:meth:`acquire` returns a sweep captured entirely in the new switch state.
Used by the Python-driven calibration capture, where the collector does not
tag sweeps; the switch-aware collector path handles this with combo tags
instead. No-op when the service is not open.
"""
if self._reader is None:
"""Start a fresh ADC session after switching; retain phase branch tracking."""
if self._prepared_combo == combo:
return
target = self._reader.published_count + max(1, int(sweeps))
deadline = time.monotonic() + self.config.radar.kamil_adc.sweep_timeout_s
while self._reader.published_count < target:
if time.monotonic() > deadline:
raise TimeoutError("Timed out draining Kamil ADC sweeps after a switch change")
raise_if_process_exited(self._process)
time.sleep(0.005)
self.close()
if self.config.runtime.settling_ms < 1:
time.sleep(.001) # The caller already waited any configured settling.
self.open(stop_event=self._stop_event)
self._prepared_combo = combo
def read_raw_sweep(self) -> RawSweep:
"""Return the next raw (main, reference) sweep without any processing.
@@ -221,9 +201,9 @@ class KamilAdcService:
unprocessed reference samples. Raises if the service is not open.
"""
if self._reader is None:
raise RuntimeError("Kamil ADC service is not open")
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
return self._reader.read_sweep(
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
timeout_s=self.config.radar.rph_radar.sweep_timeout_s,
process=self._process,
)
@@ -234,34 +214,45 @@ class KamilAdcService:
def _start_process(self) -> None:
if self._process is not None and self._process.poll() is None:
return
logger.info("Starting Kamil ADC collector: %s", " ".join(self.command))
logger.info("Starting RadioPhotonic radar (RPH) collector: %s", " ".join(self.command))
self._process = subprocess.Popen(
self.command,
cwd=str(self._resolve_project_dir()),
env=self._build_env(),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
self._output_thread = threading.Thread(target=self._log_output, args=(self._process,),
name="rph-radar-log", daemon=True)
self._output_thread.start()
@staticmethod
def _log_output(process: subprocess.Popen[bytes]) -> None:
if process.stdout is not None:
with process.stdout:
for line in process.stdout:
logger.info("Collector: %s", line.decode("utf-8", errors="replace").rstrip())
def _stop_process(self) -> None:
process = self._process
self._process = None
if process is None or process.poll() is not None:
self._process = None
return
logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid)
logger.info("Stopping RadioPhotonic radar (RPH) collector (pid=%d)", process.pid)
# The collector's graceful X502 teardown can block, so give it only a brief
# window to release the device cleanly, then SIGKILL the whole group hard.
grace_s = min(self.config.radar.kamil_adc.stop_timeout_s, _STOP_KILL_GRACE_S)
grace_s = min(self.config.radar.rph_radar.stop_timeout_s, _STOP_KILL_GRACE_S)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=grace_s)
self._process = None
return
except subprocess.TimeoutExpired:
pass
logger.warning("Kamil ADC collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
logger.warning("RadioPhotonic radar (RPH) collector (pid=%d) did not stop in %.1fs; sending SIGKILL", process.pid, grace_s)
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
# close() must never raise: a collector wedged in uninterruptible I/O
@@ -269,6 +260,9 @@ class KamilAdcService:
# window even after SIGKILL. Best-effort wait; the OS reaps it eventually.
with suppress(subprocess.TimeoutExpired):
process.wait(timeout=1.0)
# Retain ownership in D-state so retries cannot launch a second collector.
if process.poll() is not None:
self._process = None
def _wait_for_tty(
self,
@@ -276,22 +270,22 @@ class KamilAdcService:
*,
stop_event: threading.Event | None = None,
) -> None:
adc = self.config.radar.kamil_adc
adc = self.config.radar.rph_radar
deadline = time.monotonic() + adc.startup_timeout_s
while time.monotonic() < deadline:
if stop_event is not None and stop_event.is_set():
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
raise_if_process_exited(self._process)
identity = _tty_identity(adc.tty_path)
if identity is not None and identity != previous_identity:
return
if stop_event is not None:
if stop_event.wait(0.05):
raise RuntimeError("Kamil ADC startup aborted before TTY became available")
raise RuntimeError("RadioPhotonic radar (RPH) startup aborted before TTY became available")
else:
time.sleep(0.05)
raise TimeoutError(
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
f"Timed out waiting for RadioPhotonic radar (RPH) TTY `{adc.tty_path}` to be created by the collector"
)
# ------------------------------------------------------------------
@@ -299,10 +293,10 @@ class KamilAdcService:
# ------------------------------------------------------------------
def _resolve_executable(self) -> Path:
return self._resolve_path(self.config.radar.kamil_adc.executable_path)
return self._resolve_path(self.config.radar.rph_radar.executable_path)
def _resolve_project_dir(self) -> Path:
project_dir = self.config.radar.kamil_adc.project_dir
project_dir = self.config.radar.rph_radar.project_dir
return self._resolve_path(project_dir) if project_dir else _REPO_ROOT
@staticmethod
@@ -318,7 +312,7 @@ class KamilAdcService:
``LD_LIBRARY_PATH`` itself, the default L-Card library directory is
prepended so the collector's dlopen of libx502api/libe502api succeeds.
"""
adc = self.config.radar.kamil_adc
adc = self.config.radar.rph_radar
env = os.environ.copy()
for key, value in adc.env.items():
env[key] = os.path.expandvars(os.path.expanduser(value))
@@ -333,33 +327,29 @@ class KamilAdcService:
# ------------------------------------------------------------------
def _validate_config(self) -> None:
if not self.config.is_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
if not self.config.is_rph_radar:
raise RuntimeError("RphRadarService requires radar.model='rph_radar'")
if self.config.radar.driver_mode != "native":
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
adc = self.config.radar.kamil_adc
adc = self.config.radar.rph_radar
validate_rph_radar_model(adc)
if not adc.executable_path:
raise ValueError("radar.kamil_adc.executable_path is required")
raise ValueError("radar.rph_radar.executable_path is required")
if not adc.tty_path:
raise ValueError("radar.kamil_adc.tty_path is required")
raise ValueError("radar.rph_radar.tty_path is required")
if any(arg.startswith("tty:") for arg in adc.args):
raise ValueError("radar.kamil_adc.args must not contain tty:<path>; use tty_path instead")
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
if getattr(adc, name) <= 0.0:
raise ValueError(f"radar.kamil_adc.{name} must be > 0")
raise ValueError("radar.rph_radar.args must not contain tty:<path>; use tty_path instead")
project_dir = self._resolve_project_dir()
if not project_dir.is_dir():
raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}")
raise RuntimeError(f"radar.rph_radar.project_dir is not a directory: {project_dir}")
executable_path = self._resolve_executable()
if not executable_path.is_file():
raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}")
raise RuntimeError(f"radar.rph_radar.executable_path is not a file: {executable_path}")
if not os.access(executable_path, os.X_OK):
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
raise RuntimeError(f"radar.rph_radar.executable_path is not executable: {executable_path}")
# Surface a malformed calibration/band at open time, not mid-acquisition.
KamilAdcProcessingParams.from_kamil_model(adc)
def _log_rejected_sweep(self, raw) -> None:
"""Log a band-coverage rejection (throttled) with the measured span."""
@@ -367,16 +357,16 @@ class KamilAdcService:
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
return
assert self._processor is not None
params = self._processor.params
band = self.config.radar.rph_radar.band
try:
freqs = self._processor.reference_frequency_axis(raw.reference)
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
except Exception: # noqa: BLE001 — diagnostics must never raise
covered = "<unavailable>"
logger.warning(
"Kamil ADC sweep rejected (count=%d): covered %s Hz does not span band "
"RadioPhotonic radar (RPH) sweep rejected (count=%d): covered %s Hz does not span band "
"[%.6g, %.6g] Hz (usable points=%d). Check phase_calibration/band.",
self._rejected_count, covered, params.band_start_hz, params.band_stop_hz, raw.size,
self._rejected_count, covered, band.start_hz, band.stop_hz, raw.size,
)
@@ -1,8 +1,8 @@
"""Background TTY reader publishing the latest completed Kamil ADC sweep.
"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep.
The collector emits sweeps continuously, faster than callers invoke
:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the
TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, and stores the
: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
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
@@ -21,7 +21,7 @@ import subprocess
import threading
import time
from python_app.hardware_full.kamil_adc.protocol import KamilAdcStreamParser, RawSweep
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser, RawSweep
logger = logging.getLogger(__name__)
@@ -39,11 +39,11 @@ def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
return
return_code = process.poll()
if return_code is not None:
raise RuntimeError(f"Kamil ADC collector exited with code {return_code}")
raise RuntimeError(f"RadioPhotonic radar (RPH) collector exited with code {return_code}")
@dataclass(slots=True)
class KamilAdcTtyReader:
class RphRadarTtyReader:
"""Daemon-thread TTY reader publishing the latest completed sweep."""
tty_path: str
@@ -54,7 +54,7 @@ class KamilAdcTtyReader:
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
# Latest clean sweep per switch combination, for the switch-aware collector.
# read_sweep() ignores this and serves the single newest sweep (calibration /
# non-switch mode); read_sweep_for() serves a specific combination.
# non-switch mode); an optional combo selects its own slot.
_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)
@@ -71,7 +71,7 @@ class KamilAdcTtyReader:
self._published_count = 0
self._thread = threading.Thread(
target=self._reader_loop,
name=f"kamil-adc-tty-reader[{self.tty_path}]",
name=f"rph-radar-tty-reader[{self.tty_path}]",
daemon=True,
)
try:
@@ -81,18 +81,18 @@ class KamilAdcTtyReader:
fd, self._fd = self._fd, None
os.close(fd)
raise
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
logger.info("RadioPhotonic radar (RPH) TTY reader started on %s", self.tty_path)
def close(self) -> None:
"""Stop the reader thread and close the TTY descriptor."""
logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path)
logger.debug("Stopping RadioPhotonic radar (RPH) TTY reader on %s", self.tty_path)
self._stop_event.set()
with self._mailbox_cv:
self._mailbox_cv.notify_all()
if self._thread is not None:
self._thread.join(timeout=1.0)
if self._thread.is_alive():
logger.warning("Kamil ADC reader thread did not stop within 1.0s")
logger.warning("RadioPhotonic radar (RPH) reader thread did not stop within 1.0s")
self._thread = None
if self._fd is not None:
try:
@@ -113,6 +113,7 @@ class KamilAdcTtyReader:
self,
*,
timeout_s: float,
combo: tuple[int, int] | None = None,
process: subprocess.Popen[bytes] | None = None,
) -> RawSweep:
"""Wait for and return the next published sweep.
@@ -122,52 +123,19 @@ class KamilAdcTtyReader:
error caught by the reader thread.
"""
if self._thread is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is not open")
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
if self._stop_event.is_set():
raise RuntimeError("Kamil ADC TTY reader is closed")
raise RuntimeError("RadioPhotonic radar (RPH) TTY 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.
if self._latest_sweep is not None:
sweep = self._latest_sweep
self._latest_sweep = None
return sweep
if self._reader_error is not None:
raise self._reader_error
raise_if_process_exited(process)
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep after {float(timeout_s):.3f}s"
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
def read_sweep_for(
self,
combo: tuple[int, int],
*,
timeout_s: float,
process: subprocess.Popen[bytes] | None = None,
) -> RawSweep:
"""Wait for and return the latest clean sweep for ``combo``.
Used in switch-aware mode, where the collector drives the switches and tags
each sweep with its combination. Only clean sweeps are delivered (the reader
thread drops the dirty ones); the slot is consumed on read so each caller
gets a fresh capture. Raises like :meth:`read_sweep`.
"""
if self._thread is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
deadline = time.monotonic() + float(timeout_s)
with self._mailbox_cv:
while True:
if self._stop_event.is_set():
raise RuntimeError("Kamil ADC TTY reader is closed")
sweep = self._combo_slots.pop(combo, None)
sweep = self._latest_sweep if combo is None else self._combo_slots.pop(combo, None)
if sweep is not None:
if combo is None:
self._latest_sweep = None
return sweep
if self._reader_error is not None:
raise self._reader_error
@@ -175,18 +143,13 @@ class KamilAdcTtyReader:
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep for combo {combo} "
f"after {float(timeout_s):.3f}s"
f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s"
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
def _reader_loop(self) -> None:
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
parser = KamilAdcStreamParser()
parser = RphRadarStreamParser()
try:
while not self._stop_event.is_set():
chunk = self._read_available()
@@ -195,7 +158,7 @@ class KamilAdcTtyReader:
for sweep in parser.feed(chunk):
self._publish_sweep(sweep)
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
logger.exception("Kamil ADC reader thread failed on %s", self.tty_path)
logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.tty_path)
self._publish_error(exc)
def _read_available(self) -> bytes:
@@ -220,9 +183,9 @@ class KamilAdcTtyReader:
except OSError as exc:
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
return b""
raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) TTY `{self.tty_path}`: {exc}") from exc
if not chunk:
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
raise RuntimeError(f"RadioPhotonic radar (RPH) TTY `{self.tty_path}` closed while reading")
return chunk
def _publish_sweep(self, sweep: RawSweep) -> None:
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Protocol
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.rph_radar.service import RphRadarService
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.models.dataset_model import ComboKey
@@ -55,9 +55,9 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
return PointTargetMockService(config)
return LibreVnaService(serial=config.radar.serial or None)
if model == RunConfigModel.KAMIL_ADC_MODEL:
if model == RunConfigModel.RPH_RADAR_MODEL:
if config.radar.driver_mode != "native":
raise RuntimeError("Kamil ADC requires radar.driver_mode='native'")
return KamilAdcService(config)
raise RuntimeError("RadioPhotonic radar (RPH) requires radar.driver_mode='native'")
return RphRadarService(config)
raise RuntimeError(f"Unsupported single-radar model: {model}")
+38 -218
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import asdict
import logging
from typing import Any
@@ -105,8 +106,10 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
run_payload.get("locator_server", run_payload.get("locator")),
"run.locator_server",
)
if radar_payload.get("model") == "kamil_adc":
raise ValueError("radar.model kamil_adc was renamed: use rph_radar and move radar.kamil_adc to radar.rph_radar")
multi_device_payload = _as_dict(radar_payload.get("multi_device"), "radar.multi_device")
kamil_adc_payload = _as_dict(radar_payload.get("kamil_adc"), "radar.kamil_adc")
rph_radar_payload = _as_dict(radar_payload.get("rph_radar"), "radar.rph_radar")
laser_control_payload = _as_dict(radar_payload.get("laser_control"), "radar.laser_control")
model.radar.model = _read_str(radar_payload, "model", model.radar.model)
@@ -162,36 +165,36 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
"stimulus_switch_positions",
model.radar.multi_device.stimulus_switch_positions,
)
model.radar.kamil_adc.project_dir = _read_str(
kamil_adc_payload, "project_dir", model.radar.kamil_adc.project_dir
model.radar.rph_radar.project_dir = _read_str(
rph_radar_payload, "project_dir", model.radar.rph_radar.project_dir
)
model.radar.kamil_adc.executable_path = _read_str(
kamil_adc_payload, "executable_path", model.radar.kamil_adc.executable_path
model.radar.rph_radar.executable_path = _read_str(
rph_radar_payload, "executable_path", model.radar.rph_radar.executable_path
)
model.radar.kamil_adc.tty_path = _read_str(
kamil_adc_payload, "tty_path", model.radar.kamil_adc.tty_path
model.radar.rph_radar.tty_path = _read_str(
rph_radar_payload, "tty_path", model.radar.rph_radar.tty_path
)
model.radar.kamil_adc.args = _load_string_list(kamil_adc_payload, "args", "radar.kamil_adc")
model.radar.kamil_adc.env = _load_string_dict(kamil_adc_payload, "env", "radar.kamil_adc")
model.radar.kamil_adc.startup_timeout_s = _read_float(
kamil_adc_payload, "startup_timeout_s", model.radar.kamil_adc.startup_timeout_s
model.radar.rph_radar.args = _load_string_list(rph_radar_payload, "args", "radar.rph_radar")
model.radar.rph_radar.env = _load_string_dict(rph_radar_payload, "env", "radar.rph_radar")
model.radar.rph_radar.startup_timeout_s = _read_float(
rph_radar_payload, "startup_timeout_s", model.radar.rph_radar.startup_timeout_s
)
model.radar.kamil_adc.sweep_timeout_s = _read_float(
kamil_adc_payload, "sweep_timeout_s", model.radar.kamil_adc.sweep_timeout_s
model.radar.rph_radar.sweep_timeout_s = _read_float(
rph_radar_payload, "sweep_timeout_s", model.radar.rph_radar.sweep_timeout_s
)
model.radar.kamil_adc.stop_timeout_s = _read_float(
kamil_adc_payload, "stop_timeout_s", model.radar.kamil_adc.stop_timeout_s
model.radar.rph_radar.stop_timeout_s = _read_float(
rph_radar_payload, "stop_timeout_s", model.radar.rph_radar.stop_timeout_s
)
phase_calibration_payload = _as_dict(
kamil_adc_payload.get("phase_calibration"), "radar.kamil_adc.phase_calibration"
rph_radar_payload.get("phase_calibration"), "radar.rph_radar.phase_calibration"
)
calibration = model.radar.kamil_adc.phase_calibration
calibration = model.radar.rph_radar.phase_calibration
calibration.phase0_rad = _read_float(phase_calibration_payload, "phase0_rad", calibration.phase0_rad)
calibration.freq0_hz = _read_float(phase_calibration_payload, "freq0_hz", calibration.freq0_hz)
calibration.phase1_rad = _read_float(phase_calibration_payload, "phase1_rad", calibration.phase1_rad)
calibration.freq1_hz = _read_float(phase_calibration_payload, "freq1_hz", calibration.freq1_hz)
band_payload = _as_dict(kamil_adc_payload.get("band"), "radar.kamil_adc.band")
band = model.radar.kamil_adc.band
band_payload = _as_dict(rph_radar_payload.get("band"), "radar.rph_radar.band")
band = model.radar.rph_radar.band
band.start_hz = _read_float(band_payload, "start_hz", band.start_hz)
band.stop_hz = _read_float(band_payload, "stop_hz", band.stop_hz)
band.points = _read_int(band_payload, "points", band.points)
@@ -274,11 +277,6 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
model.radar.laser_control.variation.delay_time = _read_int(
laser_variation_payload, "delay_time", model.radar.laser_control.variation.delay_time
)
model.radar.laser_control.variation.temp_tolerance_c = _read_float(
laser_variation_payload,
"temp_tolerance_c",
model.radar.laser_control.variation.temp_tolerance_c,
)
load_switch_payload(port1_payload, model.output_switch)
load_switch_payload(port2_payload, model.input_switch)
@@ -410,200 +408,22 @@ def run_config_from_dict(payload: object) -> RunConfigModel:
def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"""Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure."""
"""Encode the model, spelling only fields whose wire names differ."""
model.ensure_combos()
sweep_payload = {
"start_hz": model.radar.sweep.start_hz,
"stop_hz": model.radar.sweep.stop_hz,
"if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz,
"stimulus_power_dbm": model.radar.sweep.power_dbm,
"dwell_us": model.radar.sweep.dwell_us,
}
if not model.is_kamil_adc:
sweep_payload["points"] = model.radar.sweep.points
radar = asdict(model.radar)
sweep = radar["sweep"]
sweep["stimulus_power_dbm"] = sweep.pop("power_dbm")
if model.is_rph_radar:
sweep.pop("points")
preprocess = asdict(model.preprocess)
preprocess["notch"]["bands_hz"] = [list(band) for band in model.preprocess.notch.bands_hz]
return {
"radar": {
"model": model.radar.model,
"serial": model.radar.serial,
"driver_mode": model.radar.driver_mode,
"multi_device": {
"slave_serials": list(model.radar.multi_device.slave_serials),
"force_external_reference": model.radar.multi_device.force_external_reference,
"recovery_attempts": model.radar.multi_device.recovery_attempts,
"output_switch_positions": model.radar.multi_device.output_switch_positions,
"input_switch_positions": model.radar.multi_device.input_switch_positions,
"stimulus_switch_positions": model.radar.multi_device.stimulus_switch_positions
},
"kamil_adc": {
"project_dir": model.radar.kamil_adc.project_dir,
"executable_path": model.radar.kamil_adc.executable_path,
"tty_path": model.radar.kamil_adc.tty_path,
"args": list(model.radar.kamil_adc.args),
"env": dict(model.radar.kamil_adc.env),
"startup_timeout_s": model.radar.kamil_adc.startup_timeout_s,
"sweep_timeout_s": model.radar.kamil_adc.sweep_timeout_s,
"stop_timeout_s": model.radar.kamil_adc.stop_timeout_s,
"phase_calibration": {
"phase0_rad": model.radar.kamil_adc.phase_calibration.phase0_rad,
"freq0_hz": model.radar.kamil_adc.phase_calibration.freq0_hz,
"phase1_rad": model.radar.kamil_adc.phase_calibration.phase1_rad,
"freq1_hz": model.radar.kamil_adc.phase_calibration.freq1_hz,
},
"band": {
"start_hz": model.radar.kamil_adc.band.start_hz,
"stop_hz": model.radar.kamil_adc.band.stop_hz,
"points": model.radar.kamil_adc.band.points,
},
},
"laser_control": {
"enabled": model.radar.laser_control.enabled,
"port": model.radar.laser_control.port,
"mode": model.radar.laser_control.mode,
"pi_coeff1_p": model.radar.laser_control.pi_coeff1_p,
"pi_coeff1_i": model.radar.laser_control.pi_coeff1_i,
"pi_coeff2_p": model.radar.laser_control.pi_coeff2_p,
"pi_coeff2_i": model.radar.laser_control.pi_coeff2_i,
"manual": {
"temp1": model.radar.laser_control.manual.temp1,
"temp2": model.radar.laser_control.manual.temp2,
"current1": model.radar.laser_control.manual.current1,
"current2": model.radar.laser_control.manual.current2,
},
"variation": {
"variation_type": model.radar.laser_control.variation.variation_type,
"static_temp1": model.radar.laser_control.variation.static_temp1,
"static_temp2": model.radar.laser_control.variation.static_temp2,
"static_current1": model.radar.laser_control.variation.static_current1,
"static_current2": model.radar.laser_control.variation.static_current2,
"min_value": model.radar.laser_control.variation.min_value,
"max_value": model.radar.laser_control.variation.max_value,
"step": model.radar.laser_control.variation.step,
"time_step": model.radar.laser_control.variation.time_step,
"delay_time": model.radar.laser_control.variation.delay_time,
"temp_tolerance_c": model.radar.laser_control.variation.temp_tolerance_c,
},
},
"sweep": sweep_payload,
},
"switches": {
"port1": {
"name": model.output_switch.name,
"driver_mode": model.output_switch.driver_mode,
"radar_port": model.output_switch.radar_port,
"positions": model.output_switch.positions,
"default_position": model.output_switch.default_position,
"gpio_chip": model.output_switch.gpio_chip,
"pin_a": model.output_switch.pin_a,
"pin_b": model.output_switch.pin_b,
},
"port2": {
"name": model.input_switch.name,
"driver_mode": model.input_switch.driver_mode,
"radar_port": model.input_switch.radar_port,
"positions": model.input_switch.positions,
"default_position": model.input_switch.default_position,
"gpio_chip": model.input_switch.gpio_chip,
"pin_a": model.input_switch.pin_a,
"pin_b": model.input_switch.pin_b,
},
},
"control_button": {
"enabled": model.control_button.enabled,
"gpio_chip": model.control_button.gpio_chip,
"pin": model.control_button.pin,
"active_low": model.control_button.active_low,
"bias": model.control_button.bias,
"debounce_ms": model.control_button.debounce_ms,
"action": model.control_button.action,
},
"logging": {
"level": model.logging.level,
},
"run": {
"settling_ms": model.runtime.settling_ms,
"idle_sleep_ms": model.runtime.idle_sleep_ms,
"continuous": model.runtime.continuous,
"processing_live_config_path": model.runtime.processing_live_config_path,
"locator_server": {
"device_id": model.runtime.locator_server.device_id,
"protocol_version": model.runtime.locator_server.protocol_version,
"host": model.runtime.locator_server.host,
"port": model.runtime.locator_server.port,
"max_payload_bytes": model.runtime.locator_server.max_payload_bytes,
"client_queue_size": model.runtime.locator_server.client_queue_size,
"logger_name": model.runtime.locator_server.logger_name,
},
"combos": [{"input": combo.input, "output": combo.output} for combo in model.combos],
},
"preprocess": {
"s21": {
"calibration": {
"set_name": model.preprocess.s21.calibration.set_name,
"bundle_path": model.preprocess.s21.calibration.bundle_path,
},
"reference": {
"set_name": model.preprocess.s21.reference.set_name,
"bundle_path": model.preprocess.s21.reference.bundle_path,
},
"object_calibration": {
"set_name": model.preprocess.s21.object_calibration.set_name,
"bundle_path": model.preprocess.s21.object_calibration.bundle_path,
},
},
"notch": {
"enabled": model.preprocess.notch.enabled,
"bands_hz": [[low_hz, high_hz] for low_hz, high_hz in model.preprocess.notch.bands_hz],
"taper_width_hz": model.preprocess.notch.taper_width_hz,
"taper_type": model.preprocess.notch.taper_type,
},
},
"gpr": {
"relative_permittivity": model.gpr.relative_permittivity,
"tx_geometry": [
{
"output_pos": entry.output_pos,
"x_m": entry.x_m,
"y_m": entry.y_m,
"z_m": entry.z_m,
}
for entry in model.gpr.tx_geometry
],
"rx_geometry": [
{
"input_pos": entry.input_pos,
"x_m": entry.x_m,
"y_m": entry.y_m,
"z_m": entry.z_m,
}
for entry in model.gpr.rx_geometry
],
},
"rings": {
"raw": {
"name": model.rings.raw.name,
"capacity": model.rings.raw.capacity,
"slot_size_bytes": model.rings.raw.slot_size_bytes,
},
"raw_tap": {
"name": model.rings.raw_tap.name,
"capacity": model.rings.raw_tap.capacity,
"slot_size_bytes": model.rings.raw_tap.slot_size_bytes,
},
"preprocessed": {
"name": model.rings.preprocessed.name,
"capacity": model.rings.preprocessed.capacity,
"slot_size_bytes": model.rings.preprocessed.slot_size_bytes,
},
"preprocessed_tap": {
"name": model.rings.preprocessed_tap.name,
"capacity": model.rings.preprocessed_tap.capacity,
"slot_size_bytes": model.rings.preprocessed_tap.slot_size_bytes,
},
"results": {
"name": model.rings.results.name,
"capacity": model.rings.results.capacity,
"slot_size_bytes": model.rings.results.slot_size_bytes,
},
},
"radar": radar,
"switches": {"port1": asdict(model.output_switch), "port2": asdict(model.input_switch)},
"control_button": asdict(model.control_button),
"logging": asdict(model.logging),
"run": {**asdict(model.runtime), "combos": [asdict(combo) for combo in model.combos]},
"preprocess": preprocess,
"gpr": asdict(model.gpr),
"rings": asdict(model.rings),
}
+33 -68
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
import hashlib
import json
import logging
@@ -45,7 +45,7 @@ class RadarMultiDeviceModel:
"""Multi-device LibreVNA topology settings."""
slave_serials: list[str] = field(default_factory=list)
force_external_reference: bool = True
force_external_reference: bool = False
recovery_attempts: int = 3
output_switch_positions: int = 1 # 1 = свитча нет
input_switch_positions: int = 1 # 1 = свитча нет
@@ -56,7 +56,7 @@ class RadarMultiDeviceModel:
@dataclass(slots=True)
class KamilAdcPhaseCalibrationModel:
class RphRadarPhaseCalibrationModel:
"""Affine law mapping the reference signal's unwrapped phase to frequency.
Two fixed anchor points ``(phase0_rad, freq0_hz)`` and ``(phase1_rad, freq1_hz)``
@@ -73,7 +73,7 @@ class KamilAdcPhaseCalibrationModel:
@dataclass(slots=True)
class KamilAdcBandModel:
class RphRadarBandModel:
"""Fixed frequency window every sweep is cropped to and resampled onto.
Each sweep is resampled onto ``linspace(start_hz, stop_hz, points)`` so all
@@ -88,8 +88,8 @@ class KamilAdcBandModel:
@dataclass(slots=True)
class KamilAdcModel:
"""External Kamil ADC acquisition process settings."""
class RphRadarModel:
"""External RadioPhotonic radar (RPH) acquisition process settings."""
project_dir: str = ""
executable_path: str = ""
@@ -99,10 +99,10 @@ class KamilAdcModel:
startup_timeout_s: float = 5.0
sweep_timeout_s: float = 5.0
stop_timeout_s: float = 2.0
phase_calibration: KamilAdcPhaseCalibrationModel = field(
default_factory=KamilAdcPhaseCalibrationModel
phase_calibration: RphRadarPhaseCalibrationModel = field(
default_factory=RphRadarPhaseCalibrationModel
)
band: KamilAdcBandModel = field(default_factory=KamilAdcBandModel)
band: RphRadarBandModel = field(default_factory=RphRadarBandModel)
@dataclass(slots=True)
@@ -129,14 +129,11 @@ class LaserVariationModeModel:
step: float = 0.1
time_step: int = 20
delay_time: int = 3
# Max allowed |measured - target| laser temperature before the temperature
# checker warns, °C. Applied independently to both lasers (temp1/temp2).
temp_tolerance_c: float = 0.03
@dataclass(slots=True)
class LaserControlModel:
"""Laser-control board settings applied before Kamil ADC acquisition."""
"""Laser-control board settings applied before RadioPhotonic radar (RPH) acquisition."""
enabled: bool = False
port: str = ""
@@ -158,7 +155,7 @@ class RadarModel:
driver_mode: str = "mock"
sweep: RadarSweepModel = field(default_factory=RadarSweepModel)
multi_device: RadarMultiDeviceModel = field(default_factory=RadarMultiDeviceModel)
kamil_adc: KamilAdcModel = field(default_factory=KamilAdcModel)
rph_radar: RphRadarModel = field(default_factory=RphRadarModel)
laser_control: LaserControlModel = field(default_factory=LaserControlModel)
@@ -337,7 +334,7 @@ class RunConfigModel:
LIBREVNA_MODEL = "librevna"
LIBREVNA_MULTI_MODEL = "librevna_multi"
KAMIL_ADC_MODEL = "kamil_adc"
RPH_RADAR_MODEL = "rph_radar"
MULTI_DEVICE_INPUT_POSITIONS = 4
MULTI_DEVICE_OUTPUT_POSITIONS = 2
# Stimulus ports the master itself owns. The virtual output axis is this times the
@@ -400,72 +397,40 @@ class RunConfigModel:
def build_runtime_combos(self) -> list[ComboModel]:
"""Build the combo matrix from the effective switch axis sizes."""
return self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
@property
def is_kamil_adc(self) -> bool:
"""Return whether this config targets the external Kamil ADC acquisition path."""
return self.radar.model == self.KAMIL_ADC_MODEL
def is_rph_radar(self) -> bool:
"""Return whether this config targets the external RadioPhotonic radar (RPH) acquisition path."""
return self.radar.model == self.RPH_RADAR_MODEL
def radar_key(self) -> str:
"""Stable hardware/sweep identity used to find saved preprocess sets."""
radar, sweep = self.radar, self.radar.sweep
if self.is_rph_radar:
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),
(mode, optical, laser.pi_coeff1_p, laser.pi_coeff1_i,
laser.pi_coeff2_p, laser.pi_coeff2_i) if laser.enabled 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"]
serials.extend(value.strip() or "no_serial" for value in self._radar_key_extra_parts())
if self.is_multi_device:
serials.extend(value.strip() or "no_serial" for value in radar.multi_device.slave_serials)
serial = "_".join(sanitize_path_component(value) for value in serials)
points = "adc" if self.is_kamil_adc else str(int(sweep.points))
points = str(int(sweep.points))
# Switch positions decide what each combo index means, so sets from another matrix never match.
matrix = (f"_out{self.matrix_output_switch_positions}_in{self.matrix_input_switch_positions}"
f"_stim{self.matrix_stimulus_switch_positions}") if self.is_multi_device else ""
return (
f"{radar.model}_{serial}"
f"_st{_format_float_for_key(sweep.start_hz)}_sp{_format_float_for_key(sweep.stop_hz)}"
f"_p{points}_if{_format_float_for_key(sweep.if_bandwidth_hz)}"
f"_pw{_format_float_for_key(sweep.power_dbm)}"
f"_pw{_format_float_for_key(sweep.power_dbm)}{matrix}"
)
def _radar_key_extra_parts(self) -> list[str]:
"""Return model-specific identity parts that affect captured data."""
if self.is_multi_device:
return list(self.radar.multi_device.slave_serials)
if self.is_kamil_adc:
payload = {
"kamil_adc": {
"project_dir": self.radar.kamil_adc.project_dir,
"executable_path": self.radar.kamil_adc.executable_path,
"tty_path": self.radar.kamil_adc.tty_path,
"args": list(self.radar.kamil_adc.args),
"env": dict(sorted(self.radar.kamil_adc.env.items())),
},
"laser_control": {
"enabled": self.radar.laser_control.enabled,
"port": self.radar.laser_control.port,
"mode": self.radar.laser_control.mode,
"pi_coeff1_p": self.radar.laser_control.pi_coeff1_p,
"pi_coeff1_i": self.radar.laser_control.pi_coeff1_i,
"pi_coeff2_p": self.radar.laser_control.pi_coeff2_p,
"pi_coeff2_i": self.radar.laser_control.pi_coeff2_i,
"manual": {
"temp1": self.radar.laser_control.manual.temp1,
"temp2": self.radar.laser_control.manual.temp2,
"current1": self.radar.laser_control.manual.current1,
"current2": self.radar.laser_control.manual.current2,
},
"variation": {
"variation_type": self.radar.laser_control.variation.variation_type,
"static_temp1": self.radar.laser_control.variation.static_temp1,
"static_temp2": self.radar.laser_control.variation.static_temp2,
"static_current1": self.radar.laser_control.variation.static_current1,
"static_current2": self.radar.laser_control.variation.static_current2,
"min_value": self.radar.laser_control.variation.min_value,
"max_value": self.radar.laser_control.variation.max_value,
"step": self.radar.laser_control.variation.step,
"time_step": self.radar.laser_control.variation.time_step,
"delay_time": self.radar.laser_control.variation.delay_time,
},
},
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:16]
return [f"kamil_{digest}"]
return []
def apply_device_model_constraints(self) -> None:
"""Apply only required wire-format constraints for the selected device model."""
if not self.is_multi_device:
+25 -3
View File
@@ -20,6 +20,7 @@ from python_app.models.run_config_schema import (
GprModel,
GprRxGeometryModel,
GprTxGeometryModel,
RphRadarModel,
RadarSweepModel,
RingEndpointModel,
RunConfigModel,
@@ -129,12 +130,15 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
if mode not in ("mock", "native"):
raise ValueError("radar.driver_mode must be mock or native")
if config.radar.model not in (
config.LIBREVNA_MODEL, config.LIBREVNA_MULTI_MODEL, config.KAMIL_ADC_MODEL,
config.LIBREVNA_MODEL, config.LIBREVNA_MULTI_MODEL, config.RPH_RADAR_MODEL,
):
raise ValueError(f"Unsupported radar model: {config.radar.model}")
if mode != "native" and config.is_kamil_adc:
if mode != "native" and config.is_rph_radar:
raise ValueError(f"{config.radar.model} requires native driver mode")
if config.is_rph_radar:
validate_rph_radar_model(config.radar.rph_radar)
for switch, physical_positions in (
(config.input_switch, config.matrix_input_switch_positions),
(config.output_switch, config.matrix_output_switch_positions),
@@ -155,7 +159,7 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
if not switch.gpio_chip or switch.pin_a < 0 or switch.pin_b < 0 or switch.pin_b == switch.pin_a:
raise ValueError(f"{context}: GPIO chip and distinct nonnegative control pins are required")
points = config.radar.kamil_adc.band.points if config.is_kamil_adc else config.radar.sweep.points
points = config.radar.rph_radar.band.points if config.is_rph_radar else config.radar.sweep.points
# RAW3/PRP3 header, frequency/S21 arrays and capture windows.
minimum = 44 + len(config.combos) * (28 + 12 * points)
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
@@ -166,6 +170,24 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
raise ValueError(f"rings.{name}.slot_size_bytes must be at least {minimum} for this sweep")
def validate_rph_radar_model(adc: RphRadarModel) -> None:
"""Reject phase/grid errors before stopping a running pipeline."""
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):
raise ValueError(f"radar.rph_radar.{name} must be finite")
if phase.phase0_rad == phase.phase1_rad or phase.freq0_hz == phase.freq1_hz:
raise ValueError("radar.rph_radar.phase_calibration requires distinct phases and frequencies")
if not 0 <= band.start_hz < band.stop_hz <= 3.4e38:
raise ValueError("radar.rph_radar.band requires 0 <= start_hz < stop_hz within float32")
if not isinstance(band.points, int) or not 2 <= band.points <= _UINT32_MAX:
raise ValueError("radar.rph_radar.band.points must be an integer in [2, uint32 max]")
for name in ("startup_timeout_s", "sweep_timeout_s", "stop_timeout_s"):
value = getattr(adc, name)
if not math.isfinite(value) or value <= 0:
raise ValueError(f"radar.rph_radar.{name} must be finite and positive")
def validate_sweep_model(sweep: RadarSweepModel) -> None:
"""Validate radar sweep bounds (point count and frequency span).
+2 -41
View File
@@ -24,10 +24,7 @@ logger = logging.getLogger(__name__)
_LOG_MAX_BYTES = 8 * 1024 * 1024
# Per-process force-kill deadline used on stop; each child gets its own window.
_STOP_GRACE_SECONDS = 2.0
# Cmdline marker for the Kamil ADC collector binary. The collector runs in its own
# session, so it can outlive a force-killed acquisition producer while still holding
# the L-Card E-502; we hunt it by name as a guaranteed device-release backstop.
_KAMIL_COLLECTOR_MARKER = b"kamil_adc_collector"
@dataclass(slots=True)
@@ -143,9 +140,6 @@ class ProcessSupervisor:
"""Start required pipeline binaries and wait until they are ready."""
if self.is_running():
raise RuntimeError("Acquisition processes are already running")
# Clean slate: an ADC collector orphaned by a prior crash/force-kill can
# still hold the E-502 and make a fresh acquisition fail to open.
self._kill_kamil_collectors()
logger.info(
"Starting pipeline from config %s (allow_clean_acquisition_exit=%s)",
@@ -197,7 +191,6 @@ class ProcessSupervisor:
def stop(self) -> None:
"""Stop acquisition-side processes, keep processor process intact."""
self._stop_processes(["acquisition", "data_preprocessor"])
self._kill_kamil_collectors()
def stop_acquisition(self) -> None:
"""Stop acquisition process only."""
@@ -210,7 +203,6 @@ class ProcessSupervisor:
def stop_all(self) -> None:
"""Stop all managed processes."""
self._stop_processes(["acquisition", "data_preprocessor", "data_processor"])
self._kill_kamil_collectors()
def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None:
"""Spawn one process unless same process is already alive.
@@ -339,34 +331,6 @@ class ProcessSupervisor:
except (ProcessLookupError, PermissionError):
pass
@staticmethod
def _kill_kamil_collectors() -> None:
"""SIGKILL any Kamil ADC collector found by name — a guaranteed device backstop.
The collector survives a force-killed producer (own session) and can hang
the E-502, so it is hunted by cmdline and its group killed hard, regardless
of who its parent is. Best-effort and never raises.
"""
own_pid = os.getpid()
try:
pids = [int(entry.name) for entry in Path("/proc").iterdir() if entry.name.isdigit()]
except OSError:
return
killed = 0
for pid in pids:
if pid <= 1 or pid == own_pid:
continue
try:
cmdline = (Path("/proc") / str(pid) / "cmdline").read_bytes()
except OSError:
continue
executable = cmdline.split(b"\x00", 1)[0].rsplit(b"/", 1)[-1]
if executable == _KAMIL_COLLECTOR_MARKER:
ProcessSupervisor._signal_group(pid, signal.SIGKILL)
killed += 1
if killed:
logger.warning("Force-killed %d orphaned Kamil ADC collector process(es)", killed)
@staticmethod
def _log_abnormal_stop_exit(process: ManagedProcess) -> None:
"""Log a process that self-exited abnormally around stop time.
@@ -542,9 +506,6 @@ class ProcessSupervisor:
atomic_write_text(self._pidfile_path, "")
except OSError:
pass
# The ADC collector is a grandchild (spawned by the producer), not in the
# pidfile, so reap it separately by name.
self._kill_kamil_collectors()
def _is_stale_pipeline_pid(self, pid: int) -> bool:
"""Return whether `pid` still runs one of our pipeline binaries/scripts.
@@ -555,7 +516,7 @@ class ProcessSupervisor:
native_names = {"data_processor", "data_preprocessor"}
producer_modules = {
"python_app.scripts.matrix_raw_producer",
"python_app.scripts.kamil_adc_raw_producer",
"python_app.scripts.rph_radar_raw_producer",
}
try:
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
@@ -18,7 +18,7 @@ def acquisition_command(config_path: Path) -> list[str]:
producers = {
"librevna": "matrix_raw_producer",
"librevna_multi": "matrix_raw_producer",
"kamil_adc": "kamil_adc_raw_producer",
"rph_radar": "rph_radar_raw_producer",
}
if model not in producers:
raise ValueError(f"Unsupported radar model: {model}")
-78
View File
@@ -1,78 +0,0 @@
"""Standalone laser temperature checker process.
Reads the target setpoints frozen at variation start and tails the JSONL readings
channel produced by the monitor. For every reading it validates both lasers and
prints a console warning whenever a measured temperature drifts from its target by
more than the tolerance (default 0.03 °C). Never touches the serial port, so it is
fully independent of the monitor and can be started/stopped at any time.
Example::
python -m python_app.scripts.laser_temp_checker
python -m python_app.scripts.laser_temp_checker --session s.json --readings r.jsonl
"""
from __future__ import annotations
import argparse
import logging
import signal
import threading
from pathlib import Path
from python_app.hardware_full.laser_control.monitoring import (
DEFAULT_READINGS_PATH,
DEFAULT_SESSION_PATH,
LaserTemperatureChecker,
LaserVariationSession,
ReadingReader,
)
logger = logging.getLogger("laser_temp_checker")
_POLL_INTERVAL_S = 0.2
def main() -> int:
parser = argparse.ArgumentParser(description="Validate laser temperature against setpoints")
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
help="Session snapshot with target setpoints + tolerance")
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
help="JSONL readings channel to tail")
parser.add_argument("--tolerance", type=float, default=None,
help="Override tolerance in °C (default: from session)")
parser.add_argument("--reminder-every", type=int, default=0,
help="Repeat a warning every N readings while off target (0=off)")
parser.add_argument("--from-start", action="store_true",
help="Validate the whole readings file, not just new lines")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
session = LaserVariationSession.load(args.session)
if args.tolerance is not None:
session.tolerance_c = args.tolerance
checker = LaserTemperatureChecker.from_session(session, reminder_every=args.reminder_every)
logger.info(
"Checking against T1=%.3f T2=%.3f °C, tolerance ±%.3f °C (%s)",
session.target_temp1, session.target_temp2, session.tolerance_c, session.variation_type,
)
reader = ReadingReader(args.readings, from_start=args.from_start)
stop_event = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_event.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
while not stop_event.is_set():
for reading in reader.poll():
checker.process(reading)
stop_event.wait(_POLL_INTERVAL_S)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-149
View File
@@ -1,149 +0,0 @@
"""Standalone laser temperature monitor process.
Owns the laser serial port, (optionally) starts a current-variation task, then
polls the board once per sweep and appends each reading to a JSONL channel that
the temperature checker tails. Runs until SIGINT/SIGTERM.
Examples::
# Start LD1 current variation from a run config, then monitor:
python -m python_app.scripts.laser_temp_monitor --config run_config.json --start
# Monitor a variation that is already running:
python -m python_app.scripts.laser_temp_monitor --config run_config.json
"""
from __future__ import annotations
import argparse
import logging
import signal
import threading
from datetime import datetime
from pathlib import Path
from python_app.hardware_full.laser_control.controller import (
DEVICE_MAIN_MESSAGE_ID,
LaserController,
)
from python_app.hardware_full.laser_control.exceptions import PortBusyError
from python_app.hardware_full.laser_control.models import VariationType
from python_app.hardware_full.laser_control.monitoring import (
DEFAULT_READINGS_PATH,
DEFAULT_SESSION_PATH,
LaserTemperatureMonitor,
LaserVariationSession,
ReadingWriter,
resolve_period_s,
)
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger("laser_temp_monitor")
def _start_variation(controller: LaserController, variation) -> None:
"""Send the CHANGE_CURRENT_LD1 task and freeze the session snapshot."""
controller.reset()
controller.set_manual_mode(
temp1=variation.static_temp1,
temp2=variation.static_temp2,
current1=variation.static_current1,
current2=variation.static_current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=VariationType[variation.variation_type],
params={
"static_temp1": variation.static_temp1,
"static_temp2": variation.static_temp2,
"static_current1": variation.static_current1,
"static_current2": variation.static_current2,
"min_value": variation.min_value,
"max_value": variation.max_value,
"step": variation.step,
"time_step": variation.time_step,
"delay_time": variation.delay_time,
},
)
def main() -> int:
parser = argparse.ArgumentParser(description="Poll laser temperature once per sweep")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
help="JSONL readings channel to append to")
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
help="Session snapshot path (written with --start)")
parser.add_argument("--strategy", default="computed",
help="'computed' (per sweep) or 'interval:<ms>'")
parser.add_argument("--start", action="store_true",
help="Send CHANGE_CURRENT_LD1 before monitoring")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
laser = config.radar.laser_control
variation = laser.variation
if variation.variation_type != "CHANGE_CURRENT_LD1":
logger.warning(
"Only CHANGE_CURRENT_LD1 is supported by firmware; got %s",
variation.variation_type,
)
period_s = resolve_period_s(
args.strategy,
min_value=variation.min_value,
max_value=variation.max_value,
step=variation.step,
time_step_us=variation.time_step,
delay_time_ms=variation.delay_time,
)
stop_event = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_event.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
controller = LaserController(
port=laser.port or None,
pi_coeff1_p=laser.pi_coeff1_p,
pi_coeff1_i=laser.pi_coeff1_i,
pi_coeff2_p=laser.pi_coeff2_p,
pi_coeff2_i=laser.pi_coeff2_i,
)
try:
controller.connect()
except PortBusyError as exc:
# Expected, benign conflict: the manual-control UI (or another monitor)
# already owns the port. Exit cleanly with guidance, not a traceback.
logger.error("%s", exc)
return 2
try:
if args.start:
_start_variation(controller, variation)
LaserVariationSession(
variation_type=variation.variation_type,
target_temp1=variation.static_temp1,
target_temp2=variation.static_temp2,
tolerance_c=variation.temp_tolerance_c,
started_at_iso=datetime.now().isoformat(timespec="seconds"),
).save(args.session)
logger.info("Started CHANGE_CURRENT_LD1 and wrote session %s", args.session)
with ReadingWriter(args.readings) as writer:
monitor = LaserTemperatureMonitor(
controller=controller, writer=writer, period_s=period_s
)
logger.info("Monitoring to %s (period=%.3fs)", args.readings, period_s)
monitor.run(stop_event)
finally:
controller.disconnect()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
"""Measure only RPH reference phase anchors using the current acquisition settings.
Start the laser with Apply Radar and stop acquisition before running this tool.
No laser commands are sent. --apply replaces only phase0_rad and phase1_rad;
frequency anchors, band, collector settings and JSON formatting are preserved.
"""
import argparse
import json
import logging
from pathlib import Path
import numpy as np
from python_app.file_io import atomic_write_text
from python_app.hardware_full.rph_radar.service import RphRadarService
from python_app.models.run_config_schema import RunConfigModel
def _value_span(text: str, path: tuple[str, ...]) -> tuple[int, int]:
"""Locate a unique JSON member without reserializing its neighbours."""
decoder = json.JSONDecoder()
def skip_space(index: int) -> int:
while text[index] in " \t\r\n":
index += 1
return index
start, end = 0, len(text)
for member in path:
start = skip_space(start)
if text[start] != "{":
raise ValueError(f"Expected JSON object before {member}")
cursor, found = start + 1, None
while True:
cursor = skip_space(cursor)
if text[cursor] == "}":
break
key, cursor = decoder.raw_decode(text, cursor)
cursor = skip_space(cursor) + 1 # colon; JSON already validated
cursor = skip_space(cursor)
_, next_cursor = decoder.raw_decode(text, cursor)
if key == member:
if found is not None:
raise ValueError(f"Duplicate JSON member: {member}")
found = (cursor, next_cursor)
cursor = skip_space(next_cursor)
if text[cursor] == "}":
break
cursor += 1 # comma
if found is None:
raise ValueError(f"Profile must contain explicit {'.'.join(path)}")
start, end = found
return start, end
def calibrate(config: RunConfigModel, sweeps: int, warmup: int) -> dict[str, float]:
"""Median endpoints after aligning each sweep to the preceding phase branch."""
if sweeps < 1 or warmup < 0:
raise ValueError("sweeps must be positive and warmup nonnegative")
if not config.is_rph_radar or config.radar.driver_mode != "native":
raise ValueError("Calibration requires a native rph_radar profile")
previous = config.radar.rph_radar.phase_calibration.phase0_rad
endpoints = []
service = RphRadarService(config)
try:
service.open()
for index in range(warmup + sweeps):
raw = service.read_raw_sweep()
if index < warmup:
continue
reference = np.asarray(raw.reference, dtype=np.complex128)
if (raw.dirty or reference.ndim != 1 or reference.size < 2
or not np.all(np.isfinite(reference)) or np.any(np.abs(reference) <= 1e-9)):
raise ValueError("Invalid reference sweep; phase calibration was not changed")
phase = np.unwrap(np.angle(reference))
phase += round((previous - float(phase[0])) / (2 * np.pi)) * (2 * np.pi)
if abs(float(phase[-1] - phase[0])) <= 1e-9:
raise ValueError("Degenerate reference phase span")
endpoints.append((phase[0], phase[-1]))
previous = float(phase[0])
finally:
service.close()
phase0, phase1 = np.median(endpoints, axis=0)
if abs(float(phase1 - phase0)) <= 1e-9:
raise ValueError("Degenerate median phase span")
return {"phase0_rad": float(phase0), "phase1_rad": float(phase1)}
def main() -> int:
parser = argparse.ArgumentParser(description="Calibrate RadioPhotonic radar (RPH) phase anchors")
parser.add_argument("--config", required=True, type=Path)
parser.add_argument("--sweeps", type=int, default=200)
parser.add_argument("--warmup", type=int, default=10)
parser.add_argument("--apply", action="store_true", help="Write only the two phase anchors")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
original = args.config.read_bytes()
text = original.decode("utf-8")
config = RunConfigModel.from_dict(json.loads(text))
fields = ("phase0_rad", "phase1_rad")
spans = {name: _value_span(text, ("radar", "rph_radar", "phase_calibration", name)) for name in fields}
result = calibrate(config, args.sweeps, args.warmup)
print(json.dumps(result, allow_nan=False))
if args.apply:
for name, (start, end) in sorted(spans.items(), key=lambda item: item[1][0], reverse=True):
text = text[:start] + json.dumps(result[name], allow_nan=False) + text[end:]
if args.config.read_bytes() != original:
raise RuntimeError("Profile changed during capture; refusing to overwrite it")
atomic_write_text(args.config, text, durable=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,4 +1,4 @@
"""Raw acquisition producer for the external Kamil ADC radar mode."""
"""Raw acquisition producer for RadioPhotonic radar (RPH)."""
from __future__ import annotations
@@ -9,7 +9,7 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.rph_radar.service import RphRadarService
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_schema import RunConfigModel
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
def _open_radar_with_retry(
config: RunConfigModel,
radar: KamilAdcService,
radar: RphRadarService,
input_switch: SwitchService | None,
output_switch: SwitchService | None,
stop_requested: threading.Event,
@@ -28,7 +28,7 @@ def _open_radar_with_retry(
"""Reopen the ADC and Python-owned switches, cleaning partial opens before retry."""
close_devices(input_switch, output_switch, radar)
def attempt() -> KamilAdcService:
def attempt() -> RphRadarService:
try:
radar.open(stop_event=stop_requested)
radar.configure(config.radar.sweep)
@@ -41,16 +41,16 @@ def _open_radar_with_retry(
raise
return radar
return retry_open(attempt, stop_requested, "Kamil ADC") is not None
return retry_open(attempt, stop_requested, "RadioPhotonic radar (RPH)") is not None
def main() -> int:
"""Run producer process until config or signal requests exit."""
config_path, config, stop_requested = load_producer_config("Publish Kamil ADC raw sweeps to SHM rings")
if not config.is_kamil_adc:
raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'")
config_path, config, stop_requested = load_producer_config("Publish RadioPhotonic radar (RPH) raw sweeps to SHM rings")
if not config.is_rph_radar:
raise RuntimeError("rph_radar_raw_producer requires radar.model='rph_radar'")
logger.info(
"Kamil ADC raw producer starting: config=%s, combos=%d, continuous=%s",
"RadioPhotonic radar (RPH) raw producer starting: config=%s, combos=%d, continuous=%s",
config_path, len(config.combos), config.runtime.continuous,
)
@@ -59,24 +59,21 @@ def main() -> int:
try:
publisher = RawPublisher(config.rings, cleanup)
# Switch-aware mode: with native switches the collector drives the RF switches
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
# — no sweep lost at a switch boundary. The producer then only reads tagged
# sweeps and must not touch the GPIO lines the collector owns. With mock switches
# (dev/tests) we keep the Python-driven path, which is fine where speed and the
# in-gap timing do not matter.
# 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.
collector_driven = (
config.input_switch.driver_mode == "native"
and config.output_switch.driver_mode == "native"
)
radar = KamilAdcService(
radar = RphRadarService(
config,
switch_config_path=str(config_path) if collector_driven else None,
collector_driven=collector_driven,
)
input_switch = None if collector_driven else SwitchService.from_model(config.input_switch)
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
logger.info(
"Kamil ADC switch control: %s",
"collector-driven (in-gap, lossless)" if collector_driven else "producer-driven",
"RadioPhotonic radar (RPH) switch control: %s",
"collector-driven" if collector_driven else "producer-driven",
)
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
@@ -85,8 +82,8 @@ def main() -> int:
collection_id = 1
while not stop_requested.is_set():
collection_start = time.monotonic()
capture_start_ns = time.monotonic_ns()
traces: list[TraceData] = []
cycle_id = None
try:
for combo in config.combos:
@@ -95,30 +92,30 @@ def main() -> int:
if collector_driven:
# The collector already switched and tagged the sweep; just
# read the clean capture for this combination.
sweep_start_ns = time.monotonic_ns()
sweep = radar.acquire(combo=(combo.input, combo.output))
if cycle_id is not None and cycle_id != radar.last_cycle_id:
break # A dropped or overwritten pair invalidates this cycle.
cycle_id = radar.last_cycle_id
else:
if input_switch is None or output_switch is None:
raise RuntimeError("Producer-driven switches are not initialized")
if not select_and_settle(output_switch, input_switch, combo.output, combo.input,
config.runtime.settling_ms, wait=stop_requested.wait):
break
# Stamped after switching and settling so the window covers
# the sweep alone, not the dead time before it.
sweep_start_ns = time.monotonic_ns()
radar.prepare_combo(ComboKey(combo.input, combo.output))
sweep = radar.acquire()
traces.append(
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
capture_start_ns=sweep_start_ns,
capture_end_ns=time.monotonic_ns(),
capture_start_ns=sweep.capture_start_ns,
capture_end_ns=sweep.capture_end_ns,
)
)
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
logger.warning(
"Kamil ADC acquisition failed; reconnecting and waiting for the device: %s",
"RadioPhotonic radar (RPH) acquisition failed; reconnecting and waiting for the device: %s",
exc,
exc_info=True,
)
@@ -132,8 +129,10 @@ def main() -> int:
if len(traces) != len(config.combos):
if stop_requested.is_set():
break
if collector_driven:
continue # Latest-wins transport skipped part of a source cycle.
logger.warning(
"Kamil ADC produced an incomplete collection (%d of %d combos); "
"RadioPhotonic radar (RPH) produced an incomplete collection (%d of %d combos); "
"reconnecting and waiting for the device",
len(traces),
len(config.combos),
@@ -143,11 +142,11 @@ def main() -> int:
continue
collection = SweepCollection(
collection_id=collection_id,
collection_id=cycle_id if cycle_id is not None else collection_id,
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
capture_start_ns=min(trace.capture_start_ns for trace in traces),
capture_end_ns=max(trace.capture_end_ns for trace in traces),
)
publisher.publish(collection)
@@ -155,13 +154,13 @@ def main() -> int:
break
collection_duration_s = time.monotonic() - collection_start
if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0:
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
logger.info("RadioPhotonic radar (RPH) collection %d acquired in %.3f s", collection_id, collection_duration_s)
collection_id += 1
finally:
close_devices(output_switch, input_switch, radar)
cleanup.close()
logger.info("Kamil ADC raw producer stopped")
logger.info("RadioPhotonic radar (RPH) raw producer stopped")
return 0
+1 -1
View File
@@ -18,7 +18,7 @@ def radar_config_filename_prefix(
dataset's name carries the configuration it was captured with. ADC-mode radars have
no fixed sweep, so the point count is reported as ``adc``.
"""
points_token = "adc" if model_name.strip().lower() == "kamil_adc" else f"{int(sweep_points)}pts"
points_token = "adc" if model_name.strip().lower() == "rph_radar" else f"{int(sweep_points)}pts"
tokens = (
model_name,
_format_frequency_span(sweep_start_hz, sweep_stop_hz),
+1 -1
View File
@@ -53,7 +53,7 @@ class ComboNavigationTest(unittest.TestCase):
self.w._plots.draw(result)
def test_next_button_draws_cached_combo_without_changing_acquisition(self) -> None:
for model in ("librevna", "librevna_multi", "kamil_adc"):
for model in ("librevna", "librevna_multi", "rph_radar"):
with self.subTest(model=model):
self.w._profile.state.config.radar.model = model
self._seed_bscan()
+1 -1
View File
@@ -63,4 +63,4 @@ printf 'updated environment' > "$(dirname -- "$0")/../state"
self.assertTrue((target / 'run_config_examples/device.json.new').exists())
self.assertEqual((target / 'python_app/runtime/gui_session_state.json').read_text(), 'session')
self.assertFalse((target / 'python_app/runtime/logs').exists())
self.assertIn('kamil_adc_collector', log.read_text().splitlines())
self.assertIn('rph_radar_collector', log.read_text().splitlines())
@@ -303,10 +303,10 @@ class ShutdownReentryTest(unittest.TestCase):
_mock(pipeline._status).assert_called_with("Status: error")
self.assertFalse(pipeline.can_resume(expected_stop_seq))
def test_unsupported_kamil_adc_mock_fails_before_starting_any_process(self) -> None:
def test_unsupported_rph_radar_mock_fails_before_starting_any_process(self) -> None:
pipeline = _pipeline(self)
config = RunConfigModel()
config.radar.model = "kamil_adc"
config.radar.model = "rph_radar"
config.apply_device_model_constraints()
_mock(pipeline._build_config).return_value = config
pipeline.stop_all = mock.Mock()
+6 -9
View File
@@ -16,7 +16,7 @@ from unittest.mock import Mock, patch
import numpy as np
from python_app.hardware_full.kamil_adc.tty_reader import KamilAdcTtyReader
from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader
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
@@ -30,7 +30,7 @@ from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatri
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
from python_app.models.dataset_model import SweepCollection
from python_app.orchestration.shm.decoder import RAW_MAGIC, decode_trace_collection
from python_app.scripts import kamil_adc_raw_producer, matrix_raw_producer, producer_common
from python_app.scripts import rph_radar_raw_producer, matrix_raw_producer, producer_common
from python_app.workflows.radar_config_variants import scan_radar_config_variants
@@ -252,7 +252,7 @@ class TtyLifecycleTest(unittest.TestCase):
with self.subTest(combination=combination):
master, slave = pty.openpty()
tty.setraw(slave)
reader = KamilAdcTtyReader(os.ttyname(slave))
reader = RphRadarTtyReader(os.ttyname(slave))
errors = []
started = threading.Event()
reader.open()
@@ -260,10 +260,7 @@ class TtyLifecycleTest(unittest.TestCase):
def wait():
started.set()
try:
if combination is None:
reader.read_sweep(timeout_s=20)
else:
reader.read_sweep_for(combination, timeout_s=20)
reader.read_sweep(timeout_s=20, combo=combination)
except Exception as exc:
errors.append(exc)
@@ -283,7 +280,7 @@ class TtyLifecycleTest(unittest.TestCase):
def test_failed_reader_thread_start_closes_descriptor(self) -> None:
read_fd, write_fd = os.pipe()
reader = KamilAdcTtyReader("/dev/test")
reader = RphRadarTtyReader("/dev/test")
try:
with patch("os.open", return_value=read_fd), patch("threading.Thread.start", side_effect=RuntimeError("thread failed")):
with self.assertRaises(RuntimeError):
@@ -298,7 +295,7 @@ class TtyLifecycleTest(unittest.TestCase):
class ProducerCleanupTest(unittest.TestCase):
def test_partial_ring_initialization_closes_first_writer(self) -> None:
for module, model in ((matrix_raw_producer, "librevna_multi"), (kamil_adc_raw_producer, "kamil_adc")):
for module, model in ((matrix_raw_producer, "librevna_multi"), (rph_radar_raw_producer, "rph_radar")):
with self.subTest(producer=module.__name__):
config = RunConfigModel()
config.radar.model = model
-191
View File
@@ -1,191 +0,0 @@
"""Tests for the Kamil ADC TTY wire-protocol parser."""
from __future__ import annotations
import struct
import unittest
import numpy as np
from python_app.hardware_full.kamil_adc.protocol import (
COMBO_MARKER,
MAIN_MARKER,
REFERENCE_MARKER,
KamilAdcStreamParser,
)
def _boundary() -> bytes:
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
def _main(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
def _reference(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
class KamilAdcStreamParserTest(unittest.TestCase):
def test_pairs_main_and_reference_by_step(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, -1) + _reference(1, 100, 5)
+ _main(2, 20, -2) + _reference(2, 200, 6)
+ _boundary()
)
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 1)
sweep = sweeps[0]
self.assertEqual(sweep.steps.tolist(), [1, 2])
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(20, -2)])
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
def test_keeps_only_steps_present_in_both_channels(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, 0) # main only -> dropped
+ _main(2, 20, 0) + _reference(2, 200, 0) # both -> kept
+ _reference(3, 300, 0) # reference only -> dropped
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [2])
self.assertEqual(sweep.main.tolist(), [complex(20, 0)])
self.assertEqual(sweep.reference.tolist(), [complex(200, 0)])
def test_orders_by_ascending_step_regardless_of_arrival(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(3, 3, 0) + _reference(3, 30, 0)
+ _main(1, 1, 0) + _reference(1, 10, 0)
+ _main(2, 2, 0) + _reference(2, 20, 0)
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [1, 2, 3])
self.assertEqual(sweep.main.real.tolist(), [1, 2, 3])
def test_discards_preroll_before_first_boundary(self) -> None:
parser = KamilAdcStreamParser()
# Garbage + a partial point before the first real boundary must be skipped.
stream = (
_main(7, 7, 7) # pre-roll point (no preceding boundary) -> ignored
+ _boundary()
+ _main(1, 11, 0) + _reference(1, 1, 0)
+ _boundary()
)
(sweep,) = parser.feed(stream)
self.assertEqual(sweep.steps.tolist(), [1])
def test_handles_chunk_splits_across_frames(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 10, 0) + _reference(1, 100, 0)
+ _main(2, 20, 0) + _reference(2, 200, 0)
+ _boundary()
)
sweeps: list = []
# Feed one byte at a time to exercise reassembly across feed() calls.
for byte in stream:
sweeps.extend(parser.feed(bytes([byte])))
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].steps.tolist(), [1, 2])
def test_multiple_sweeps_in_one_feed(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary()
+ _main(1, 1, 0) + _reference(1, 10, 0)
+ _boundary()
+ _main(1, 2, 0) + _reference(1, 20, 0)
+ _boundary()
)
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 2)
self.assertEqual(sweeps[0].main.real.tolist(), [1])
self.assertEqual(sweeps[1].main.real.tolist(), [2])
def test_empty_sweep_between_boundaries_is_skipped(self) -> None:
parser = KamilAdcStreamParser()
stream = _boundary() + _boundary() + _main(1, 5, 0) + _reference(1, 1, 0) + _boundary()
sweeps = parser.feed(stream)
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].steps.tolist(), [1])
def test_corrupt_marker_raises(self) -> None:
parser = KamilAdcStreamParser()
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
with self.assertRaisesRegex(ValueError, "protocol violation"):
parser.feed(_boundary() + _main(1, 1, 0) + corrupt + _boundary())
def test_partial_trailing_frame_is_buffered(self) -> None:
parser = KamilAdcStreamParser()
self.assertEqual(parser.feed(_boundary() + _main(1, 1, 0)[:5]), [])
# Supply the rest of the frame plus its reference and the closing boundary.
rest = _main(1, 1, 0)[5:]
(sweep,) = parser.feed(rest + _reference(1, 9, 0) + _boundary())
self.assertEqual(sweep.steps.tolist(), [1])
def test_reset_clears_state(self) -> None:
parser = KamilAdcStreamParser()
parser.feed(_boundary() + _main(1, 1, 0))
parser.reset()
# After reset we must re-align on a fresh boundary before collecting.
sweeps = parser.feed(_main(9, 9, 0) + _boundary() + _main(1, 2, 0) + _reference(1, 2, 0) + _boundary())
self.assertEqual(len(sweeps), 1)
self.assertEqual(sweeps[0].main.real.tolist(), [2])
def test_untagged_sweep_has_no_combo(self) -> None:
parser = KamilAdcStreamParser()
(sweep,) = parser.feed(_boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
self.assertIsNone(sweep.combo)
self.assertFalse(sweep.dirty)
def test_combo_tag_labels_following_sweep(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary() + _combo(1, 2)
+ _main(1, 10, 0) + _reference(1, 100, 0)
+ _boundary() + _combo(3, 0, dirty=1)
+ _main(1, 20, 0) + _reference(1, 200, 0)
+ _boundary()
)
first, second = parser.feed(stream)
self.assertEqual(first.combo, (1, 2))
self.assertFalse(first.dirty)
self.assertEqual(second.combo, (3, 0))
self.assertTrue(second.dirty)
def test_combo_not_carried_into_untagged_sweep(self) -> None:
parser = KamilAdcStreamParser()
stream = (
_boundary() + _combo(1, 1)
+ _main(1, 1, 0) + _reference(1, 1, 0)
+ _boundary() # next sweep has no combo frame
+ _main(2, 2, 0) + _reference(2, 2, 0)
+ _boundary()
)
first, second = parser.feed(stream)
self.assertEqual(first.combo, (1, 1))
self.assertIsNone(second.combo)
def test_dtypes(self) -> None:
parser = KamilAdcStreamParser()
(sweep,) = parser.feed(_boundary() + _main(1, 1, 2) + _reference(1, 3, 4) + _boundary())
self.assertEqual(sweep.main.dtype, np.complex64)
self.assertEqual(sweep.reference.dtype, np.complex64)
self.assertEqual(sweep.steps.dtype, np.int32)
if __name__ == "__main__":
unittest.main()
-312
View File
@@ -1,312 +0,0 @@
"""Tests for the Kamil ADC TTY reader, config round-trip, and producer wiring."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pty
import struct
import subprocess
import sys
import tempfile
import time
import tty
import unittest
from unittest import mock
from python_app.hardware_full.kamil_adc import KamilAdcService, KamilAdcTtyReader
from python_app.hardware_full.kamil_adc.protocol import (
COMBO_MARKER,
MAIN_MARKER,
REFERENCE_MARKER,
)
from python_app.models.run_config_schema import RunConfigModel
from python_app.orchestration.process_supervisor import ProcessSupervisor
def _boundary() -> bytes:
return struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
def _main(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", MAIN_MARKER, step, real, imag)
def _reference(step: int, real: int, imag: int) -> bytes:
return struct.pack("<HHhh", REFERENCE_MARKER, step, real, imag)
def _combo(input_pos: int, output_pos: int, dirty: int = 0) -> bytes:
return struct.pack("<HHhh", COMBO_MARKER, input_pos, output_pos, dirty)
class KamilAdcTtyReaderTest(unittest.TestCase):
"""End-to-end tests over a PTY exercising the background reader thread."""
def _open_pty_reader(self) -> tuple[int, int, KamilAdcTtyReader]:
master_fd, slave_fd = pty.openpty()
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
return master_fd, slave_fd, reader
@staticmethod
def _close(master_fd: int, slave_fd: int, reader: KamilAdcTtyReader) -> None:
try:
reader.close()
finally:
os.close(master_fd)
os.close(slave_fd)
def test_publishes_sweep_with_aligned_main_and_reference(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_boundary()
+ _main(1, 10, -1) + _reference(1, 100, 5)
+ _main(2, -20, 2) + _reference(2, 200, 6)
+ _boundary(),
)
sweep = reader.read_sweep(timeout_s=1.0)
self.assertEqual(sweep.steps.tolist(), [1, 2])
self.assertEqual(sweep.main.tolist(), [complex(10, -1), complex(-20, 2)])
self.assertEqual(sweep.reference.tolist(), [complex(100, 5), complex(200, 6)])
finally:
self._close(master_fd, slave_fd, reader)
def test_variable_length_sweeps_are_allowed(self) -> None:
"""Unlike the old format, sweep length may vary — no locking, just resample later."""
master_fd, slave_fd, reader = self._open_pty_reader()
try:
# One complete sweep per write, read between, so delivery is deterministic
# (the reader publishes only the latest, overwriting unread sweeps).
os.write(master_fd, _boundary() + _main(1, 1, 0) + _reference(1, 9, 0) + _boundary())
first = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.steps.tolist(), [1])
os.write(
master_fd,
_main(1, 2, 0) + _reference(1, 8, 0)
+ _main(2, 3, 0) + _reference(2, 7, 0)
+ _boundary(),
)
second = reader.read_sweep(timeout_s=1.0)
self.assertEqual(second.steps.tolist(), [1, 2])
finally:
self._close(master_fd, slave_fd, reader)
def test_only_latest_sweep_is_published(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
payload = (
_boundary() + _main(1, 1, 0) + _reference(1, 1, 0)
+ _boundary() + _main(1, 2, 0) + _reference(1, 2, 0)
+ _boundary() + _main(1, 3, 0) + _reference(1, 3, 0)
+ _boundary()
)
os.write(master_fd, payload)
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline and reader.published_count < 3:
time.sleep(0.005)
self.assertGreaterEqual(reader.published_count, 3)
sweep = reader.read_sweep(timeout_s=1.0)
self.assertEqual(sweep.main.tolist(), [complex(3, 0)])
finally:
self._close(master_fd, slave_fd, reader)
def test_corrupt_frame_fails_fast(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
corrupt = struct.pack("<HHhh", 0x001A, 1, 5, 5) # unknown marker
os.write(master_fd, _boundary() + _main(1, 10, -1) + corrupt + _boundary())
with self.assertRaises((ValueError, RuntimeError)):
reader.read_sweep(timeout_s=1.0)
finally:
self._close(master_fd, slave_fd, reader)
def test_no_completed_sweep_times_out(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(master_fd, _boundary() + _main(1, 10, -1) + _reference(1, 1, 0))
with self.assertRaisesRegex(TimeoutError, "Timed out waiting for Kamil ADC sweep"):
reader.read_sweep(timeout_s=0.1)
finally:
self._close(master_fd, slave_fd, reader)
def test_read_sweep_for_demuxes_by_combo(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
_boundary() + _combo(0, 0) + _main(1, 11, 0) + _reference(1, 1, 0)
+ _boundary() + _combo(0, 1) + _main(1, 22, 0) + _reference(1, 1, 0)
+ _boundary(),
)
# Each combination is served from its own slot, regardless of order.
second = reader.read_sweep_for((0, 1), timeout_s=1.0)
self.assertEqual(second.main.real.tolist(), [22])
self.assertEqual(second.combo, (0, 1))
first = reader.read_sweep_for((0, 0), timeout_s=1.0)
self.assertEqual(first.main.real.tolist(), [11])
finally:
self._close(master_fd, slave_fd, reader)
def test_read_sweep_for_drops_dirty_and_takes_retake(self) -> None:
master_fd, slave_fd, reader = self._open_pty_reader()
try:
os.write(
master_fd,
# A dirty combo (0,1) sweep, then its clean re-take of the same combo.
_boundary() + _combo(0, 1, dirty=1) + _main(1, 99, 0) + _reference(1, 1, 0)
+ _boundary() + _combo(0, 1) + _main(1, 42, 0) + _reference(1, 1, 0)
+ _boundary(),
)
sweep = reader.read_sweep_for((0, 1), timeout_s=1.0)
# The dirty sweep (99) is dropped; only the clean re-take (42) is served.
self.assertEqual(sweep.main.real.tolist(), [42])
self.assertFalse(sweep.dirty)
finally:
self._close(master_fd, slave_fd, reader)
class KamilAdcConfigTest(unittest.TestCase):
def test_config_round_trip_preserves_kamil_sections(self) -> None:
payload = {
"radar": {
"model": "kamil_adc",
"serial": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": "",
"executable_path": "build/bin/kamil_adc_collector",
"tty_path": "/tmp/ttyADC_data",
"args": ["profile:phase", "do8_freq_ref"],
"env": {"ADC_ENV": "1"},
"startup_timeout_s": 7.0,
"sweep_timeout_s": 8.0,
"stop_timeout_s": 3.0,
"phase_calibration": {
"phase0_rad": 1.5,
"freq0_hz": 2_046_000_000.0,
"phase1_rad": 301.0,
"freq1_hz": 5_612_000_000.0,
},
"band": {
"start_hz": 2_100_000_000.0,
"stop_hz": 5_500_000_000.0,
"points": 1024,
},
},
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
encoded = RunConfigModel.from_dict(payload).to_dict()
kamil = encoded["radar"]["kamil_adc"]
self.assertEqual(encoded["radar"]["model"], "kamil_adc")
self.assertEqual(kamil["executable_path"], "build/bin/kamil_adc_collector")
self.assertEqual(kamil["args"], ["profile:phase", "do8_freq_ref"])
self.assertEqual(kamil["phase_calibration"]["phase0_rad"], 1.5)
self.assertEqual(kamil["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
self.assertEqual(kamil["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
def test_close_never_raises_when_collector_refuses_to_die(self) -> None:
"""close() must stay exception-safe even if a SIGKILL'd collector is not
reaped within the grace window (e.g. wedged in USB D-state)."""
with tempfile.TemporaryDirectory() as tmp_dir:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": tmp_dir,
"executable_path": "/bin/sh", # any real executable
"tty_path": "/tmp/ttyADC_test",
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
)
service = KamilAdcService(config)
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
process.poll.return_value = None # always "alive"
process.wait.side_effect = subprocess.TimeoutExpired("kamil_adc_collector", timeout=3.0)
service._process = process
with mock.patch("python_app.hardware_full.kamil_adc.service.os.killpg"):
service.close() # must not raise
def test_drain_after_switch_waits_for_fresh_sweeps(self) -> None:
"""After a switch change, drain must skip the configured number of freshly
published sweeps before returning, so the next capture is post-switch."""
from python_app.hardware_full.kamil_adc import service as service_module
with tempfile.TemporaryDirectory() as tmp_dir:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": tmp_dir,
"executable_path": "/bin/sh",
"tty_path": "/tmp/ttyADC_test",
"sweep_timeout_s": 5.0,
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
)
service = KamilAdcService(config)
reader = mock.Mock(spec=KamilAdcTtyReader, published_count=10)
service._reader = reader
service._process = mock.Mock(poll=mock.Mock(return_value=None))
# Each poll-sleep advances the published count, as the reader thread would.
def _advance(_seconds: float) -> None:
reader.published_count += 1
with mock.patch.object(service_module.time, "sleep", _advance):
service.drain_after_switch(sweeps=3)
# Started at 10, must have waited for at least 3 more sweeps.
self.assertGreaterEqual(reader.published_count, 13)
def test_drain_after_switch_is_noop_when_not_open(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": tmp_dir,
"executable_path": "/bin/sh",
"tty_path": "/tmp/ttyADC_test",
},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
)
KamilAdcService(config).drain_after_switch() # no reader → must not raise
def test_supervisor_selects_kamil_adc_producer(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
config_path = Path(tmp_dir) / "run_config.json"
config_path.write_text(json.dumps({"radar": {"model": "kamil_adc"}}), encoding="utf-8")
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
self.assertEqual(
command,
[sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer", "--config", str(config_path)],
)
if __name__ == "__main__":
unittest.main()
+44 -84
View File
@@ -8,9 +8,9 @@ import unittest
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.models import VariationType
from python_app.hardware_full.laser_control.protocol import Protocol, TaskType
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
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.rph_radar.laser import apply_rph_radar_laser_control
from python_app.models.run_config_schema import RunConfigModel
@@ -18,11 +18,6 @@ DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
)
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = (
# Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the
# step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005).
"7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406"
)
class _FakeProtocol:
@@ -43,11 +38,11 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
self.controller.connect.return_value = True
@staticmethod
def _kamil_config(laser_payload: dict[str, object]) -> RunConfigModel:
def _rph_config(laser_payload: dict[str, object]) -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"model": "rph_radar",
"driver_mode": "native",
"laser_control": laser_payload,
}
@@ -71,7 +66,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
def test_variation_commands_match_device_main_bytes(self) -> None:
ld1_command = Protocol.encode_task_enable(
task_type=TaskType.CHANGE_CURRENT_LD1,
static_temp1=28.0,
static_temp2=28.9,
static_current1=33.0,
@@ -81,50 +75,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
step=0.05,
time_step=50,
delay_time=10,
message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560,
pi_coeff1_i=128,
pi_coeff2_p=2560,
pi_coeff2_i=128,
)
ld2_command = Protocol.encode_task_enable(
task_type=TaskType.CHANGE_CURRENT_LD2,
static_temp1=28.0,
static_temp2=28.9,
static_current1=33.0,
static_current2=35.0,
min_value=33.0,
max_value=35.0,
step=0.05,
time_step=50,
delay_time=10,
message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560,
pi_coeff1_i=128,
pi_coeff2_p=2560,
pi_coeff2_i=128,
)
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX)
def test_ld1_and_ld2_encode_current_step_identically(self) -> None:
# Regression guard for the LD2 step-scale bug: both current-variation channels
# must encode the step with the same scale (current_ma_to_n), like min/max.
ld1, ld2 = [Protocol.encode_task_enable(
task_type=task_type, static_temp1=28.0, static_temp2=28.9,
static_current1=33.0, static_current2=35.0, min_value=33.0, max_value=35.0,
step=0.05, time_step=50, delay_time=10, message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128,
) for task_type in (TaskType.CHANGE_CURRENT_LD1, TaskType.CHANGE_CURRENT_LD2)]
# Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step).
self.assertEqual(ld1[10:12], ld2[10:12])
def test_start_sequence_matches_device_main_order(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
controller = LaserController("/dev/ttyLASER", pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol))
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
@@ -137,7 +99,6 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=VariationType.CHANGE_CURRENT_LD1,
params={
"static_temp1": 28.0,
"static_temp2": 28.9,
@@ -160,34 +121,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
],
)
def test_stop_sequence_restores_device_main_manual_bytes(self) -> None:
cleanup = ExitStack()
self.addCleanup(cleanup.close)
fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
cleanup.enter_context(patch.object(controller, "_protocol", fake_protocol))
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
controller.set_manual_mode(
temp1=28.0,
temp2=28.9,
current1=33.0,
current2=35.0,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
fake_protocol.sent.clear()
controller.stop_task(restore_message_id=DEVICE_MAIN_MESSAGE_ID)
self.assertEqual(
[command.hex() for command in fake_protocol.sent],
[
"2222",
DEVICE_MAIN_MANUAL_HEX,
],
)
def test_apply_radar_variation_sequence_matches_device_main_order(self) -> None:
config = self._kamil_config(
config = self._rph_config(
{
"enabled": True,
"port": "/dev/ttyUSB0",
@@ -211,8 +146,8 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
applied = apply_kamil_adc_laser_control(config)
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
applied = apply_rph_radar_laser_control(config)
self.assertTrue(applied)
controller = self.controller
@@ -233,13 +168,12 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
)
# The variation handoff itself (type + params), not just call order, must be right.
variation_payload = controller.start_variation.call_args.kwargs
self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1)
self.assertEqual(variation_payload["params"]["step"], 0.05)
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
config = self._kamil_config(
config = self._rph_config(
{
"enabled": True,
"port": "/dev/ttyUSB0",
@@ -247,27 +181,53 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
"manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0},
}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
applied = apply_kamil_adc_laser_control(config)
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
applied = apply_rph_radar_laser_control(config)
self.assertTrue(applied)
controller = self.controller
self.assertEqual([call[0] for call in controller.method_calls], ["connect", "reset", "set_manual_mode", "disconnect"])
self.assertEqual(controller.set_manual_mode.call_args.kwargs["current1"], 30.0)
def test_apply_radar_unknown_variation_type_raises(self) -> None:
config = self._kamil_config(
config = self._rph_config(
{"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation",
"variation": {"variation_type": "NOT_A_REAL_TYPE"}}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller):
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller):
with self.assertRaisesRegex(ValueError, "variation_type"):
apply_kamil_adc_laser_control(config)
apply_rph_radar_laser_control(config)
def test_state_failure_cannot_report_command_success(self) -> None:
controller = LaserController("/dev/ttyLASER")
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
with patch("python_app.hardware_full.laser_control.controller.time.sleep"):
with self.assertRaises(CommunicationError):
controller.reset()
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", "step", 0.0),
("variation", "step", 0.002),
("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})
target = config.radar.laser_control
setattr(target if mode == "pi" else getattr(target, mode), field, value)
with self.subTest(mode=mode, field=field), patch(
"python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller
) as factory:
with self.assertRaises((ValueError, ValidationError)):
apply_rph_radar_laser_control(config)
factory.assert_not_called()
def test_apply_radar_skips_disabled_laser_control(self) -> None:
config = self._kamil_config({"enabled": False})
config = self._rph_config({"enabled": False})
with patch("python_app.hardware_full.laser_control.controller.LaserController", return_value=self.controller) as factory:
applied = apply_kamil_adc_laser_control(config)
with patch("python_app.hardware_full.rph_radar.laser.LaserController", return_value=self.controller) as factory:
applied = apply_rph_radar_laser_control(config)
self.assertFalse(applied)
factory.assert_not_called()
@@ -1,218 +0,0 @@
"""Tests for the laser current-variation temperature monitoring package."""
from __future__ import annotations
import tempfile
import threading
import unittest
from unittest import mock
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
from python_app.hardware_full.laser_control.monitoring import (
LaserTemperatureChecker,
LaserTemperatureMonitor,
LaserVariationSession,
ReadingReader,
ReadingWriter,
TemperatureReading,
compute_sweep_period_s,
resolve_period_s,
)
@dataclass
class _FakeMeasurements:
temp1: float
temp2: float
temp_ext1: Optional[float] = None
temp_ext2: Optional[float] = None
current1: Optional[float] = None
current2: Optional[float] = None
class _FakeController:
"""Returns a queued sequence of measurements, then None."""
def __init__(self, measurements: List[Optional[_FakeMeasurements]]) -> None:
self._queue = list(measurements)
def get_measurements(self) -> Optional[_FakeMeasurements]:
return self._queue.pop(0) if self._queue else None
class SessionRoundTripTest(unittest.TestCase):
def test_save_then_load_preserves_targets_and_tolerance(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "session.json"
LaserVariationSession(
variation_type="CHANGE_CURRENT_LD1",
target_temp1=28.0,
target_temp2=28.9,
tolerance_c=0.03,
started_at_iso="2026-07-27T12:00:00",
).save(path)
loaded = LaserVariationSession.load(path)
self.assertEqual(loaded.variation_type, "CHANGE_CURRENT_LD1")
self.assertAlmostEqual(loaded.target_temp1, 28.0)
self.assertAlmostEqual(loaded.target_temp2, 28.9)
self.assertAlmostEqual(loaded.tolerance_c, 0.03)
def test_load_missing_file_raises(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(FileNotFoundError):
LaserVariationSession.load(Path(tmp) / "absent.json")
class ReadingsChannelTest(unittest.TestCase):
def _reading(self, seq: int, t1: float = 25.0, t2: float = 25.0) -> TemperatureReading:
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
def test_reader_tails_appended_lines_in_order(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "readings.jsonl"
reader = ReadingReader(path) # start at (nonexistent) end
with ReadingWriter(path) as writer:
writer.write(self._reading(0, 25.0))
writer.write(self._reading(1, 26.0))
first = list(reader.poll())
writer.write(self._reading(2, 27.0))
second = list(reader.poll())
self.assertEqual([r.seq for r in first], [0, 1])
self.assertEqual([r.seq for r in second], [2])
self.assertAlmostEqual(first[1].temp1, 26.0)
def test_partial_trailing_line_is_buffered_until_newline(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "readings.jsonl"
path.write_text('{"seq":0,"mono_ns":0,"temp1":25.0,"temp2":25.0}\n{"seq":1,"mono',
encoding="utf-8")
reader = ReadingReader(path, from_start=True)
first = list(reader.poll())
# Complete the truncated line.
with path.open("a", encoding="utf-8") as fh:
fh.write('_ns":1,"temp1":26.0,"temp2":26.0}\n')
second = list(reader.poll())
self.assertEqual([r.seq for r in first], [0])
self.assertEqual([r.seq for r in second], [1])
class SweepPeriodTest(unittest.TestCase):
def test_compute_sweep_period_matches_formula(self) -> None:
# (35-33)/0.05 = 40 -> 41 points; per point = 10ms + 50us = 0.01005s.
period = compute_sweep_period_s(33.0, 35.0, 0.05, time_step_us=50, delay_time_ms=10)
self.assertAlmostEqual(period, 41 * 0.01005, places=6)
def test_resolve_interval_strategy(self) -> None:
period = resolve_period_s(
"interval:250", min_value=33.0, max_value=35.0, step=0.05,
time_step_us=50, delay_time_ms=10,
)
self.assertAlmostEqual(period, 0.25)
def test_resolve_rejects_unknown_strategy(self) -> None:
with self.assertRaises(ValueError):
resolve_period_s("bogus", min_value=0, max_value=1, step=0.1,
time_step_us=50, delay_time_ms=10)
class MonitorTest(unittest.TestCase):
def test_read_once_maps_measurement_fields(self) -> None:
controller = _FakeController([_FakeMeasurements(
temp1=28.01, temp2=28.9, temp_ext1=22.0, temp_ext2=23.0,
current1=33.0, current2=35.0,
)])
with tempfile.TemporaryDirectory() as tmp:
with ReadingWriter(Path(tmp) / "r.jsonl") as writer:
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
reading = monitor.read_once(7)
assert reading is not None
self.assertEqual(reading.seq, 7)
self.assertAlmostEqual(reading.temp1, 28.01)
assert reading.temp_ext1 is not None and reading.current2 is not None
self.assertAlmostEqual(reading.temp_ext1, 22.0)
self.assertAlmostEqual(reading.current2, 35.0)
def test_run_publishes_until_stopped(self) -> None:
controller = _FakeController([
_FakeMeasurements(28.0, 28.9),
_FakeMeasurements(28.0, 28.9),
])
stop = threading.Event()
writer = mock.Mock(spec=ReadingWriter)
writer.write.side_effect = lambda _reading: stop.set()
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
monitor.run(stop)
writer.write.assert_called_once()
self.assertEqual(writer.write.call_args.args[0].seq, 0)
class CheckerTest(unittest.TestCase):
def _checker(self, reminder_every: int = 0) -> LaserTemperatureChecker:
return LaserTemperatureChecker(target_temp1=28.0, target_temp2=28.9,
tolerance_c=0.03, reminder_every=reminder_every)
def _reading(self, t1: float, t2: float, seq: int = 0) -> TemperatureReading:
return TemperatureReading(seq=seq, mono_ns=seq, temp1=t1, temp2=t2)
def test_laser1_off_target_warns_once_for_laser1(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=28.05, t2=28.9)) # laser1 off by 0.05
self.assertEqual([d.laser for d in warned], [1])
def test_within_tolerance_no_warning(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=28.01, t2=28.9)) # 0.01 < 0.03
self.assertEqual(warned, [])
def test_boundary_equal_tolerance_is_ok(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=28.03, t2=28.9)) # |Δ|==tol -> within
self.assertEqual(warned, [])
def test_both_lasers_off_target_warn_independently(self) -> None:
checker = self._checker()
warned = checker.process(self._reading(t1=27.9, t2=29.0))
self.assertEqual(sorted(d.laser for d in warned), [1, 2])
def test_persistent_mismatch_warns_once_then_silent(self) -> None:
checker = self._checker()
first = checker.process(self._reading(t1=28.1, t2=28.9, seq=0))
second = checker.process(self._reading(t1=28.1, t2=28.9, seq=1))
self.assertEqual([d.laser for d in first], [1])
self.assertEqual(second, []) # no reminder configured
def test_reminder_repeats_warning(self) -> None:
checker = self._checker(reminder_every=2)
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # initial warn
self.assertEqual(checker.process(self._reading(t1=28.1, t2=28.9, seq=1)), [])
again = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # reminder
self.assertEqual([d.laser for d in again], [1])
def test_recovery_clears_mismatch_state(self) -> None:
checker = self._checker()
checker.process(self._reading(t1=28.1, t2=28.9, seq=0)) # warn
checker.process(self._reading(t1=28.0, t2=28.9, seq=1)) # recover (info, no warn)
rewarn = checker.process(self._reading(t1=28.1, t2=28.9, seq=2)) # warns again
self.assertEqual([d.laser for d in rewarn], [1])
def test_from_session_uses_session_targets(self) -> None:
session = LaserVariationSession(
variation_type="CHANGE_CURRENT_LD1",
target_temp1=30.0, target_temp2=31.0, tolerance_c=0.03,
)
checker = LaserTemperatureChecker.from_session(session)
warned = checker.process(self._reading(t1=30.1, t2=31.0))
self.assertEqual([d.laser for d in warned], [1])
if __name__ == "__main__":
unittest.main()
@@ -7,7 +7,9 @@ from unittest import mock
from PyQt6.QtWidgets import QApplication, QComboBox, QDoubleSpinBox, QStackedWidget
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
from python_app.gui.controllers.radar_settings_controller import RadarSettingsController
from python_app.gui.controllers.sections.processing_section import build_processing_group
from python_app.gui.controllers.sections.radar_section import build_radar_group
from python_app.models.gui_profile_schema import GuiStateModel
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_schema import RunConfigModel
@@ -33,6 +35,26 @@ class LiveSettingsRegressionTest(unittest.TestCase):
editor = widget(section, f"_{mode}_{bound}_freq_mhz", QDoubleSpinBox)
self.assertEqual((editor.minimum(), editor.maximum(), editor.value()), (1, 9500, expected))
def test_rph_processing_limits_use_band_instead_of_hidden_vna_fields(self) -> None:
config, gui = RunConfigModel(), GuiStateModel()
config.radar.model = "rph_radar"
config.radar.sweep.start_hz, config.radar.sweep.stop_hz = 1e6, 2e6
config.radar.rph_radar.band.start_hz, config.radar.rph_radar.band.stop_hz = 2.1e9, 5.5e9
gui.processing.bscan.start_freq_mhz, gui.processing.bscan.stop_freq_mhz = 2200, 5400
radar, processing = build_radar_group(config), build_processing_group(config, gui)
for group in (radar.group, processing.group):
self.addCleanup(group.deleteLater)
controller = RadarSettingsController(radar, mock.Mock(), processing, lambda: config, mock.Mock(),
reset_preprocess=lambda: None, live_changed=lambda: None)
for stage in ("initial", "resync"):
if stage == "resync":
controller.sync_frequency_limits()
with self.subTest(stage=stage):
for editor in processing.frequency_controls:
self.assertEqual((editor.minimum(), editor.maximum()), (2100, 5500))
self.assertEqual(widget(processing, "_bscan_start_freq_mhz", QDoubleSpinBox).value(), 2200)
self.assertEqual(widget(processing, "_bscan_stop_freq_mhz", QDoubleSpinBox).value(), 5400)
def test_failed_live_write_restores_web_batch_and_desktop_changes(self) -> None:
window = isolated_window(self.addCleanup)
window._pipeline.timer.stop()
+12 -15
View File
@@ -11,12 +11,12 @@ from python_app.models.run_config_schema import RunConfigModel
from python_app.workflows.neutral_preprocess import build_neutral_s21_sets, supports_neutral_preprocess_sets
def _kamil_config(*, points: int = 5) -> RunConfigModel:
def _rph_config(*, points: int = 5) -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"kamil_adc": {
"model": "rph_radar",
"rph_radar": {
"band": {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": points},
},
},
@@ -26,9 +26,9 @@ def _kamil_config(*, points: int = 5) -> RunConfigModel:
)
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
class RphRadarNeutralPreprocessTest(unittest.TestCase):
def test_builds_passthrough_s21_sets_on_band_grid(self) -> None:
calibration, reference = build_neutral_s21_sets(_kamil_config(points=5))
calibration, reference = build_neutral_s21_sets(_rph_config(points=5))
expected_frequency = np.linspace(2_100_000_000.0, 5_500_000_000.0, 5).astype(np.float32)
self.assertEqual(len(calibration.traces), 2)
@@ -46,22 +46,19 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_grid_matches_processor_grid(self) -> None:
"""The neutral axis must be byte-identical to the acquisition grid."""
from python_app.hardware_full.kamil_adc import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
config = _kamil_config(points=17)
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc))
config = _rph_config(points=17)
processor = RphRadarSweepProcessor(config.radar.rph_radar)
calibration, _reference = build_neutral_s21_sets(config)
np.testing.assert_array_equal(calibration.traces[0].frequency_hz, processor.grid_hz)
def test_rejects_degenerate_band(self) -> None:
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
build_neutral_s21_sets(_kamil_config(points=1))
with self.assertRaisesRegex(ValueError, "band.points must be"):
build_neutral_s21_sets(_rph_config(points=1))
def test_dtypes_are_float32_and_complex64(self) -> None:
calibration, _reference = build_neutral_s21_sets(_kamil_config())
calibration, _reference = build_neutral_s21_sets(_rph_config())
trace = calibration.traces[0]
self.assertEqual(trace.frequency_hz.dtype, np.float32)
self.assertEqual(trace.s21.dtype, np.complex64)
@@ -69,7 +66,7 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
# The preprocessor divides measured/calibration, so calibration S21
# must never be zero — that is the whole point of the '1+0j neutral' contract.
calibration, _reference = build_neutral_s21_sets(_kamil_config())
calibration, _reference = build_neutral_s21_sets(_rph_config())
for trace in calibration.traces:
self.assertTrue(bool(np.all(trace.s21 != 0)))
@@ -141,9 +141,9 @@ class RadarModelCommandTest(unittest.TestCase):
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
def test_kamil_producer_for_kamil_adc(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
def test_rph_producer_for_rph_radar(self) -> None:
cmd = self.supervisor._acquisition_command(self._config_with_model("rph_radar"))
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer"])
def test_single_and_multi_use_python_in_both_modes(self) -> None:
@@ -270,19 +270,19 @@ class SupervisorLifecycleTest(unittest.TestCase):
group_kill.assert_not_called()
kill.assert_called_once_with(123, signal.SIGTERM)
def test_collector_reaper_does_not_match_command_text(self) -> None:
def test_stop_does_not_kill_unowned_collector(self) -> None:
entries = [Path("/proc/123"), Path("/proc/456")]
cmdlines = [
b"bash\x00-c\x00echo kamil_adc_collector\x00",
b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00",
b"bash\x00-c\x00echo rph_radar_collector\x00",
b"/project/build/bin/rph_radar_collector\x00--config\x00config.json\x00",
]
with (
mock.patch.object(Path, "iterdir", return_value=entries),
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
):
self.supervisor._kill_kamil_collectors()
signal_group.assert_called_once_with(456, signal.SIGKILL)
self.supervisor.stop_all()
signal_group.assert_not_called()
if __name__ == "__main__":
@@ -0,0 +1,91 @@
"""Phase-only calibration preserves acquisition settings and unrelated JSON bytes."""
from contextlib import redirect_stdout
from io import StringIO
import json
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
import numpy as np
from python_app.models.run_config_schema import RunConfigModel
from python_app.scripts import rph_radar_calibrate as calibration
class RphCalibrationTest(unittest.TestCase):
def setUp(self) -> None:
logging_setup = patch.object(calibration.logging, "basicConfig")
logging_setup.start()
self.addCleanup(logging_setup.stop)
self.payload = {"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
"executable_path": "/custom/collector", "args": ["duration_ms:123"],
"band": {"start_hz": 1.2e9, "stop_hz": 1.8e9, "points": 137},
"phase_calibration": {"phase0_rad": 3.456789, "phase1_rad": 9.456789,
"freq0_hz": 1e9, "freq1_hz": 2e9}}}, "note": "Пример: phase0_rad"}
self.config = RunConfigModel.from_dict(self.payload)
self.service = Mock()
def raw(self, phase: np.ndarray) -> SimpleNamespace:
return SimpleNamespace(reference=np.exp(1j * phase), dirty=False)
def test_apply_preserves_every_other_byte_and_uses_current_settings(self) -> None:
for indent, separators in ((None, (",", ":")), (2, (",", ": ")), ("\t", (",", " : "))):
original = json.dumps(self.payload, ensure_ascii=False, indent=indent, separators=separators).replace("\n", "\r\n").encode()
for apply in (False, True):
with self.subTest(indent=indent, apply=apply), TemporaryDirectory() as directory:
path = Path(directory) / "profile.json"
path.write_bytes(original)
self.service.read_raw_sweep.side_effect = [self.raw(np.array([0., 1.]))] + [
self.raw(np.linspace(start, start + 2., 20)) for start in (3.10, -3.10, 3.12)]
argv = ["calibrate", "--config", str(path), "--sweeps", "3", "--warmup", "1"]
with patch.object(calibration, "RphRadarService", return_value=self.service) as factory, \
patch("sys.argv", argv + (["--apply"] if apply else [])), redirect_stdout(StringIO()) as output:
self.assertEqual(calibration.main(), 0)
result = json.loads(output.getvalue())
self.assertAlmostEqual(result["phase0_rad"], 3.12)
self.assertAlmostEqual(result["phase1_rad"], 5.12)
expected = original
if apply:
expected = original.replace(b"3.456789", json.dumps(result["phase0_rad"]).encode()).replace(
b"9.456789", json.dumps(result["phase1_rad"]).encode())
self.assertEqual(path.read_bytes(), expected)
self.assertEqual(factory.call_args.args[0].to_dict(), self.config.to_dict())
self.assertEqual(self.service.read_raw_sweep.call_count, 4)
self.service.close.assert_called_once()
self.service.reset_mock()
def test_invalid_reference_never_produces_anchors_and_always_closes(self) -> None:
for reference in (np.array([]), np.array([1]), np.array([1, np.nan]),
np.array([1, np.inf]), np.array([0, 1]), np.ones(3), np.ones((2, 2))):
self.service.read_raw_sweep.return_value = SimpleNamespace(reference=reference, dirty=False)
with self.subTest(reference=reference), patch.object(calibration, "RphRadarService", return_value=self.service):
with self.assertRaises(ValueError):
calibration.calibrate(self.config, sweeps=1, warmup=0)
self.service.close.assert_called_once()
self.service.reset_mock()
def test_changed_profile_is_not_overwritten(self) -> None:
with TemporaryDirectory() as directory:
path = Path(directory) / "profile.json"
path.write_text(json.dumps(self.payload))
def change_profile(*_args):
path.write_bytes(b"external edit")
return {"phase0_rad": 3., "phase1_rad": 6.}
with patch.object(calibration, "calibrate", side_effect=change_profile), \
patch("sys.argv", ["calibrate", "--config", str(path), "--apply"]), redirect_stdout(StringIO()):
with self.assertRaisesRegex(RuntimeError, "changed during capture"):
calibration.main()
self.assertEqual(path.read_bytes(), b"external edit")
def test_missing_anchors_fail_before_capture(self) -> None:
del self.payload["radar"]["rph_radar"]["phase_calibration"]["phase0_rad"]
with TemporaryDirectory() as directory:
path = Path(directory) / "profile.json"
path.write_text(json.dumps(self.payload))
with patch.object(calibration, "calibrate") as capture, patch("sys.argv", ["calibrate", "--config", str(path)]):
with self.assertRaisesRegex(ValueError, "explicit.*phase0_rad"):
calibration.main()
capture.assert_not_called()
@@ -1,4 +1,4 @@
"""Tests for the Kamil ADC reference-channel signal processing.
"""Tests for the RadioPhotonic radar (RPH) reference-channel signal processing.
References are built from an explicit *unwrapped* phase ramp whose first sample
lies in ``(-pi, pi]`` and whose steps are below ``pi``, so that
@@ -9,15 +9,12 @@ accumulating forward) and lets us reason precisely about the frequency mapping.
from __future__ import annotations
from dataclasses import replace
import unittest
import numpy as np
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
from python_app.models.run_config_schema import RphRadarModel, RphRadarBandModel, RphRadarPhaseCalibrationModel
def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
@@ -25,46 +22,14 @@ def _reference(phase: np.ndarray, amplitude: float = 1000.0) -> np.ndarray:
return amplitude * np.exp(1j * np.asarray(phase, dtype=np.float64))
# Calibration shared by the processor tests: phase 0 rad -> 2 GHz, phase 100 rad
# -> 4 GHz (slope 2e7 Hz/rad). Band [2.5, 3.5] GHz corresponds to phase [25, 75].
_CALIBRATION = KamilAdcProcessingParams(
phase0_rad=0.0,
freq0_hz=2_000_000_000.0,
phase1_rad=100.0,
freq1_hz=4_000_000_000.0,
band_start_hz=2_500_000_000.0,
band_stop_hz=3_500_000_000.0,
band_points=11,
)
class ProcessingParamsTest(unittest.TestCase):
def _params(self, **overrides: float) -> KamilAdcProcessingParams:
return replace(_CALIBRATION, **overrides)
def test_slope_is_constant_from_anchors(self) -> None:
self.assertAlmostEqual(self._params().hz_per_rad, 2.0e7)
def test_rejects_equal_phase_anchors(self) -> None:
with self.assertRaisesRegex(ValueError, "distinct phases"):
self._params(phase0_rad=5.0, phase1_rad=5.0)
def test_rejects_equal_frequency_anchors(self) -> None:
with self.assertRaisesRegex(ValueError, "distinct frequencies"):
self._params(freq0_hz=3.0e9, freq1_hz=3.0e9)
def test_rejects_inverted_band(self) -> None:
with self.assertRaisesRegex(ValueError, "stop_hz must be greater"):
self._params(band_start_hz=4.0e9, band_stop_hz=3.0e9)
def test_rejects_degenerate_point_count(self) -> None:
with self.assertRaisesRegex(ValueError, "points must be >= 2"):
self._params(band_points=1)
class SweepProcessorTest(unittest.TestCase):
def _processor(self, **overrides: float) -> KamilAdcSweepProcessor:
return KamilAdcSweepProcessor(replace(_CALIBRATION, **overrides))
def _processor(self, **phase: float) -> RphRadarSweepProcessor:
calibration = {"phase0_rad": 0., "freq0_hz": 2e9, "phase1_rad": 100., "freq1_hz": 4e9}
calibration.update(phase)
return RphRadarSweepProcessor(RphRadarModel(
phase_calibration=RphRadarPhaseCalibrationModel(**calibration),
band=RphRadarBandModel(start_hz=2.5e9, stop_hz=3.5e9, points=11),
))
def test_grid_is_fixed_and_identical_across_calls(self) -> None:
processor = self._processor()
@@ -0,0 +1,69 @@
"""Collector packets preserve whole sweeps, source windows and pair identity."""
import struct
import unittest
import numpy as np
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser
def packet(*, source_id=1, cycle_id=1, start=100, end=200, combo=(1, 2), dirty=0, points=None):
if points is None:
points = [(0xA, 1, 10, -1), (0xA8, 1, 100, 5)]
payload = b"".join(struct.pack("<HHhh", *point) for point in points)
return struct.pack("<4sIQQQQHHI", b"RPH1", len(payload), source_id, cycle_id,
start, end, *combo, dirty) + payload
class RphRadarStreamParserTest(unittest.TestCase):
def test_fragmented_packets_keep_source_window_and_identity(self):
parser = RphRadarStreamParser()
sweeps = []
for byte in b"preroll" + packet(source_id=73, cycle_id=9):
sweeps.extend(parser.feed(bytes([byte])))
self.assertEqual(len(sweeps), 1)
sweep = sweeps[0]
self.assertEqual((sweep.source_id, sweep.cycle_id, sweep.capture_start_ns, sweep.capture_end_ns),
(73, 9, 100, 200))
self.assertEqual(sweep.combo, (1, 2))
self.assertEqual(sweep.main.tolist(), [10-1j])
self.assertEqual(sweep.reference.tolist(), [100+5j])
self.assertEqual(sweep.main.dtype, np.complex64)
def test_whole_packet_loss_does_not_merge_pairs(self):
parser = RphRadarStreamParser()
first, last = parser.feed(packet(source_id=1) + packet(source_id=3, combo=(2, 3),
points=[(0xA, 2, 20, 0), (0xA8, 2, 200, 0)]))
self.assertEqual((first.combo, first.steps.tolist()), ((1, 2), [1]))
self.assertEqual((last.combo, last.steps.tolist()), ((2, 3), [2]))
self.assertEqual(last.source_id, 3)
def test_aligns_both_channels_and_keeps_only_shared_steps(self):
parser = RphRadarStreamParser()
(sweep,) = parser.feed(packet(points=[(0xA, 3, 3, 0), (0xA8, 3, 30, 0),
(0xA, 1, 1, 0), (0xA8, 1, 10, 0), (0xA, 2, 2, 0)]))
self.assertEqual(sweep.steps.tolist(), [1, 3])
self.assertEqual(sweep.main.real.tolist(), [1, 3])
self.assertEqual(sweep.reference.real.tolist(), [10, 30])
def test_bad_window_size_flags_and_point_marker_fail_fast(self):
for encoded in (packet(start=0), packet(start=200, end=100), packet(dirty=2),
packet(source_id=0), packet(cycle_id=0),
packet(points=[(0x1A, 1, 1, 1)]),
b"RPH1" + struct.pack("<I", 0xFFFFFFFF) + bytes(40)):
with self.subTest(encoded=encoded), self.assertRaisesRegex(ValueError, "protocol"):
RphRadarStreamParser().feed(encoded)
def test_reset_discards_partial_packet(self):
parser = RphRadarStreamParser()
self.assertEqual(parser.feed(packet()[:53]), [])
parser.reset()
(sweep,) = parser.feed(packet(source_id=2, dirty=1, combo=(65535, 65535)))
self.assertEqual(sweep.source_id, 2)
self.assertIsNone(sweep.combo)
self.assertTrue(sweep.dirty)
if __name__ == "__main__":
unittest.main()
+243
View File
@@ -0,0 +1,243 @@
"""PTY integration, source timing, fresh captures and owned collector lifecycle."""
import json
import os
from pathlib import Path
import pty
import subprocess
import sys
import tempfile
import threading
import time
import tty
import unittest
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.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
def config():
return RunConfigModel.from_dict({
"radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": {
"executable_path": "/bin/sh", "tty_path": "/tmp/rph-test"}},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
"run": {"continuous": False, "combos": [{"input": 0, "output": 0}]},
})
class RphRadarTtyReaderTest(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.open()
try:
os.write(master, packet(source_id=1, combo=(0, 0))
+ packet(source_id=2, combo=(0, 1), dirty=1)
+ packet(source_id=3, combo=(0, 1)))
deadline = time.monotonic() + 1
while reader.published_count < 3 and time.monotonic() < deadline:
time.sleep(.005)
self.assertEqual(reader.read_sweep(timeout_s=1).source_id, 3)
self.assertEqual(reader.read_sweep(combo=(0, 0), timeout_s=1).source_id, 1)
self.assertEqual(reader.read_sweep(combo=(0, 1), timeout_s=1).source_id, 3)
with self.assertRaises(TimeoutError):
reader.read_sweep(combo=(0, 1), timeout_s=.01)
os.write(master, packet(start=0))
with self.assertRaisesRegex(ValueError, "protocol"):
reader.read_sweep(timeout_s=1)
finally:
reader.close()
os.close(master)
os.close(slave)
def test_new_source_session_rejects_backlog_despite_adc_clock_drift(self):
for settling_ms in (0, 2):
with self.subTest(settling_ms=settling_ms):
cfg = config()
cfg.runtime.settling_ms = settling_ms
service = RphRadarService(cfg)
# Physically this packet crossed the switch; a +50 ppm ADC clock
# after 1000 s puts its timestamp 44 ms past the host switch.
stale, fresh = RphRadarStreamParser().feed(
packet(source_id=9, start=1_044_000_000, end=1_064_000_000)
+ packet(source_id=1, cycle_id=1, start=1_100_000_000, end=1_120_000_000,
points=[(0xA, 1, 20, 0), (0xA8, 1, 100, 0)]))
old_reader = mock.Mock(read_sweep=mock.Mock(return_value=stale))
new_reader = mock.Mock(read_sweep=mock.Mock(return_value=fresh))
service._reader = old_reader
service._process = mock.Mock(poll=mock.Mock(return_value=None))
service._stop_event = threading.Event()
processor = mock.Mock(grid_hz=np.array([1.]), _previous_anchor_rad=3.3,
process=mock.Mock(side_effect=lambda main, reference: main))
service._processor = processor
def reopen(_service, *, stop_event):
old_reader.close.assert_called_once()
self.assertIs(stop_event, service._stop_event)
service._reader = new_reader
service._process = mock.Mock(poll=mock.Mock(return_value=None))
with mock.patch.object(RphRadarService, "open", autospec=True, side_effect=reopen) as opened, \
mock.patch("python_app.hardware_full.rph_radar.service.time.monotonic_ns", return_value=1_000_000_000), \
mock.patch("python_app.hardware_full.rph_radar.service.time.sleep") as sleep:
with mock.patch.object(RphRadarService, "_stop_process", side_effect=lambda: setattr(service._process, "poll", mock.Mock(return_value=0))):
service.prepare_combo(ComboKey(0, 0))
result = service.acquire()
np.testing.assert_array_equal(result.trace("s21"), [20+0j])
service.prepare_combo(ComboKey(0, 0))
opened.assert_called_once()
self.assertEqual(sleep.call_count, int(settling_ms == 0))
self.assertEqual((result.capture_start_ns, result.capture_end_ns), (1_100_000_000, 1_120_000_000))
self.assertIs(service._processor, processor)
self.assertEqual(processor._previous_anchor_rad, 3.3)
old_reader.read_sweep.assert_not_called()
def test_collector_output_is_captured_in_existing_log(self):
import io
service = RphRadarService(config())
process = mock.Mock(stdout=io.BytesIO(b"ADC disconnected\n"), poll=mock.Mock(return_value=1))
with mock.patch("python_app.hardware_full.rph_radar.service.subprocess.Popen", return_value=process) as launch:
with self.assertLogs("python_app.hardware_full.rph_radar.service", "INFO") as logs:
service._start_process()
service.close()
self.assertIn("ADC disconnected", " ".join(logs.output))
self.assertEqual(launch.call_args.kwargs["stdout"], subprocess.PIPE)
def test_producer_drains_switch_and_never_mixes_source_cycles(self):
for native in (False, True):
with self.subTest(collector_driven=native):
cfg = config()
cfg.input_switch.driver_mode = "native"
cfg.output_switch.driver_mode = "native" if native else "mock"
cfg.combos = cfg.build_full_combos(1, 2) if native else cfg.combos
radar, publisher = mock.Mock(), mock.Mock()
windows = iter(((1, 1, 5), (2, 6, 10), (3, 11, 15), (3, 16, 20)) if native
else ((1, 11, 20),))
def acquire(**_kwargs):
radar.last_cycle_id, start, end = next(windows)
return SweepResult(np.array([1., 2.]), {"s21": np.ones(2)},
capture_start_ns=start, capture_end_ns=end)
radar.acquire.side_effect = acquire
with mock.patch.object(producer, "load_producer_config", return_value=(Path("unused"), cfg, threading.Event())), \
mock.patch.object(producer, "RphRadarService", return_value=radar), \
mock.patch.object(producer, "RawPublisher", return_value=publisher), \
mock.patch.object(producer.SwitchService, "from_model"), \
mock.patch.object(producer, "_open_radar_with_retry", return_value=True), \
mock.patch.object(producer, "select_and_settle", return_value=True):
self.assertEqual(producer.main(), 0)
if not native:
calls = radar.mock_calls
self.assertLess(calls.index(mock.call.prepare_combo(ComboKey(0, 0))), calls.index(mock.call.acquire()))
publisher.publish.assert_called_once()
collection = publisher.publish.call_args.args[0]
self.assertEqual(collection.collection_id, 3 if native else 1)
self.assertEqual((collection.capture_start_ns, collection.capture_end_ns), (11, 20))
self.assertEqual(collection.traces[0].capture_start_ns, 11)
class RphRadarConfigTest(unittest.TestCase):
def test_collector_uses_current_settings_without_rereading_profile(self):
cfg = config()
cfg.runtime.settling_ms = 7
service = RphRadarService(cfg, collector_driven=True)
payload = next(arg.removeprefix("switching:") for arg in service.command if arg.startswith("switching:"))
switching = json.loads(payload)
self.assertEqual(switching["settling_ms"], 7)
self.assertEqual(switching["combos"], [[0, 0]])
self.assertEqual(switching["input"]["gpio_chip"], cfg.input_switch.gpio_chip)
self.assertFalse(any(arg.startswith("config:") for arg in service.command))
def test_config_round_trip_preserves_rph_sections(self) -> None:
payload = {
"radar": {
"model": "rph_radar",
"serial": "rph_radar",
"driver_mode": "native",
"rph_radar": {
"project_dir": "",
"executable_path": "build/bin/rph_radar_collector",
"tty_path": "/tmp/ttyADC_data",
"args": ["profile:phase", "do8_freq_ref"],
"env": {"ADC_ENV": "1"},
"startup_timeout_s": 7.0,
"sweep_timeout_s": 8.0,
"stop_timeout_s": 3.0,
"phase_calibration": {
"phase0_rad": 1.5,
"freq0_hz": 2_046_000_000.0,
"phase1_rad": 301.0,
"freq1_hz": 5_612_000_000.0,
},
"band": {
"start_hz": 2_100_000_000.0,
"stop_hz": 5_500_000_000.0,
"points": 1024,
},
},
"laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"},
"sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2},
},
"switches": {"port1": {"positions": 1}, "port2": {"positions": 1}},
}
encoded = RunConfigModel.from_dict(payload).to_dict()
rph = encoded["radar"]["rph_radar"]
self.assertEqual(encoded["radar"]["model"], "rph_radar")
self.assertEqual(rph["executable_path"], "build/bin/rph_radar_collector")
self.assertEqual(rph["args"], ["profile:phase", "do8_freq_ref"])
self.assertEqual(rph["phase_calibration"]["phase0_rad"], 1.5)
self.assertEqual(rph["phase_calibration"]["freq1_hz"], 5_612_000_000.0)
self.assertEqual(rph["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024})
def test_unreaped_collector_blocks_prepare_and_retry_before_touching_tty(self):
service = RphRadarService(config())
process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000)
process.poll.return_value = None
process.wait.side_effect = subprocess.TimeoutExpired("rph_radar_collector", timeout=.5)
service._process = process
with mock.patch("python_app.hardware_full.rph_radar.service.os.killpg"):
with self.assertRaisesRegex(RuntimeError, "did not stop"):
service.prepare_combo(ComboKey(0, 0))
service.close() # Producer retry closes again; still exception-safe.
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"):
with self.assertRaisesRegex(RuntimeError, "did not stop"):
service.open()
unlink.assert_not_called()
start.assert_not_called()
self.assertIs(service._process, process)
process.poll.return_value = 0
service.open() # Recovery becomes possible only after actual exit.
start.assert_called_once()
service.close()
def test_supervisor_selects_rph_radar_producer(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
config_path = Path(tmp_dir) / "run_config.json"
config_path.write_text(json.dumps({"radar": {"model": "rph_radar"}}), encoding="utf-8")
command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path)
self.assertEqual(
command,
[sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer", "--config", str(config_path)],
)
if __name__ == "__main__":
unittest.main()
+48 -2
View File
@@ -99,6 +99,17 @@ class StrictNumericTypingTest(unittest.TestCase):
class StructuralTypingTest(unittest.TestCase):
"""A present-but-wrong-shaped section fails loudly instead of being dropped."""
def test_old_adc_model_is_rejected_without_alias(self) -> None:
payload = _valid_payload()
payload["radar"]["model"] = "kamil_adc"
with self.assertRaisesRegex(ValueError, "rph_radar"):
RunConfigModel.from_dict(payload)
def test_inactive_old_adc_section_from_saved_profiles_is_ignored(self) -> None:
payload = _valid_payload()
payload["radar"]["kamil_adc"] = {"tty_path": "/tmp/old"}
self.assertEqual(RunConfigModel.from_dict(payload).radar.model, payload["radar"]["model"])
def test_root_must_be_an_object(self) -> None:
for payload in (None, [], 'bad', 42):
with self.subTest(payload=payload), self.assertRaisesRegex(ValueError, 'root'):
@@ -112,6 +123,19 @@ class StructuralTypingTest(unittest.TestCase):
class AcquisitionValidationTest(unittest.TestCase):
def test_rph_key_normalizes_active_laser_mode(self) -> None:
keys = []
for mode in ("variation", " Variation ", "VARIATION"):
config = self.config("rph_radar")
laser = config.radar.laser_control
laser.enabled, laser.mode = True, mode
original = config.radar_key()
keys.append(original)
laser.variation.max_value += 1
with self.subTest(mode=mode):
self.assertNotEqual(config.radar_key(), original)
self.assertEqual(len(set(keys)), 1)
def test_default_combo_expansion_is_bounded_before_allocation(self) -> None:
payload = _valid_payload()
payload["switches"]["port1"]["positions"] = 65536
@@ -149,9 +173,31 @@ class AcquisitionValidationTest(unittest.TestCase):
ring.slot_size_bytes = original
validate_acquisition_config(config)
def test_kamil_requires_native_mode(self) -> None:
def test_radar_key_separates_switch_matrices(self) -> None:
stock, per_point = self.config("librevna_multi"), self.config("librevna_multi")
per_point.radar.multi_device.stimulus_switch_positions = 2
self.assertTrue(stock.radar_key().endswith("_out1_in1_stim1"))
self.assertNotEqual(stock.radar_key(), per_point.radar_key())
def test_rph_requires_native_mode(self) -> None:
with self.assertRaisesRegex(ValueError, "native"):
validate_acquisition_config(self.config("kamil_adc"))
validate_acquisition_config(self.config("rph_radar"))
def test_adc_phase_band_and_timeouts_fail_preflight(self) -> None:
for section, field, value in (
("phase_calibration", "phase1_rad", 0.0),
("phase_calibration", "freq1_hz", 2_046_000_000.0),
("phase_calibration", "phase0_rad", float("nan")),
("band", "stop_hz", 2_100_000_000.0),
("band", "start_hz", -1.0), ("band", "points", 1),
("", "sweep_timeout_s", 0.0), ("", "startup_timeout_s", float("inf")),
):
config = self.config("rph_radar")
config.radar.driver_mode = "native"
adc = config.radar.rph_radar
setattr(getattr(adc, section) if section else adc, field, value)
with self.subTest(section=section, field=field), self.assertRaises(ValueError):
validate_acquisition_config(config)
def test_matrix_validates_physical_switch_instead_of_virtual_axis(self) -> None:
config = self.config("librevna_multi")
+31
View File
@@ -75,6 +75,37 @@ class RemoveLastHistoryTest(unittest.TestCase):
class RuntimeSignatureTest(unittest.TestCase):
def test_reference_clock_changes_multi_device_history_only(self) -> None:
for model in ("librevna_multi", "librevna", "rph_radar"):
config = RunConfigModel()
config.radar.model = model
before = build_run_history_signature(config)
config.radar.multi_device.force_external_reference = not config.radar.multi_device.force_external_reference
with self.subTest(model=model):
self.assertEqual(before != build_run_history_signature(config), model == "librevna_multi")
def test_adc_key_and_history_follow_measurement_not_launch_paths(self) -> None:
original = RunConfigModel()
original.radar.model, original.radar.driver_mode = "rph_radar", "native"
for section, field, value, changes_identity in (
("rph_radar.phase_calibration", "phase1_rad", 301.0, True),
("rph_radar.band", "points", 1024, True),
("rph_radar.band", "start_hz", 2.2e9, True),
("rph_radar", "executable_path", "/new/collector", False),
("rph_radar", "tty_path", "/new/tty", False),
("sweep", "if_bandwidth_hz", 999.0, False),
("", "serial", "unused ADC serial", False),
):
changed = original.clone()
target = changed.radar
for part in section.split(".") if section else ():
target = getattr(target, part)
setattr(target, field, value)
with self.subTest(section=section, field=field):
self.assertEqual(original.radar_key() != changed.radar_key(), changes_identity)
self.assertEqual(build_run_history_signature(original) != build_run_history_signature(changed),
changes_identity)
def test_every_ring_endpoint_setting_requires_restart(self) -> None:
original = RunConfigModel()
signature = build_processor_run_signature(original)
+2 -2
View File
@@ -76,8 +76,8 @@ class RadarConfigPrefixTest(unittest.TestCase):
def test_adc_model_reports_adc_point_count(self) -> None:
self.assertEqual(
radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0),
"kamil_adc_1-6GHz_adc_0dBm",
radar_config_filename_prefix("rph_radar", 1e9, 6e9, 1, 0.0),
"rph_radar_1-6GHz_adc_0dBm",
)
+5 -11
View File
@@ -2,7 +2,7 @@
Applying these sets leaves measurements unchanged and satisfies pipeline asset
validation before physical calibration is available. The shortcut is enabled for
verified frequency axes: Kamil ADC, multi-device LibreVNA and single-device mock.
verified frequency axes: RadioPhotonic radar (RPH), multi-device LibreVNA and single-device mock.
"""
from __future__ import annotations
@@ -12,10 +12,7 @@ import time
import numpy as np
from python_app.hardware_full.kamil_adc import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_schema import ComboModel, RunConfigModel
@@ -24,19 +21,16 @@ logger = logging.getLogger(__name__)
def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool:
"""Enable the shortcut only when the emitted frequency grid is verified."""
return bool(config.is_kamil_adc or config.is_multi_device or (
return bool(config.is_rph_radar or config.is_multi_device or (
config.radar.model == RunConfigModel.LIBREVNA_MODEL and config.radar.driver_mode == "mock"
))
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
"""Derive the neutral axis; the preprocessor verifies it against measured data."""
if config.is_kamil_adc:
if config.is_rph_radar:
# Single source of truth for the axis: the same grid the processor emits.
processor = KamilAdcSweepProcessor(
KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc)
)
return processor.grid_hz
return RphRadarSweepProcessor(config.radar.rph_radar).grid_hz
points = int(config.radar.sweep.points)
if points < 1: