Merge branch 'laser-temp-monitoring' into feature/switched-matrix-radar
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user