Merge branch 'laser-temp-monitoring' into feature/switched-matrix-radar

This commit is contained in:
2026-08-04 14:57:03 +03:00
45 changed files with 2520 additions and 182 deletions
@@ -58,14 +58,14 @@ _WEB_LIVE_SCHEMA = [
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_stop_freq_mhz", "_legacy_gpr_stop_freq_mhz")),
("gpr_imaging_plane_y_m", "Geometry & depth", ("gpr",), _attr("_gpr_imaging_plane_y_m")),
("gpr_range_comp_power", "Imaging", ("gpr",), _attr("_gpr_range_comp_power")),
("gpr_angle_comp_power", "Imaging", ("gpr",), _attr("_gpr_angle_comp_power")),
("gpr_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", "_legacy_gpr_background_subtract_enabled")),
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", "_legacy_gpr_background_mean_count")),
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
("gpr_min_visible_score", "Detection", ("gpr",), _attr("_gpr_min_visible_score")),
("gpr_object_min_frac", "Detection", ("gpr",), _attr("_gpr_object_min_frac")),
("gpr_max_detected_objects_to_draw", "Detection", ("gpr",), _attr("_gpr_max_detected_objects_to_draw")),
("gpr_draw_top_m_objects", "Detection", ("gpr",), _attr("_gpr_draw_top_m_objects")),
("gpr_object_approach_min_frames", "Detection", ("gpr",), _attr("_gpr_object_approach_min_frames")),
("gpr_comp_power", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_comp_power")),
("gpr_snr_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
@@ -110,7 +110,9 @@ _WEB_DISPLAY_SCHEMA = [
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
# "applies on Start" and editing them just updates the widget for the next start.
_WEB_STABLE_SCHEMA = [
("relative_permittivity", "Geometry & medium", _GPR_MODES, _attr("_gpr_relative_permittivity")),
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative
# permittivity is a legacy-GPR-only knob; BP ignores it.
("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")),
("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")),
("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")),
]
@@ -479,14 +481,13 @@ class AppWindowLiveProcessingMixin:
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
f"range_comp={self._gpr_range_comp_power.value():g}, "
f"angle_comp={self._gpr_angle_comp_power.value():g}, "
f"object_min_frac={self._gpr_object_min_frac.value():g}, "
f"score_mode={self._gpr_score_mode.currentText()}, "
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
f"mean_count={self._gpr_background_mean_count.value()}, "
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, "
f"render_mode={self._gpr_render_mode.currentText()}, "
f"min_score={self._gpr_min_visible_score.value():g}, "
f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, "
f"draw_top={self._gpr_draw_top_m_objects.value()})"
)
@@ -292,7 +292,7 @@ class AppWindowConfigProfileIOMixin:
self._gpr_min_depth_m,
self._gpr_max_depth_m,
self._gpr_range_comp_power,
self._gpr_angle_comp_power,
self._gpr_object_min_frac,
self._gpr_score_mode,
self._gpr_motion_mode,
self._gpr_look_angle_deg,
@@ -301,6 +301,7 @@ class AppWindowConfigProfileIOMixin:
self._gpr_speed_m_s,
self._gpr_max_detected_objects_to_draw,
self._gpr_draw_top_m_objects,
self._gpr_object_approach_min_frames,
self._gpr_start_freq_mhz,
self._gpr_stop_freq_mhz,
self._gpr_background_subtract_enabled,
@@ -308,7 +309,6 @@ class AppWindowConfigProfileIOMixin:
self._gpr_remove_sidelobe_objects_enabled,
self._gpr_imaging_plane_y_m,
self._gpr_render_mode,
self._gpr_min_visible_score,
self._gpr_visible_x_min_m,
self._gpr_visible_x_max_m,
self._gpr_visible_z_min_m,
@@ -459,7 +459,7 @@ class AppWindowConfigProfileIOMixin:
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power))
self._gpr_angle_comp_power.setValue(float(gui_state.processing.gpr.angle_comp_power))
self._gpr_object_min_frac.setValue(float(gui_state.processing.gpr.object_min_frac))
self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode)
self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode)
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
@@ -472,6 +472,7 @@ class AppWindowConfigProfileIOMixin:
int(gui_state.processing.gpr.max_detected_objects_to_draw)
)
self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects))
self._gpr_object_approach_min_frames.setValue(int(gui_state.processing.gpr.object_approach_min_frames))
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
self._gpr_background_subtract_enabled.setChecked(
@@ -483,7 +484,6 @@ class AppWindowConfigProfileIOMixin:
)
self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m))
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
self._gpr_min_visible_score.setValue(float(gui_state.processing.gpr.min_visible_score))
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
@@ -241,7 +241,7 @@ class AppWindowConfigStateBuildersMixin:
min_depth_m=2.0,
max_depth_m=14.0,
range_comp_power=0.1,
angle_comp_power=0.0,
object_min_frac=0.7,
score_mode="combined",
motion_mode="int_minus",
look_angle_deg=0.0,
@@ -250,6 +250,7 @@ class AppWindowConfigStateBuildersMixin:
ignore_socket_speed_enabled=False,
max_detected_objects_to_draw=5,
draw_top_m_objects=2,
object_approach_min_frames=3,
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
background_subtract_enabled=True,
@@ -257,7 +258,6 @@ class AppWindowConfigStateBuildersMixin:
remove_sidelobe_objects_enabled=True,
imaging_plane_y_m=0.0,
render_mode="heatmap",
min_visible_score=0.0,
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
visible_z_min_m=0.0,
@@ -369,7 +369,7 @@ class AppWindowConfigStateBuildersMixin:
min_depth_m=float(self._gpr_min_depth_m.value()),
max_depth_m=float(self._gpr_max_depth_m.value()),
range_comp_power=float(self._gpr_range_comp_power.value()),
angle_comp_power=float(self._gpr_angle_comp_power.value()),
object_min_frac=float(self._gpr_object_min_frac.value()),
score_mode=self._gpr_score_mode.currentText(),
motion_mode=self._gpr_motion_mode.currentText(),
look_angle_deg=float(self._gpr_look_angle_deg.value()),
@@ -378,6 +378,7 @@ class AppWindowConfigStateBuildersMixin:
ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()),
max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
object_approach_min_frames=int(self._gpr_object_approach_min_frames.value()),
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
@@ -385,7 +386,6 @@ class AppWindowConfigStateBuildersMixin:
remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
render_mode=self._gpr_render_mode.currentText(),
min_visible_score=float(self._gpr_min_visible_score.value()),
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
@@ -8,7 +8,6 @@ import pyqtgraph as pg
from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.gpr_locator import (
apply_object_draw_limits as gpr_apply_object_draw_limits,
collection_payload_by_name as gpr_collection_payload_by_name,
collection_payloads_by_prefix as gpr_collection_payloads_by_prefix,
filter_object_rows as gpr_filter_object_rows,
@@ -371,26 +370,6 @@ class AppWindowGprPlotMixin:
return self._legacy_gpr_render_mode.currentText()
return self._gpr_render_mode.currentText()
def _gpr_locator_threshold(self) -> float:
"""Return object threshold using the active GPR mode's score semantics."""
if self._processing_mode.currentText() == "legacy_gpr":
return float(self._legacy_gpr_min_visible_pair_count.value())
return float(self._gpr_min_visible_score.value())
def _gpr_draw_limits(self) -> tuple[int, int] | None:
"""Return GPR object draw limits, or None for legacy GPR."""
if self._processing_mode.currentText() == "legacy_gpr":
return None
return (
int(self._gpr_max_detected_objects_to_draw.value()),
int(self._gpr_draw_top_m_objects.value()),
)
@staticmethod
def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray:
"""Apply object count/top-M drawing rules to already-filtered rows."""
return gpr_apply_object_draw_limits(rows, limits)
@staticmethod
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
"""Return lower display bound, preserving surface markers only when surface is visible."""
@@ -506,18 +485,24 @@ class AppWindowGprPlotMixin:
return extract_gpr_object_rows(collection)
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows filtered by threshold, visible X/Z bounds, and active GPR draw limits."""
"""Return the object rows to draw for the active GPR mode.
Coherent BP is already finalized by the processor (visible window + N/M draw
limits, no score threshold exactly Horns_motion_3libre.py), so its rows are
drawn verbatim. Legacy GPR is still filtered here by its pair-count threshold
and the visible window.
"""
rows = self._gpr_object_rows(collection)
if rows.size == 0:
if rows.size == 0 or self._processing_mode.currentText() != "legacy_gpr":
return rows
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
return gpr_filter_object_rows(
rows,
min_score=self._gpr_locator_threshold(),
min_score=float(self._legacy_gpr_min_visible_pair_count.value()),
x_bounds=(x_min, x_max),
z_bounds=(z_min, z_max),
draw_limits=self._gpr_draw_limits(),
draw_limits=None,
)
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
@@ -188,10 +188,14 @@ def build_processing_group(owner) -> QGroupBox:
gpr_defaults = owner._defaults_config.gpr
# Medium permittivity is a legacy-GPR-only knob (shown on the legacy page below).
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so it is not
# offered there. Applies on the next pipeline start (a run_config field).
owner._gpr_relative_permittivity = QDoubleSpinBox()
owner._gpr_relative_permittivity.setDecimals(4)
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
owner._gpr_relative_permittivity.setSingleStep(0.05)
owner._gpr_relative_permittivity.setToolTip("Applied on the next pipeline start (Save Config and restart).")
owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
@@ -205,14 +209,13 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_common_page = _build_processing_mode_page(
group,
[
("Relative permittivity", owner._gpr_relative_permittivity),
("Tx geometry", owner._gpr_tx_geometry_input),
("Rx geometry", owner._gpr_rx_geometry_input),
],
split_index=1,
)
owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry or permittivity changes: Save Config and restart the app")
owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry changes: Save Config and restart the app")
owner._gpr_geometry_hint.setWordWrap(True)
owner._gpr_common_page.layout().addWidget(owner._gpr_geometry_hint)
@@ -240,11 +243,15 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_range_comp_power.setSingleStep(0.01)
owner._gpr_range_comp_power.setValue(float(gpr_live_defaults.range_comp_power))
owner._gpr_angle_comp_power = QDoubleSpinBox()
owner._gpr_angle_comp_power.setDecimals(3)
owner._gpr_angle_comp_power.setRange(0.0, 5.0)
owner._gpr_angle_comp_power.setSingleStep(0.01)
owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power))
owner._gpr_object_min_frac = QDoubleSpinBox()
owner._gpr_object_min_frac.setDecimals(2)
owner._gpr_object_min_frac.setRange(0.0, 1.0)
owner._gpr_object_min_frac.setSingleStep(0.05)
owner._gpr_object_min_frac.setToolTip(
"Object detection stops once a peak falls below this fraction of the global "
"maximum (Horns_motion_3libre.py BP_OBJECT_MIN_FRAC)."
)
owner._gpr_object_min_frac.setValue(float(gpr_live_defaults.object_min_frac))
owner._gpr_score_mode = QComboBox()
owner._gpr_score_mode.addItems(["peak", "combined"])
@@ -300,6 +307,14 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_draw_top_m_objects.setRange(0, 10_000)
owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects))
owner._gpr_object_approach_min_frames = QSpinBox()
owner._gpr_object_approach_min_frames.setRange(1, 100)
owner._gpr_object_approach_min_frames.setToolTip(
"Show an object only after it persists as a motion-consistent track this many "
"consecutive frames (1 disables the approach filter)."
)
owner._gpr_object_approach_min_frames.setValue(int(gpr_live_defaults.object_approach_min_frames))
owner._gpr_start_freq_mhz = QDoubleSpinBox()
owner._gpr_start_freq_mhz.setDecimals(1)
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
@@ -326,12 +341,6 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_render_mode.addItems(["heatmap", "objects_only"])
owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode)
owner._gpr_min_visible_score = QDoubleSpinBox()
owner._gpr_min_visible_score.setDecimals(2)
owner._gpr_min_visible_score.setRange(0.0, 1.0)
owner._gpr_min_visible_score.setSingleStep(0.05)
owner._gpr_min_visible_score.setValue(float(gpr_live_defaults.min_visible_score))
owner._gpr_visible_x_min_m = QDoubleSpinBox()
owner._gpr_visible_x_min_m.setDecimals(2)
owner._gpr_visible_x_min_m.setRange(-100.0, 100.0)
@@ -373,7 +382,7 @@ def build_processing_group(owner) -> QGroupBox:
("Min depth m", owner._gpr_min_depth_m),
("Max depth m", owner._gpr_max_depth_m),
("Range comp power", owner._gpr_range_comp_power),
("Angle comp power", owner._gpr_angle_comp_power),
("Object min frac", owner._gpr_object_min_frac),
("Score mode", owner._gpr_score_mode),
("Motion mode", owner._gpr_motion_mode),
("Look angle deg", owner._gpr_look_angle_deg),
@@ -381,9 +390,9 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_ignore_socket_speed_enabled,
("Speed m/s", owner._gpr_speed_m_s),
("Render mode", owner._gpr_render_mode),
("Min visible score", owner._gpr_min_visible_score),
("Max detected objects", owner._gpr_max_detected_objects_to_draw),
("Draw top M objects", owner._gpr_draw_top_m_objects),
("Approach min frames", owner._gpr_object_approach_min_frames),
("Start MHz", owner._gpr_start_freq_mhz),
("Stop MHz", owner._gpr_stop_freq_mhz),
("Imaging plane Y m", owner._gpr_imaging_plane_y_m),
@@ -533,6 +542,7 @@ def build_processing_group(owner) -> QGroupBox:
owner._processing_mode_pages,
[
("Config mode", owner._legacy_gpr_config_mode),
("Relative permittivity", owner._gpr_relative_permittivity),
("Input positions", owner._legacy_gpr_input_positions_input),
("Output positions", owner._legacy_gpr_output_positions_input),
("Min depth m", owner._legacy_gpr_min_depth_m),
@@ -586,7 +596,7 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_angle_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_object_min_frac.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_motion_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
@@ -600,9 +610,9 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._gpr_imaging_plane_y_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
owner._gpr_min_visible_score.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
owner._gpr_object_approach_min_frames.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
@@ -86,12 +86,38 @@ def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"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:
+30 -1
View File
@@ -29,6 +29,12 @@ import numpy as np
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.
@@ -52,6 +58,8 @@ class RawSweep:
steps: np.ndarray
main: np.ndarray
reference: np.ndarray
combo: tuple[int, int] | None = None
dirty: bool = False
@property
def size(self) -> int:
@@ -66,13 +74,17 @@ class KamilAdcStreamParser:
but holds no I/O and is cheap to unit-test.
"""
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
__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."""
@@ -95,6 +107,10 @@ class KamilAdcStreamParser:
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}"
@@ -107,6 +123,8 @@ class KamilAdcStreamParser:
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.
@@ -122,6 +140,8 @@ class KamilAdcStreamParser:
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
@@ -130,12 +150,21 @@ class KamilAdcStreamParser:
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,
)
+54 -4
View File
@@ -47,6 +47,10 @@ _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)
@@ -54,6 +58,10 @@ class KamilAdcService:
"""Launch the external Kamil ADC 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
_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)
@@ -64,9 +72,18 @@ class KamilAdcService:
@property
def command(self) -> list[str]:
"""External collector command, including the generated ``tty:`` argument."""
"""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
return [str(self._resolve_executable()), *adc.args, f"tty:{adc.tty_path}"]
cmd = [str(self._resolve_executable()), *adc.args]
if self.switch_config_path is not None:
cmd.append(f"config:{self.switch_config_path}")
cmd.append(f"tty:{adc.tty_path}")
return cmd
def open(self, *, stop_event: threading.Event | None = None) -> None:
"""Launch the collector and start the TTY reader thread.
@@ -122,12 +139,17 @@ class KamilAdcService:
"""Kamil ADC has no runtime-readable sweep-limit API."""
raise RuntimeError("Kamil ADC device limits are not available")
def acquire(self) -> SweepResult:
def acquire(self, combo: tuple[int, int] | None = None) -> SweepResult:
"""Return the next sweep that covers the band, as S21 on the fixed grid.
Sweeps whose floated frequency range does not span the configured band are
rejected and the next sweep is read, until one passes or the sweep timeout
elapses (which then surfaces as a :class:`TimeoutError`).
When ``combo`` is given (switch-aware mode), only the clean sweep captured
under that switch combination is returned; the collector drives the switches
and tags each sweep. When ``None`` (calibration / non-switch mode), the
single newest sweep is returned regardless of combination.
"""
if self._processor is None:
raise RuntimeError("Kamil ADC service is not configured")
@@ -147,7 +169,10 @@ class KamilAdcService:
raise TimeoutError(
"Timed out waiting for a Kamil ADC sweep covering the configured band"
)
raw = self._reader.read_sweep(timeout_s=remaining_s, process=process)
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)
s21 = self._processor.process(raw.main, raw.reference)
if s21 is not None:
return SweepResult(
@@ -159,6 +184,31 @@ class KamilAdcService:
)
self._log_rejected_sweep(raw)
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:
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)
def read_raw_sweep(self) -> RawSweep:
"""Return the next raw (main, reference) sweep without any processing.
@@ -52,6 +52,10 @@ class KamilAdcTtyReader:
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
_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.
_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)
@@ -62,6 +66,7 @@ class KamilAdcTtyReader:
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
self._stop_event.clear()
self._latest_sweep = None
self._combo_slots = {}
self._reader_error = None
self._published_count = 0
self._thread = threading.Thread(
@@ -89,6 +94,7 @@ class KamilAdcTtyReader:
finally:
self._fd = None
self._latest_sweep = None
self._combo_slots = {}
self._reader_error = None
@property
@@ -131,6 +137,39 @@ class KamilAdcTtyReader:
)
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:
sweep = self._combo_slots.pop(combo, None)
if sweep is not 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 for combo {combo} "
f"after {float(timeout_s):.3f}s"
)
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
# ------------------------------------------------------------------
# Reader-thread internals
# ------------------------------------------------------------------
@@ -177,11 +216,22 @@ class KamilAdcTtyReader:
return chunk
def _publish_sweep(self, sweep: RawSweep) -> None:
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
"""Publish a completed sweep to the mailbox(es), waking any waiter.
Dirty sweeps (those that straddled a switch transition) are counted but not
delivered: the collector re-takes that combination on the next sweep. Clean
tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode)
only update the single newest-sweep mailbox that read_sweep() serves.
"""
with self._mailbox_cv:
self._latest_sweep = sweep
self._published_count += 1
self._mailbox_cv.notify()
if sweep.dirty:
self._mailbox_cv.notify_all()
return
self._latest_sweep = sweep
if sweep.combo is not None:
self._combo_slots[sweep.combo] = sweep
self._mailbox_cv.notify_all()
def _publish_error(self, exc: Exception) -> None:
"""Record ``exc`` as the reader fault and wake any waiter."""
@@ -0,0 +1,247 @@
# Контроль температуры при вариации тока лазера
Набор из трёх развязанных компонентов для автоматизации измерений в режиме
**вариации тока лазера 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 в консоль
```
- **Порт лазера эксклюзивен.** `starter` трогает его кратко, затем `monitor`
владеет им всё время. `checker` порт не трогает вовсе — читает только файлы.
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
останавливаются и перезапускаются независимо, без рукопожатия.
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
термисторам `temp_ext*`), каждый со своим допуском (по умолчанию `0.03 °C`).
## Быстрый старт (CLI, два процесса)
Терминал 1 — стартовать вариацию и мониторить температуру:
```bash
python -m python_app.scripts.laser_temp_monitor \
--config run_config.json \
--start
```
Терминал 2 — валидировать температуру и печатать предупреждения:
```bash
python -m python_app.scripts.laser_temp_checker
```
Пример вывода чекера при расхождении и возврате в допуск:
```
WARNING laser_temp_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 laser_temp_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` | Серийный порт лазерной платы (пусто → автоопределение) |
| `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
python -m python_app.scripts.laser_temp_monitor --config run_config.json
```
Фиксированный период вместо расчётного (напр. раз в 500 мс):
```bash
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
# монитор
python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
# чекер
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)), снимок сессии
`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)
threading.Thread(target=monitor.run, args=(stop,), daemon=True).start()
# 3. Чекер: тайлить показания и валидировать оба лазера
checker = LaserTemperatureChecker.from_session(session)
reader = ReadingReader("readings.jsonl")
while not stop.is_set():
for reading in reader.poll():
checker.process(reading) # печатает WARNING при |Δ| > tolerance
stop.wait(0.2)
```
`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
python -m pytest python_app/tests/test_laser_temp_monitoring.py -q
```
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
расчёт периода свипа, маппинг измерений монитором, и валидация чекера по каждому
лазеру отдельно (порог, граница допуска, повторные предупреждения, восстановление).
@@ -0,0 +1,36 @@
"""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",
]
@@ -0,0 +1,139 @@
"""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,
)
@@ -0,0 +1,142 @@
"""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 _MeasurementSource(Protocol):
"""Minimal controller surface the monitor depends on (eases testing)."""
def get_measurements(self) -> object: ...
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: object) -> Optional[float]:
return None if value is None else float(value)
@@ -0,0 +1,131 @@
"""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: object) -> 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
@@ -0,0 +1,75 @@
"""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
_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.
"""
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_name(f"{dest.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(asdict(self), indent=2), encoding="utf-8")
os.replace(tmp, dest)
return dest
@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
+15 -15
View File
@@ -282,10 +282,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.gpr.range_comp_power,
"gui.processing.gpr",
),
angle_comp_power=_optional_float(
object_min_frac=_optional_float(
gpr_object,
"angle_comp_power",
gui.processing.gpr.angle_comp_power,
"object_min_frac",
gui.processing.gpr.object_min_frac,
"gui.processing.gpr",
),
score_mode=_optional_string(
@@ -336,6 +336,12 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.gpr.draw_top_m_objects,
"gui.processing.gpr",
),
object_approach_min_frames=_optional_int(
gpr_object,
"object_approach_min_frames",
gui.processing.gpr.object_approach_min_frames,
"gui.processing.gpr",
),
start_freq_mhz=_optional_float(
gpr_object,
"start_freq_mhz",
@@ -378,12 +384,6 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.gpr.render_mode,
"gui.processing.gpr",
),
min_visible_score=_optional_float(
gpr_object,
"min_visible_score",
gui.processing.gpr.min_visible_score,
"gui.processing.gpr",
),
visible_x_min_m=_optional_float(
gpr_object,
"visible_x_min_m",
@@ -473,14 +473,14 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
)
if gui.processing.gpr.range_comp_power < 0.0:
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
if gui.processing.gpr.angle_comp_power < 0.0:
raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0")
if gui.processing.gpr.min_visible_score < 0.0:
raise ValueError("gui.processing.gpr.min_visible_score must be >= 0")
if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0:
raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]")
if gui.processing.gpr.max_detected_objects_to_draw < 0:
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
if gui.processing.gpr.draw_top_m_objects < 0:
raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0")
if gui.processing.gpr.object_approach_min_frames < 1:
raise ValueError("gui.processing.gpr.object_approach_min_frames must be >= 1")
if gui.processing.legacy_gpr.comp_power < 0.0:
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
if gui.processing.legacy_gpr.snr_thresh < 0.0:
@@ -593,7 +593,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"min_depth_m": gui.processing.gpr.min_depth_m,
"max_depth_m": gui.processing.gpr.max_depth_m,
"range_comp_power": gui.processing.gpr.range_comp_power,
"angle_comp_power": gui.processing.gpr.angle_comp_power,
"object_min_frac": gui.processing.gpr.object_min_frac,
"score_mode": gui.processing.gpr.score_mode,
"motion_mode": gui.processing.gpr.motion_mode,
"look_angle_deg": gui.processing.gpr.look_angle_deg,
@@ -602,6 +602,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"ignore_socket_speed_enabled": gui.processing.gpr.ignore_socket_speed_enabled,
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
"draw_top_m_objects": gui.processing.gpr.draw_top_m_objects,
"object_approach_min_frames": gui.processing.gpr.object_approach_min_frames,
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
@@ -609,7 +610,6 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"remove_sidelobe_objects_enabled": gui.processing.gpr.remove_sidelobe_objects_enabled,
"imaging_plane_y_m": gui.processing.gpr.imaging_plane_y_m,
"render_mode": gui.processing.gpr.render_mode,
"min_visible_score": gui.processing.gpr.min_visible_score,
"visible_x_min_m": gui.processing.gpr.visible_x_min_m,
"visible_x_max_m": gui.processing.gpr.visible_x_max_m,
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
+7 -2
View File
@@ -63,7 +63,10 @@ class GuiGprStateModel:
min_depth_m: float = 2.0
max_depth_m: float = 14.0
range_comp_power: float = 0.1
angle_comp_power: float = 0.0
# BP object-detection stop level, fraction of the global peak (Horns_motion_3libre.py
# BP_OBJECT_MIN_FRAC). Angle compensation and permittivity are fixed for coherent BP
# (Python 0.3 block), so they are not exposed here.
object_min_frac: float = 0.7
score_mode: str = "combined"
motion_mode: str = "int_minus"
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
@@ -75,6 +78,9 @@ class GuiGprStateModel:
ignore_socket_speed_enabled: bool = False
max_detected_objects_to_draw: int = 5
draw_top_m_objects: int = 2
# Cross-frame approach filter: show an object only after it persists as a
# motion-consistent track this many consecutive frames (<= 1 disables it).
object_approach_min_frames: int = 3
start_freq_mhz: float = 3000.0
stop_freq_mhz: float = 6000.0
background_subtract_enabled: bool = True
@@ -82,7 +88,6 @@ class GuiGprStateModel:
remove_sidelobe_objects_enabled: bool = True
imaging_plane_y_m: float = 0.0
render_mode: str = "heatmap"
min_visible_score: float = 0.0
visible_x_min_m: float = -2.0
visible_x_max_m: float = 2.0
visible_z_min_m: float = 0.0
+6
View File
@@ -341,6 +341,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> 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)
@@ -552,6 +557,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"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,
+3
View File
@@ -118,6 +118,9 @@ 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)
@@ -32,8 +32,8 @@ class ProcessingLiveConfig:
gpr_min_depth_m: float = 2.0
gpr_max_depth_m: float = 14.0
gpr_range_comp_power: float = 0.1
gpr_angle_comp_power: float = 0.0
gpr_comp_power: float = 0.2
gpr_object_min_frac: float = 0.7
gpr_score_mode: str = "combined"
# Backprojection intra-sweep speed-correction mode: "int_minus" (full
# correction) or "int_focus" (focusing residual only). Mirrors Python
@@ -41,6 +41,7 @@ class ProcessingLiveConfig:
gpr_motion_mode: str = "int_minus"
gpr_max_detected_objects_to_draw: int = 5
gpr_draw_top_m_objects: int = 2
gpr_object_approach_min_frames: int = 3
gpr_speed_m_s: float = 0.0
gpr_look_angle_deg: float = 0.0
# Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips
@@ -61,8 +62,9 @@ class ProcessingLiveConfig:
gpr_background_mean_count: int = 10
gpr_remove_sidelobe_objects_enabled: bool = True
gpr_imaging_plane_y_m: float = 0.0
# Locator filter parameters consumed by the C++ TCP locator server.
gpr_min_visible_score: float = 0.0
# Locator filter parameter consumed by the C++ TCP locator server. Coherent BP
# objects are already finalized in the processor (no score threshold); only legacy
# GPR still thresholds, on a pair count.
legacy_gpr_min_visible_pair_count: float = 0.0
# Visible X/Z window (metres). The locator and the desktop plot both clip
# detected objects to this window, so the socket broadcasts only what is shown.
@@ -109,12 +111,13 @@ class ProcessingLiveConfig:
"gpr_min_depth_m": float(self.gpr_min_depth_m),
"gpr_max_depth_m": float(self.gpr_max_depth_m),
"gpr_range_comp_power": float(self.gpr_range_comp_power),
"gpr_angle_comp_power": float(self.gpr_angle_comp_power),
"gpr_comp_power": float(self.gpr_comp_power),
"gpr_object_min_frac": float(self.gpr_object_min_frac),
"gpr_score_mode": str(self.gpr_score_mode),
"gpr_motion_mode": str(self.gpr_motion_mode),
"gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw),
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
"gpr_object_approach_min_frames": int(self.gpr_object_approach_min_frames),
"gpr_speed_m_s": float(self.gpr_speed_m_s),
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
"gpr_direction_sign": float(self.gpr_direction_sign),
@@ -128,7 +131,6 @@ class ProcessingLiveConfig:
"gpr_background_mean_count": int(self.gpr_background_mean_count),
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
"gpr_min_visible_score": float(self.gpr_min_visible_score),
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
+57 -25
View File
@@ -36,8 +36,8 @@ _OPEN_RETRY_LOG_EVERY = 30
def _open_radar_with_retry(
config: RunConfigModel,
radar: KamilAdcService,
input_switch: SwitchService,
output_switch: SwitchService,
input_switch: SwitchService | None,
output_switch: SwitchService | None,
stop_requested: threading.Event,
) -> bool:
"""Open+configure the radar and both switches, retrying forever until stop.
@@ -48,14 +48,19 @@ def _open_radar_with_retry(
relaunched collector starts clean. Returns ``True`` once everything is open, or
``False`` if a stop was requested before the device became available. Backoff is
capped and every wait is interruptible by SIGTERM.
``input_switch``/``output_switch`` are ``None`` in switch-aware mode, where the
collector owns the GPIO lines and the producer must not open them.
"""
# Tear down any prior open first: open()/switch.open() are idempotent no-ops
# while still "open", so a mid-run reconnect must close them to force a fresh
# collector relaunch and TTY re-attach.
with suppress(Exception):
input_switch.close()
with suppress(Exception):
output_switch.close()
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if output_switch is not None:
with suppress(Exception):
output_switch.close()
with suppress(Exception):
radar.close()
@@ -65,15 +70,19 @@ def _open_radar_with_retry(
try:
radar.open(stop_event=stop_requested)
radar.configure(config.radar.sweep)
output_switch.open()
input_switch.open()
if output_switch is not None:
output_switch.open()
if input_switch is not None:
input_switch.open()
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
# Drop any partial open (collector process, TTY reader, switches)
# before the next attempt so the relaunch starts from a clean state.
with suppress(Exception):
input_switch.close()
with suppress(Exception):
output_switch.close()
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if output_switch is not None:
with suppress(Exception):
output_switch.close()
with suppress(Exception):
radar.close()
attempt += 1
@@ -136,9 +145,26 @@ def main() -> int:
"Opened SHM ring writers: raw=%s, raw_tap=%s",
config.rings.raw.name, config.rings.raw_tap.name,
)
radar = KamilAdcService(config)
input_switch = SwitchService.from_model(config.input_switch)
output_switch = SwitchService.from_model(config.output_switch)
# 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.
collector_driven = (
config.input_switch.driver_mode == "native"
and config.output_switch.driver_mode == "native"
)
radar = KamilAdcService(
config,
switch_config_path=str(args.config) if collector_driven else None,
)
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",
)
try:
if not _open_radar_with_retry(config, radar, input_switch, output_switch, stop_requested):
@@ -155,12 +181,16 @@ def main() -> int:
for combo in config.combos:
if stop_requested.is_set():
break
output_switch.switch_to(combo.output)
input_switch.switch_to(combo.input)
if config.runtime.settling_ms > 0:
time.sleep(config.runtime.settling_ms / 1000.0)
sweep = radar.acquire()
if collector_driven:
# The collector already switched and tagged the sweep; just
# read the clean capture for this combination.
sweep = radar.acquire(combo=(combo.input, combo.output))
else:
output_switch.switch_to(combo.output)
input_switch.switch_to(combo.input)
if config.runtime.settling_ms > 0:
time.sleep(config.runtime.settling_ms / 1000.0)
sweep = radar.acquire()
traces.append(
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
@@ -222,10 +252,12 @@ def main() -> int:
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
collection_id += 1
finally:
with suppress(Exception):
output_switch.close()
with suppress(Exception):
input_switch.close()
if output_switch is not None:
with suppress(Exception):
output_switch.close()
if input_switch is not None:
with suppress(Exception):
input_switch.close()
with suppress(Exception):
radar.close()
raw_tap_writer.close()
+78
View File
@@ -0,0 +1,78 @@
"""Standalone laser temperature checker process.
Reads the target setpoints frozen at variation start and tails the JSONL readings
channel produced by the monitor. For every reading it validates both lasers and
prints a console warning whenever a measured temperature drifts from its target by
more than the tolerance (default 0.03 °C). Never touches the serial port, so it is
fully independent of the monitor and can be started/stopped at any time.
Example::
python -m python_app.scripts.laser_temp_checker
python -m python_app.scripts.laser_temp_checker --session s.json --readings r.jsonl
"""
from __future__ import annotations
import argparse
import logging
import signal
import threading
from pathlib import Path
from python_app.hardware_full.laser_control.monitoring import (
DEFAULT_READINGS_PATH,
DEFAULT_SESSION_PATH,
LaserTemperatureChecker,
LaserVariationSession,
ReadingReader,
)
logger = logging.getLogger("laser_temp_checker")
_POLL_INTERVAL_S = 0.2
def main() -> int:
parser = argparse.ArgumentParser(description="Validate laser temperature against setpoints")
parser.add_argument("--session", type=Path, default=DEFAULT_SESSION_PATH,
help="Session snapshot with target setpoints + tolerance")
parser.add_argument("--readings", type=Path, default=DEFAULT_READINGS_PATH,
help="JSONL readings channel to tail")
parser.add_argument("--tolerance", type=float, default=None,
help="Override tolerance in °C (default: from session)")
parser.add_argument("--reminder-every", type=int, default=0,
help="Repeat a warning every N readings while off target (0=off)")
parser.add_argument("--from-start", action="store_true",
help="Validate the whole readings file, not just new lines")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
session = LaserVariationSession.load(args.session)
if args.tolerance is not None:
session.tolerance_c = args.tolerance
checker = LaserTemperatureChecker.from_session(session, reminder_every=args.reminder_every)
logger.info(
"Checking against T1=%.3f T2=%.3f °C, tolerance ±%.3f °C (%s)",
session.target_temp1, session.target_temp2, session.tolerance_c, session.variation_type,
)
reader = ReadingReader(args.readings, from_start=args.from_start)
stop_event = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_event.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
while not stop_event.is_set():
for reading in reader.poll():
checker.process(reading)
stop_event.wait(_POLL_INTERVAL_S)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+149
View File
@@ -0,0 +1,149 @@
"""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_model 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())
@@ -8,6 +8,7 @@ import unittest
import numpy as np
from python_app.hardware_full.kamil_adc.protocol import (
COMBO_MARKER,
MAIN_MARKER,
REFERENCE_MARKER,
KamilAdcStreamParser,
@@ -18,6 +19,10 @@ 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)
@@ -140,6 +145,40 @@ class KamilAdcStreamParserTest(unittest.TestCase):
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())
+99 -1
View File
@@ -16,7 +16,11 @@ 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 MAIN_MARKER, REFERENCE_MARKER
from python_app.hardware_full.kamil_adc.protocol import (
COMBO_MARKER,
MAIN_MARKER,
REFERENCE_MARKER,
)
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.process_supervisor import ProcessSupervisor
@@ -33,6 +37,10 @@ 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."""
@@ -127,6 +135,41 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
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:
@@ -205,6 +248,61 @@ class KamilAdcConfigTest(unittest.TestCase):
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."""
import types
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)
service._reader = types.SimpleNamespace(published_count=10) # type: ignore[assignment]
service._process = types.SimpleNamespace(poll=lambda: None) # type: ignore[assignment]
# Each poll-sleep advances the published count, as the reader thread would.
def _advance(_seconds: float) -> None:
service._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(service._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"
@@ -0,0 +1,223 @@
"""Tests for the laser current-variation temperature monitoring package."""
from __future__ import annotations
import tempfile
import threading
import unittest
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)
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()
class _OneShotWriter:
def __init__(self) -> None:
self.written: List[TemperatureReading] = []
def write(self, reading: TemperatureReading) -> None:
self.written.append(reading)
stop.set() # stop after the first publish
writer = _OneShotWriter()
monitor = LaserTemperatureMonitor(controller, writer, period_s=0.0)
monitor.run(stop)
self.assertEqual(len(writer.written), 1)
self.assertEqual(writer.written[0].seq, 0)
class CheckerTest(unittest.TestCase):
def _checker(self, **kwargs: object) -> LaserTemperatureChecker:
return LaserTemperatureChecker(target_temp1=28.0, target_temp2=28.9,
tolerance_c=0.03, **kwargs)
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()
+2 -2
View File
@@ -161,8 +161,8 @@ class WebControllerTest(unittest.TestCase):
def test_known_field_emits_and_returns_snapshot(self) -> None:
received: list[dict] = []
self.controller.apply_settings_requested.connect(received.append)
out = self.controller.apply_live_settings({"gpr_min_visible_score": 0.5})
self.assertEqual(received, [{"gpr_min_visible_score": 0.5}])
out = self.controller.apply_live_settings({"gpr_object_min_frac": 0.5})
self.assertEqual(received, [{"gpr_object_min_frac": 0.5}])
self.assertIsInstance(out, list)
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
@@ -183,6 +183,16 @@ class SequentialCaptureSession:
if self._config.runtime.settling_ms > 0:
time.sleep(self._config.runtime.settling_ms / 1000.0)
# A free-running streaming radar (Kamil ADC) keeps a sweep captured in the
# previous combination buffered, and may have a transition-straddling sweep
# in flight. Drop those so this capture holds data from the new switch state
# — otherwise the trace is labelled with this combo but carries the previous
# one's data (an off-by-one across the sequence). Discrete radars (LibreVNA)
# acquire a fresh sweep per call and expose no such method, so skip them.
drain_after_switch = getattr(self._radar, "drain_after_switch", None)
if callable(drain_after_switch):
drain_after_switch()
sweep_traces: list[TraceData] = []
for _ in range(self._median_sweep_count):
sweep = self._radar.acquire()