Rename kamil_adc to rph_radar and fix its data path
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
+20
-85
@@ -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
|
||||
+109
-119
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+22
-59
@@ -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}")
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
+31
-32
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
+10
-45
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user