web UI added and refactoring done
This commit is contained in:
@@ -29,6 +29,7 @@ from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
||||
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
||||
from python_app.gui.controllers.app_window_snapshot_mixin import AppWindowSnapshotMixin
|
||||
from python_app.gui.controllers.app_window_ui_mixin import AppWindowUiMixin
|
||||
from python_app.gui.controllers.app_window_web_mixin import AppWindowWebMixin
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.models.gui_profile_model import GuiProfileModel
|
||||
@@ -53,6 +54,7 @@ class AppWindow(
|
||||
AppWindowPipelineMixin,
|
||||
AppWindowSnapshotMixin,
|
||||
AppWindowControlButtonMixin,
|
||||
AppWindowWebMixin,
|
||||
QMainWindow,
|
||||
):
|
||||
"""Top-level window coordinating GUI state and acquisition runtime."""
|
||||
@@ -288,6 +290,7 @@ class AppWindow(
|
||||
self._timer.start()
|
||||
self._maybe_auto_start_pipeline()
|
||||
self._start_control_button_watcher()
|
||||
self._init_web_ui()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self._install_headless_watchdog()
|
||||
|
||||
@@ -629,6 +632,8 @@ class AppWindow(
|
||||
return
|
||||
self._closing = True
|
||||
try:
|
||||
# 0) Stop the web server first so a late request cannot start work.
|
||||
self._shutdown_web_ui()
|
||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||
self._stop_control_button_watcher()
|
||||
self._resume_pipeline_after_capture = False
|
||||
|
||||
@@ -3,10 +3,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
_GPR_MODES = ("gpr", "legacy_gpr")
|
||||
|
||||
|
||||
def _is_legacy_gpr(window) -> bool:
|
||||
return window._processing_mode.currentText() == "legacy_gpr"
|
||||
|
||||
|
||||
def _attr(name: str):
|
||||
"""Widget getter for a field backed by a single fixed widget."""
|
||||
return lambda window: getattr(window, name)
|
||||
|
||||
|
||||
def _dual(gpr_attr: str, legacy_attr: str):
|
||||
"""Widget getter for a gpr_* field with separate gpr / legacy_gpr widgets."""
|
||||
return lambda window: getattr(window, legacy_attr if _is_legacy_gpr(window) else gpr_attr)
|
||||
|
||||
|
||||
# The ONE authoritative field <-> widget map (inverse of `_live_processing_config`),
|
||||
# plus each field's display group and the processor mode(s) it applies to. Everything
|
||||
# else the web form needs — input type, combobox options, numeric ranges, the current
|
||||
# value, the enabled state — is read live FROM these widgets by `_build_web_settings_schema`,
|
||||
# so the web hardcodes none of it and always mirrors the desktop. `modes=None` => all modes.
|
||||
# Entries: (field, group, modes, widget_getter).
|
||||
_WEB_LIVE_SCHEMA = [
|
||||
("processor_mode", "Processor", None, _attr("_processing_mode")),
|
||||
("pass_through_fixed_y_enabled", "Pass-through", ("pass_through",), _attr("_pass_through_fixed_y_enabled")),
|
||||
("pass_through_y_min_db", "Pass-through", ("pass_through",), _attr("_pass_through_y_min_db")),
|
||||
("pass_through_y_max_db", "Pass-through", ("pass_through",), _attr("_pass_through_y_max_db")),
|
||||
("bscan_axis", "B-scan", ("bscan",), _attr("_bscan_axis")),
|
||||
("bscan_cut_m", "B-scan", ("bscan",), _attr("_bscan_cut_m")),
|
||||
("bscan_max_depth_m", "B-scan", ("bscan",), _attr("_bscan_max_depth_m")),
|
||||
("bscan_gain", "B-scan", ("bscan",), _attr("_bscan_gain")),
|
||||
("bscan_start_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_start_freq_mhz")),
|
||||
("bscan_stop_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_stop_freq_mhz")),
|
||||
("legacy_gpr_mode", "Mode", ("legacy_gpr",), _attr("_legacy_gpr_config_mode")),
|
||||
("gpr_input_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_input_positions_input", "_legacy_gpr_input_positions_input")),
|
||||
("gpr_output_positions", "Geometry & depth", _GPR_MODES, _dual("_gpr_output_positions_input", "_legacy_gpr_output_positions_input")),
|
||||
("gpr_min_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_min_depth_m", "_legacy_gpr_min_depth_m")),
|
||||
("gpr_max_depth_m", "Geometry & depth", _GPR_MODES, _dual("_gpr_max_depth_m", "_legacy_gpr_max_depth_m")),
|
||||
("gpr_start_freq_mhz", "Geometry & depth", _GPR_MODES, _dual("_gpr_start_freq_mhz", "_legacy_gpr_start_freq_mhz")),
|
||||
("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_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_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")),
|
||||
("legacy_gpr_min_visible_pair_count", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_min_visible_pair_count")),
|
||||
("gpr_look_angle_deg", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_look_angle_deg")),
|
||||
("gpr_apply_freq_phase_correction", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_apply_freq_phase_correction")),
|
||||
("gpr_reference_mode", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_reference_mode")),
|
||||
("ignore_socket_speed", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_ignore_socket_speed_enabled")),
|
||||
("gpr_speed_m_s", "Motion", ("legacy_gpr",), _attr("_legacy_gpr_speed_m_s")),
|
||||
]
|
||||
_WEB_LIVE_GETTERS = {field: getter for field, _group, _modes, getter in _WEB_LIVE_SCHEMA}
|
||||
|
||||
|
||||
def _web_field_schema(field: str, group: str, widget) -> dict | None:
|
||||
"""Describe one widget for the web form (type, options/range, value, enabled)."""
|
||||
base = {"name": field, "group": group, "enabled": bool(widget.isEnabled())}
|
||||
if isinstance(widget, QComboBox):
|
||||
return {**base, "kind": "select", "value": widget.currentText(),
|
||||
"options": [widget.itemText(i) for i in range(widget.count())]}
|
||||
if isinstance(widget, QCheckBox):
|
||||
return {**base, "kind": "bool", "value": bool(widget.isChecked())}
|
||||
if isinstance(widget, QSpinBox):
|
||||
return {**base, "kind": "int", "value": int(widget.value()),
|
||||
"min": int(widget.minimum()), "max": int(widget.maximum()), "step": int(widget.singleStep())}
|
||||
if isinstance(widget, QDoubleSpinBox):
|
||||
return {**base, "kind": "float", "value": float(widget.value()),
|
||||
"min": float(widget.minimum()), "max": float(widget.maximum()),
|
||||
"step": float(widget.singleStep()), "decimals": int(widget.decimals())}
|
||||
if isinstance(widget, QLineEdit):
|
||||
return {**base, "kind": "text", "value": widget.text()}
|
||||
return None
|
||||
|
||||
|
||||
def _build_web_settings_schema(window) -> list[dict]:
|
||||
"""Build the settings schema for the active mode straight from the Qt widgets."""
|
||||
mode = window._processing_mode.currentText()
|
||||
schema: list[dict] = []
|
||||
for field, group, modes, getter in _WEB_LIVE_SCHEMA:
|
||||
if modes is not None and mode not in modes:
|
||||
continue
|
||||
entry = _web_field_schema(field, group, getter(window))
|
||||
if entry is not None:
|
||||
schema.append(entry)
|
||||
return schema
|
||||
|
||||
|
||||
def _set_web_live_field(window, field: str, value) -> None:
|
||||
"""Write one web value into the desktop widget that feeds ``field``."""
|
||||
getter = _WEB_LIVE_GETTERS.get(field)
|
||||
if getter is None:
|
||||
return
|
||||
widget = getter(window)
|
||||
if isinstance(widget, QComboBox):
|
||||
window._set_combo_current_text(widget, str(value))
|
||||
elif isinstance(widget, QCheckBox):
|
||||
widget.setChecked(bool(value))
|
||||
elif isinstance(widget, QSpinBox):
|
||||
widget.setValue(int(float(value)))
|
||||
elif isinstance(widget, QDoubleSpinBox):
|
||||
widget.setValue(float(value))
|
||||
elif isinstance(widget, QLineEdit):
|
||||
widget.setText(",".join(str(int(v)) for v in value) if isinstance(value, list) else str(value))
|
||||
|
||||
|
||||
class AppWindowLiveProcessingMixin:
|
||||
"""Handle live processing updates, redraws, and locator republishing."""
|
||||
@@ -55,7 +170,8 @@ class AppWindowLiveProcessingMixin:
|
||||
|
||||
y_min_db = float(self._pass_through_y_min_db.value())
|
||||
y_max_db = float(self._pass_through_y_max_db.value())
|
||||
return ProcessingLiveConfig(
|
||||
visible_x_min, visible_x_max, visible_z_min, visible_z_max = self._gpr_visible_bounds()
|
||||
config = ProcessingLiveConfig(
|
||||
processor_mode=mode,
|
||||
pass_through_channel="s21",
|
||||
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||
@@ -95,11 +211,16 @@ class AppWindowLiveProcessingMixin:
|
||||
gpr_imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
||||
gpr_min_visible_score=float(self._gpr_min_visible_score.value()),
|
||||
legacy_gpr_min_visible_pair_count=float(self._legacy_gpr_min_visible_pair_count.value()),
|
||||
gpr_visible_x_min_m=visible_x_min,
|
||||
gpr_visible_x_max_m=visible_x_max,
|
||||
gpr_visible_z_min_m=visible_z_min,
|
||||
gpr_visible_z_max_m=visible_z_max,
|
||||
ignore_socket_speed=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
|
||||
reprocess_current_result=bool(reprocess_current_result),
|
||||
history_command_seq=int(self._history_command_seq),
|
||||
history_command=str(history_command),
|
||||
)
|
||||
return config
|
||||
|
||||
def _write_live_processing_config(
|
||||
self,
|
||||
@@ -118,8 +239,50 @@ class AppWindowLiveProcessingMixin:
|
||||
)
|
||||
)
|
||||
|
||||
def _apply_web_live_settings(self, fields: dict) -> None:
|
||||
"""Apply web-requested live settings through the SAME path as a desktop edit.
|
||||
|
||||
Writes the values into the desktop widgets, then runs the single
|
||||
``_on_processing_live_settings_changed`` handler — one writer, one redraw —
|
||||
so the browser and the desktop never desync and ``processing_live.json`` has
|
||||
exactly one author. The per-widget change signals are neutralized by a
|
||||
suppression flag so the final handler runs once instead of dozens of times;
|
||||
a history command keeps its own server-managed sequence-bump path.
|
||||
"""
|
||||
try:
|
||||
history_command = str(fields.get("history_command", "none"))
|
||||
settings = {
|
||||
name: value
|
||||
for name, value in fields.items()
|
||||
if name not in {"history_command", "history_command_seq"}
|
||||
}
|
||||
self._suppress_live_settings_handler = True
|
||||
try:
|
||||
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
|
||||
# legacy_gpr widget based on the active mode.
|
||||
if "processor_mode" in settings:
|
||||
_set_web_live_field(self, "processor_mode", settings.pop("processor_mode"))
|
||||
for name, value in settings.items():
|
||||
_set_web_live_field(self, name, value)
|
||||
finally:
|
||||
self._suppress_live_settings_handler = False
|
||||
|
||||
if history_command in {"clear_all", "remove_last"}:
|
||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||
self._on_processing_live_settings_changed()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to apply web live settings", exc)
|
||||
|
||||
def _web_settings_schema(self) -> list[dict]:
|
||||
"""Return the live-settings schema for the active mode (read from widgets)."""
|
||||
return _build_web_settings_schema(self)
|
||||
|
||||
def _on_processing_live_settings_changed(self, *_args) -> None:
|
||||
"""Handle live-processing setting changes and trigger redraw when needed."""
|
||||
# Web-apply sets many widgets at once; their individual change signals are
|
||||
# suppressed so this writer+redraw runs exactly once at the end.
|
||||
if getattr(self, "_suppress_live_settings_handler", False):
|
||||
return
|
||||
try:
|
||||
current_mode = self._processing_mode.currentText()
|
||||
if self._is_gpr_processing_mode(current_mode):
|
||||
|
||||
@@ -22,6 +22,7 @@ from python_app.orchestration.preprocess_assets import (
|
||||
preprocess_asset_model,
|
||||
runtime_preprocess_asset_keys,
|
||||
)
|
||||
from python_app.orchestration.restart_policy import RestartPolicy
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
|
||||
|
||||
@@ -36,6 +37,15 @@ class AppWindowPipelineMixin:
|
||||
# a wedged reader cannot stay silently broken forever.
|
||||
_READER_ERROR_RECONNECT_AT = 40
|
||||
_READER_ERROR_STOP_AT = 400
|
||||
# If a pipeline child exits unexpectedly while the run should be live, relaunch
|
||||
# the whole pipeline from the last-written runtime config. It retries FOREVER with
|
||||
# capped back-off (this is an unattended appliance — it must keep trying to come
|
||||
# back, never permanently stop); the failure streak resets once a healthy poll sees
|
||||
# data. The "if it breaks it comes back up" contract holds in BOTH GUI and headless.
|
||||
_RESTART_POLICY = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0)
|
||||
# Safety backstop so a wedged/dropping processor cannot leave a single capture
|
||||
# polling forever with no completion and no error. Generous, not a tight deadline.
|
||||
_SINGLE_CAPTURE_TIMEOUT_S = 300.0
|
||||
|
||||
def _processor_requires_restart(self, run_signature: tuple[object, ...]) -> bool:
|
||||
"""Return whether alive `data_processor` was started with different stable run settings."""
|
||||
@@ -148,6 +158,12 @@ class AppWindowPipelineMixin:
|
||||
self._drop_pending_ring_payloads(include_results=True)
|
||||
self._last_reader_error_signature = None
|
||||
self._reader_error_repeat_count = 0
|
||||
# Record what to relaunch if a child later dies unexpectedly. Only a
|
||||
# continuous run auto-restarts; a single capture is bounded by its deadline.
|
||||
self._active_run_config = config
|
||||
self._active_run_config_path = config_path
|
||||
self._pipeline_should_run = not single_capture
|
||||
self._pipeline_restart_count = 0
|
||||
if single_capture:
|
||||
self._single_capture_start_ns = time.monotonic_ns()
|
||||
|
||||
@@ -246,6 +262,7 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _stop_run(self) -> None:
|
||||
"""Stop acquisition-side processes and close readers as needed."""
|
||||
self._pipeline_should_run = False # an explicit stop disables crash auto-restart
|
||||
was_running = self._supervisor.is_running()
|
||||
if was_running:
|
||||
self._supervisor.stop_orchestrator()
|
||||
@@ -271,6 +288,7 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _stop_all_processes(self) -> None:
|
||||
"""Stop all managed pipeline processes and close all readers."""
|
||||
self._pipeline_should_run = False # an explicit stop disables crash auto-restart
|
||||
was_running = self._supervisor.is_running() or self._supervisor.is_processor_running()
|
||||
self._supervisor.stop_all()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
@@ -299,12 +317,8 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _poll_rings(self) -> None:
|
||||
"""Poll readers, ingest history, and trigger rendering."""
|
||||
for report in self._supervisor.collect_exit_reports():
|
||||
if report.level == "INFO":
|
||||
self._log(report.format())
|
||||
continue
|
||||
self._status_label.setText("Status: error")
|
||||
self._log_error(report.format())
|
||||
self._web_update_snapshot() # guarded internally; never raises
|
||||
self._handle_process_exit_reports()
|
||||
|
||||
try:
|
||||
if self._raw_reader is not None:
|
||||
@@ -318,9 +332,13 @@ class AppWindowPipelineMixin:
|
||||
if self._single_capture_active:
|
||||
if self._finish_single_capture_if_ready():
|
||||
return
|
||||
self._check_single_capture_deadline()
|
||||
return
|
||||
|
||||
if result_latest is not None:
|
||||
# Genuine data flowed: the pipeline is healthy, so reset the
|
||||
# crash-storm budget (it only caps consecutive crash-restarts).
|
||||
self._pipeline_restart_count = 0
|
||||
render_started_ns = time.monotonic_ns()
|
||||
self._draw_preferred_collection(result_latest=result_latest)
|
||||
self._pipeline_metrics.record(
|
||||
@@ -331,6 +349,88 @@ class AppWindowPipelineMixin:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._handle_reader_poll_error(exc)
|
||||
|
||||
def _handle_process_exit_reports(self) -> None:
|
||||
"""Log child exits and auto-restart the pipeline on an unexpected death.
|
||||
|
||||
Runs first in the poll tick and is fully guarded: it must never raise, or
|
||||
it would abort the Qt slot. An unexpected (non-clean) exit while the run is
|
||||
meant to be live triggers a bounded relaunch — in both GUI and headless.
|
||||
"""
|
||||
unexpected = False
|
||||
try:
|
||||
for report in self._supervisor.collect_exit_reports():
|
||||
if report.level == "INFO":
|
||||
self._log(report.format())
|
||||
continue
|
||||
self._status_label.setText("Status: error")
|
||||
self._log_error(report.format())
|
||||
unexpected = True
|
||||
except Exception as exc: # noqa: BLE001 - the poll tick must survive this
|
||||
self._log_exception("Failed to collect process exit reports", exc, level="ERROR")
|
||||
return
|
||||
|
||||
if unexpected and getattr(self, "_pipeline_should_run", False):
|
||||
self._recover_pipeline_after_crash()
|
||||
|
||||
def _recover_pipeline_after_crash(self) -> None:
|
||||
"""Relaunch the pipeline after an unexpected child exit (GUI and headless).
|
||||
|
||||
Re-spawns from the already-written runtime config — no widgets, no dialogs,
|
||||
no re-validation — so it is safe to call from the poll tick. Retries forever
|
||||
with capped back-off (never gives up); a healthy poll resets the failure streak.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
consecutive_failures = getattr(self, "_pipeline_restart_count", 0)
|
||||
if not self._RESTART_POLICY.should_restart_now(
|
||||
now_s=now,
|
||||
last_restart_s=getattr(self, "_last_pipeline_restart_s", 0.0),
|
||||
consecutive_failures=consecutive_failures,
|
||||
):
|
||||
return # still inside the current back-off window; let it settle
|
||||
self._last_pipeline_restart_s = now
|
||||
self._pipeline_restart_count = consecutive_failures + 1
|
||||
|
||||
config = getattr(self, "_active_run_config", None)
|
||||
config_path = getattr(self, "_active_run_config_path", None)
|
||||
if config is None or config_path is None:
|
||||
self._pipeline_should_run = False
|
||||
self._log_error("Cannot auto-restart pipeline: no active run configuration recorded.")
|
||||
return
|
||||
|
||||
self._log_error(
|
||||
"Pipeline process exited unexpectedly; restarting "
|
||||
f"(attempt {self._pipeline_restart_count}, next back-off "
|
||||
f"{self._RESTART_POLICY.backoff_for(self._pipeline_restart_count):.0f}s)."
|
||||
)
|
||||
try:
|
||||
# Note: do NOT drain rings here — draining pumps the Qt event loop, which
|
||||
# would re-enter _poll_rings mid-restart (and reset the crash-storm count).
|
||||
self._supervisor.stop_all()
|
||||
self._close_readers(keep_results=False)
|
||||
self._supervisor.start(config_path, allow_clean_orchestrator_exit=False)
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
|
||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||
self._processor_run_signature = self._build_processor_run_signature(config)
|
||||
self._drop_pending_ring_payloads(include_results=True)
|
||||
self._status_label.setText("Status: running")
|
||||
self._log("Pipeline auto-restarted after crash.")
|
||||
except Exception as exc: # noqa: BLE001 - retry on the next crash signal
|
||||
self._log_exception("Pipeline auto-restart failed; will retry", exc, level="ERROR")
|
||||
|
||||
def _check_single_capture_deadline(self) -> None:
|
||||
"""Fail a single capture that never completes so it cannot hang forever."""
|
||||
start = self._single_capture_start_ns
|
||||
if start is None:
|
||||
return
|
||||
if time.monotonic_ns() - start <= int(self._SINGLE_CAPTURE_TIMEOUT_S * 1e9):
|
||||
return
|
||||
self._log_error(
|
||||
f"Single capture timed out after {self._SINGLE_CAPTURE_TIMEOUT_S:.0f}s "
|
||||
"with no result; stopping."
|
||||
)
|
||||
self._stop_run()
|
||||
|
||||
def _handle_reader_poll_error(self, exc: Exception) -> None:
|
||||
"""Surface a reader-poll failure without spamming the log.
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ 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,
|
||||
gpr_object_rows as extract_gpr_object_rows,
|
||||
)
|
||||
|
||||
@@ -252,13 +254,12 @@ class AppWindowGprPlotMixin:
|
||||
|
||||
self._draw_gpr_geometry_markers()
|
||||
|
||||
# Both gpr and legacy_gpr filter heatmap object markers by their own
|
||||
# threshold + visible X/Z window (legacy has the same controls), so the
|
||||
# markers match the objects-only view instead of showing raw detections.
|
||||
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
|
||||
if points_payload is not None and np.asarray(points_payload.table).size > 0:
|
||||
points = (
|
||||
self._filtered_gpr_object_rows(collection)
|
||||
if self._processing_mode.currentText() == "gpr"
|
||||
else np.asarray(points_payload.table, dtype=np.float32)
|
||||
)
|
||||
points = self._filtered_gpr_object_rows(collection)
|
||||
else:
|
||||
points = np.zeros((0, 3), dtype=np.float32)
|
||||
|
||||
@@ -388,13 +389,7 @@ class AppWindowGprPlotMixin:
|
||||
@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."""
|
||||
if limits is None or rows.size == 0:
|
||||
return rows
|
||||
|
||||
max_detected_objects, draw_top_objects = limits
|
||||
if rows.shape[0] > int(max_detected_objects):
|
||||
return np.zeros((0, rows.shape[1]), dtype=rows.dtype)
|
||||
return rows[: max(0, int(draw_top_objects))]
|
||||
return gpr_apply_object_draw_limits(rows, limits)
|
||||
|
||||
@staticmethod
|
||||
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
||||
@@ -517,17 +512,13 @@ class AppWindowGprPlotMixin:
|
||||
return rows
|
||||
|
||||
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||
min_score = self._gpr_locator_threshold()
|
||||
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
|
||||
visible_mask = (
|
||||
finite_mask
|
||||
& (rows[:, 2] >= min_score)
|
||||
& (rows[:, 0] >= x_min)
|
||||
& (rows[:, 0] <= x_max)
|
||||
& (rows[:, 1] >= z_min)
|
||||
& (rows[:, 1] <= z_max)
|
||||
return gpr_filter_object_rows(
|
||||
rows,
|
||||
min_score=self._gpr_locator_threshold(),
|
||||
x_bounds=(x_min, x_max),
|
||||
z_bounds=(z_min, z_max),
|
||||
draw_limits=self._gpr_draw_limits(),
|
||||
)
|
||||
return self._apply_object_draw_limits(rows[visible_mask], self._gpr_draw_limits())
|
||||
|
||||
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
||||
"""Draw only detected GPR objects inside configured X/Z bounds."""
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Embed the browser UI inside the AppWindow without duplicating any rendering.
|
||||
|
||||
The desktop already owns the hardware and draws every plot with pyqtgraph, so the
|
||||
web view simply streams a snapshot of the *current Qt plot widget* — the browser
|
||||
shows exactly what the desktop shows, for every processing mode, with zero
|
||||
re-implemented rendering. Controls cross back to the Qt main thread through queued
|
||||
signals (the GPIO-button pattern) and invoke the AppWindow's existing buttons.
|
||||
|
||||
Two roles, kept separate from the Qt-free :mod:`python_app.webui` package:
|
||||
|
||||
* :class:`AppWindowWebController` — the Qt bridge implementing the web contract.
|
||||
* :class:`AppWindowWebMixin` — wiring that grabs the plot, refreshes snapshots,
|
||||
and starts/stops the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import os
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
|
||||
_DEFAULT_PORT = 8080
|
||||
# Headless has no shown window, so give the offscreen window a usable size for the
|
||||
# grabbed plot. In GUI mode the user's real (shown) window size is used as-is.
|
||||
_HEADLESS_PLOT_SIZE = (1600, 900)
|
||||
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
|
||||
_GRAB_INTERVAL_S = 0.2
|
||||
_LIVE_FIELD_NAMES = frozenset(field.name for field in dataclasses.fields(ProcessingLiveConfig))
|
||||
|
||||
|
||||
class AppWindowWebController(QObject):
|
||||
"""Qt bridge satisfying the web contract: streams the plot, forwards controls.
|
||||
|
||||
Mutating calls (made on the web thread) emit queued signals the AppWindow
|
||||
connects to its existing slots. Read calls return immutable snapshots the
|
||||
AppWindow refreshes on its poll tick — replaced atomically, never mutated in
|
||||
place, so the web thread reads a consistent value without locking.
|
||||
"""
|
||||
|
||||
start_requested = pyqtSignal()
|
||||
stop_requested = pyqtSignal()
|
||||
single_capture_requested = pyqtSignal()
|
||||
capture_requested = pyqtSignal()
|
||||
apply_settings_requested = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._status: dict = {}
|
||||
self._live_settings: list = []
|
||||
self._frame: dict | None = None
|
||||
|
||||
# -- Snapshot refresh (Qt main thread) -----------------------------------
|
||||
|
||||
def update_snapshot(self, *, status: dict, live_settings: dict, frame: dict | None) -> None:
|
||||
"""Replace the served snapshots; ``frame`` only when a new one was grabbed."""
|
||||
self._status = status
|
||||
self._live_settings = live_settings
|
||||
if frame is not None:
|
||||
self._frame = frame
|
||||
|
||||
# -- WebController reads (web thread) ------------------------------------
|
||||
|
||||
def status(self) -> dict:
|
||||
return dict(self._status)
|
||||
|
||||
def current_live_settings(self) -> list:
|
||||
return list(self._live_settings)
|
||||
|
||||
def peek_frame(self) -> dict | None:
|
||||
"""Return the most recent rendered-plot frame (or None before the first)."""
|
||||
return self._frame
|
||||
|
||||
# -- WebController controls (web thread -> Qt main thread) ---------------
|
||||
|
||||
def start(self) -> None:
|
||||
self.start_requested.emit()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_requested.emit()
|
||||
|
||||
def single_capture(self) -> None:
|
||||
self.single_capture_requested.emit()
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
self.capture_requested.emit()
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> dict:
|
||||
unknown = set(fields) - _LIVE_FIELD_NAMES
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
|
||||
self.apply_settings_requested.emit(dict(fields))
|
||||
return self.current_live_settings()
|
||||
|
||||
|
||||
class AppWindowWebMixin:
|
||||
"""Start/stop the embedded web server and feed it the live plot + settings."""
|
||||
|
||||
def _init_web_ui(self) -> None:
|
||||
"""Start the web server (default-on, both modes), wired to existing buttons."""
|
||||
self._web_controller: AppWindowWebController | None = None
|
||||
self._web_server = None
|
||||
self._web_frame_seq = 0
|
||||
self._web_last_png: str | None = None
|
||||
self._web_last_grab_s = 0.0
|
||||
|
||||
try:
|
||||
from python_app.webui.server import WebUiServer
|
||||
|
||||
# Headless never shows the window; size it so the grabbed plot is usable.
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self.resize(*_HEADLESS_PLOT_SIZE)
|
||||
|
||||
controller = AppWindowWebController(parent=self)
|
||||
controller.start_requested.connect(self._start_run)
|
||||
controller.stop_requested.connect(self._stop_run)
|
||||
controller.single_capture_requested.connect(self._start_single_capture)
|
||||
controller.capture_requested.connect(self._capture_tmp_reference)
|
||||
controller.apply_settings_requested.connect(self._apply_web_live_settings)
|
||||
|
||||
self._web_controller = controller
|
||||
self._web_update_snapshot() # seed snapshots before the first request
|
||||
|
||||
port = self._web_ui_port()
|
||||
self._web_server = WebUiServer(controller, port=port)
|
||||
self._web_server.start()
|
||||
self._log(f"Web UI started on http://0.0.0.0:{port}")
|
||||
except Exception as exc: # noqa: BLE001 - a missing dependency must not abort startup
|
||||
self._log_exception("Failed to start web UI", exc, level="WARN")
|
||||
self._web_controller = None
|
||||
self._web_server = None
|
||||
|
||||
def _web_update_snapshot(self) -> None:
|
||||
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
|
||||
|
||||
Must NEVER raise: it runs inside the periodic render tick, where an escaping
|
||||
exception would abort the Qt slot (qFatal) and kill the app.
|
||||
"""
|
||||
controller = getattr(self, "_web_controller", None)
|
||||
if controller is None:
|
||||
return
|
||||
try:
|
||||
controller.update_snapshot(
|
||||
status={
|
||||
"running": self._supervisor.is_running(),
|
||||
"processor_running": self._supervisor.is_processor_running(),
|
||||
"ring_name": self._defaults_config.rings.results.name,
|
||||
},
|
||||
live_settings=self._web_settings_schema(),
|
||||
frame=self._web_grab_frame_if_due(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - the render loop must survive this
|
||||
self._web_snapshot_errors = getattr(self, "_web_snapshot_errors", 0) + 1
|
||||
if self._web_snapshot_errors % 200 == 1:
|
||||
self._log_exception("Web UI snapshot refresh failed", exc, level="WARN")
|
||||
|
||||
def _web_grab_frame_if_due(self) -> dict | None:
|
||||
"""Grab the current plot as a PNG frame, throttled and change-gated."""
|
||||
now = time.monotonic()
|
||||
if now - self._web_last_grab_s < _GRAB_INTERVAL_S:
|
||||
return None
|
||||
self._web_last_grab_s = now
|
||||
png_b64 = self._grab_plot_png_b64()
|
||||
if png_b64 is None or png_b64 == self._web_last_png:
|
||||
return None # nothing rendered yet, or the plot is unchanged
|
||||
self._web_last_png = png_b64
|
||||
self._web_frame_seq += 1
|
||||
return {
|
||||
"type": "frame",
|
||||
"seq": self._web_frame_seq,
|
||||
"mode": self._processing_mode.currentText(),
|
||||
"png_b64": png_b64,
|
||||
}
|
||||
|
||||
def _grab_plot_png_b64(self) -> str | None:
|
||||
"""Render the currently-visible plot page to a base64 PNG (exactly as shown)."""
|
||||
widget = self._plot_stack.currentWidget()
|
||||
if widget is None or widget.width() <= 0 or widget.height() <= 0:
|
||||
return None
|
||||
pixmap = widget.grab()
|
||||
if pixmap.isNull():
|
||||
return None
|
||||
buffer = QBuffer()
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pixmap.save(buffer, "PNG")
|
||||
return base64.b64encode(bytes(buffer.data())).decode()
|
||||
|
||||
def _shutdown_web_ui(self) -> None:
|
||||
"""Stop the server (and its broadcaster) during teardown."""
|
||||
server = getattr(self, "_web_server", None)
|
||||
if server is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
server.stop()
|
||||
self._web_server = None
|
||||
self._web_controller = None
|
||||
|
||||
@staticmethod
|
||||
def _web_ui_port() -> int:
|
||||
"""Resolve the web UI port from the environment, defaulting to 8080."""
|
||||
raw = os.environ.get(_WEBUI_PORT_ENV, "").strip()
|
||||
if not raw:
|
||||
return _DEFAULT_PORT
|
||||
try:
|
||||
port = int(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_PORT
|
||||
return port if 1 <= port <= 65535 else _DEFAULT_PORT
|
||||
@@ -342,7 +342,10 @@ class Protocol:
|
||||
case TaskType.CHANGE_CURRENT_LD2:
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(min_value))) # Word 3
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(max_value))) # Word 4
|
||||
data += _flipfour(_int_to_hex4(int(step * 100))) # Word 5
|
||||
# Word 5: current step encoded like LD1 and like min/max (current_ma_to_n),
|
||||
# NOT int(step*100) — the latter was a copy/paste from temperature scaling
|
||||
# and produced a different wire value than LD1 for the same physical step.
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(step))) # Word 5
|
||||
data += _flipfour(_int_to_hex4(int(time_step * 100))) # Word 6: Delta_Time_µs × 100
|
||||
data += _flipfour(_int_to_hex4(temp_c_to_n(static_temp2))) # Word 7
|
||||
data += _flipfour(_int_to_hex4(current_ma_to_n(static_current1)))# Word 8
|
||||
|
||||
@@ -17,6 +17,7 @@ from python_app.models.run_config_validation import (
|
||||
load_control_button_payload,
|
||||
load_ring_payload,
|
||||
load_switch_payload,
|
||||
validate_combos,
|
||||
validate_gpr_model,
|
||||
)
|
||||
|
||||
@@ -431,6 +432,11 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
)
|
||||
|
||||
model.ensure_combos()
|
||||
validate_combos(
|
||||
model.combos,
|
||||
input_positions=model.input_switch.positions,
|
||||
output_positions=model.output_switch.positions,
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
|
||||
@@ -21,9 +21,10 @@ class ComboModel:
|
||||
class RadarSweepModel:
|
||||
"""Sweep settings for LibreVNA acquisition."""
|
||||
|
||||
# Keep schema defaults minimal/safe; operational values come from run_config.json.
|
||||
start_hz: float = 0.0
|
||||
stop_hz: float = 0.0
|
||||
# A valid default range (stop > start) so a bare/default config is self-consistent;
|
||||
# operational values come from run_config.json. (1 MHz .. 6 GHz mirrors the real configs.)
|
||||
start_hz: float = 1_000_000.0
|
||||
stop_hz: float = 6_000_000_000.0
|
||||
points: int = 1
|
||||
if_bandwidth_hz: float = 1.0
|
||||
power_dbm: float = -30.0
|
||||
|
||||
@@ -24,20 +24,18 @@ _MAX_COMBOS = 4096
|
||||
|
||||
|
||||
def _require_int(payload: dict[str, Any], key: str, default: int) -> int:
|
||||
"""Read an integer field, rejecting JSON arrays/objects with a named ValueError.
|
||||
"""Read a strict JSON integer, treating an explicit ``null`` as 'use default'.
|
||||
|
||||
Bare ``int()`` raises ``TypeError`` on a list/dict, which escapes the
|
||||
config-error contract; surface it as a ValueError naming the field instead.
|
||||
Accept only a genuine JSON integer (not bool, not float, not numeric string):
|
||||
silently truncating ``5.7`` or parsing ``"5"`` would hide a malformed config.
|
||||
Mirrors ``run_config_codec._read_int`` so every config integer reads identically.
|
||||
"""
|
||||
value = payload.get(key, default)
|
||||
if value is None: # explicit JSON null -> use the default, never coerce
|
||||
return default
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(f"{key} must be a JSON integer")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{key} must be a JSON integer") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _require_str(payload: dict[str, Any], key: str, default: str) -> str:
|
||||
@@ -131,8 +129,8 @@ def validate_sweep_model(sweep: RadarSweepModel) -> None:
|
||||
raise ValueError("radar.sweep.points must be an integer")
|
||||
if points <= 0:
|
||||
raise ValueError("radar.sweep.points must be > 0")
|
||||
if float(sweep.stop_hz) < float(sweep.start_hz):
|
||||
raise ValueError("radar.sweep.stop_hz must be >= radar.sweep.start_hz")
|
||||
if float(sweep.stop_hz) <= float(sweep.start_hz):
|
||||
raise ValueError("radar.sweep.stop_hz must be > radar.sweep.start_hz")
|
||||
|
||||
|
||||
def validate_gpr_model(
|
||||
@@ -173,6 +171,34 @@ def validate_gpr_model(
|
||||
seen_input_positions.add(input_pos)
|
||||
|
||||
|
||||
def validate_combos(
|
||||
combos: list[ComboModel],
|
||||
*,
|
||||
input_positions: int,
|
||||
output_positions: int,
|
||||
) -> None:
|
||||
"""Validate run combos against the configured switch dimensions.
|
||||
|
||||
Each combo's input/output must index a real switch position, and no
|
||||
``input:output`` pair may repeat — an out-of-range or duplicate combo is a
|
||||
config error that would otherwise produce missing or doubled traces downstream.
|
||||
"""
|
||||
seen: set[tuple[int, int]] = set()
|
||||
for combo in combos:
|
||||
if not 0 <= int(combo.input) < int(input_positions):
|
||||
raise ValueError(
|
||||
f"run.combos input {combo.input} is out of range [0, {input_positions})"
|
||||
)
|
||||
if not 0 <= int(combo.output) < int(output_positions):
|
||||
raise ValueError(
|
||||
f"run.combos output {combo.output} is out of range [0, {output_positions})"
|
||||
)
|
||||
pair = (int(combo.input), int(combo.output))
|
||||
if pair in seen:
|
||||
raise ValueError(f"run.combos contains duplicate combo {combo.input}:{combo.output}")
|
||||
seen.add(pair)
|
||||
|
||||
|
||||
def parse_combos_from_text(text: str) -> list[ComboModel]:
|
||||
"""Parse UI combos string in `input:output,input:output` format."""
|
||||
cleaned = text.strip()
|
||||
|
||||
@@ -65,3 +65,52 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
|
||||
return centers[:, :3]
|
||||
|
||||
return np.zeros((0, 3), dtype=np.float32)
|
||||
|
||||
|
||||
def apply_object_draw_limits(
|
||||
rows: np.ndarray,
|
||||
limits: tuple[int, int] | None,
|
||||
) -> np.ndarray:
|
||||
"""Apply the object count/top-M drawing rules to already-filtered `[x, z, score]` rows.
|
||||
|
||||
`limits` is `(max_detected_objects, draw_top_objects)`, or `None` to disable
|
||||
(legacy GPR). When more than ``max_detected_objects`` survive, ALL are hidden
|
||||
(the scene is too cluttered to be meaningful); otherwise the top ``draw_top_objects``
|
||||
rows are kept (rows arrive already sorted by score descending).
|
||||
"""
|
||||
if limits is None or rows.size == 0:
|
||||
return rows
|
||||
max_detected_objects, draw_top_objects = limits
|
||||
if rows.shape[0] > int(max_detected_objects):
|
||||
return np.zeros((0, rows.shape[1]), dtype=rows.dtype)
|
||||
return rows[: max(0, int(draw_top_objects))]
|
||||
|
||||
|
||||
def filter_object_rows(
|
||||
rows: np.ndarray,
|
||||
*,
|
||||
min_score: float,
|
||||
x_bounds: tuple[float, float],
|
||||
z_bounds: tuple[float, float],
|
||||
draw_limits: tuple[int, int] | None,
|
||||
) -> np.ndarray:
|
||||
"""Filter `[x_m, z_m, score]` object rows for display/broadcast.
|
||||
|
||||
Drops non-finite rows, rows below ``min_score`` (the caller passes the mode's own
|
||||
threshold — a normalized float for coherent GPR, a pair count for legacy GPR; the
|
||||
comparison is identical either way), and rows outside the visible X/Z window, then
|
||||
applies ``draw_limits``. Mode-agnostic: all semantics enter through the parameters.
|
||||
"""
|
||||
if rows.size == 0:
|
||||
return rows
|
||||
x_min, x_max = x_bounds
|
||||
z_min, z_max = z_bounds
|
||||
visible_mask = (
|
||||
np.all(np.isfinite(rows[:, :3]), axis=1)
|
||||
& (rows[:, 2] >= min_score)
|
||||
& (rows[:, 0] >= x_min)
|
||||
& (rows[:, 0] <= x_max)
|
||||
& (rows[:, 1] >= z_min)
|
||||
& (rows[:, 1] <= z_max)
|
||||
)
|
||||
return apply_object_draw_limits(rows[visible_mask], draw_limits)
|
||||
|
||||
@@ -57,6 +57,12 @@ class ProcessingLiveConfig:
|
||||
# Locator filter parameters consumed by the C++ TCP locator server.
|
||||
gpr_min_visible_score: float = 0.0
|
||||
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.
|
||||
gpr_visible_x_min_m: float = -2.0
|
||||
gpr_visible_x_max_m: float = 2.0
|
||||
gpr_visible_z_min_m: float = 0.0
|
||||
gpr_visible_z_max_m: float = 14.0
|
||||
# When true, the C++ data_processor ignores socket-supplied vlc updates
|
||||
# and keeps using `gpr_speed_m_s` from this file.
|
||||
ignore_socket_speed: bool = False
|
||||
@@ -116,6 +122,10 @@ class ProcessingLiveConfig:
|
||||
"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),
|
||||
"gpr_visible_z_min_m": float(self.gpr_visible_z_min_m),
|
||||
"gpr_visible_z_max_m": float(self.gpr_visible_z_max_m),
|
||||
"ignore_socket_speed": bool(self.ignore_socket_speed),
|
||||
"reprocess_current_result": bool(self.reprocess_current_result),
|
||||
"history_command_seq": int(self.history_command_seq),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Crash auto-restart back-off policy (pure, GUI-independent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RestartPolicy:
|
||||
"""Decide when to relaunch a crashed pipeline — retry forever with capped back-off.
|
||||
|
||||
This is an unattended appliance, so the pipeline never permanently gives up. The
|
||||
wait between restart attempts grows with the number of consecutive failures (so a
|
||||
persistently broken pipeline is not hammered) but is capped at ``max_interval_s``,
|
||||
and the failure streak resets to zero once genuine data flows again. A burst of
|
||||
crash signals within the current back-off window collapses to a single restart.
|
||||
"""
|
||||
|
||||
min_interval_s: float = 3.0
|
||||
max_interval_s: float = 60.0
|
||||
backoff_factor: float = 2.0
|
||||
|
||||
def backoff_for(self, consecutive_failures: int) -> float:
|
||||
"""Return the seconds to wait before the next restart for this failure streak.
|
||||
|
||||
``consecutive_failures`` is the number of restarts already attempted without a
|
||||
recovery: 0 → ``min_interval_s``, then each additional failure multiplies the
|
||||
wait by ``backoff_factor``, capped at ``max_interval_s``.
|
||||
"""
|
||||
if consecutive_failures <= 0:
|
||||
return self.min_interval_s
|
||||
# Beyond this many doublings the wait is always capped; clamp the exponent so a
|
||||
# long streak cannot overflow ``factor ** n``.
|
||||
max_exponent = max(1, math.ceil(math.log(self.max_interval_s / self.min_interval_s, self.backoff_factor)))
|
||||
exponent = min(int(consecutive_failures), max_exponent)
|
||||
return min(self.min_interval_s * (self.backoff_factor ** exponent), self.max_interval_s)
|
||||
|
||||
def should_restart_now(
|
||||
self,
|
||||
*,
|
||||
now_s: float,
|
||||
last_restart_s: float,
|
||||
consecutive_failures: int,
|
||||
) -> bool:
|
||||
"""Return whether enough back-off has elapsed since the last restart to retry."""
|
||||
return now_s - last_restart_s >= self.backoff_for(consecutive_failures)
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Byte-wise cursor utilities for decoding binary ring payloads."""
|
||||
"""Byte-wise cursor utilities for decoding binary ring payloads.
|
||||
|
||||
Every read is bounds-checked and raises :class:`ValueError` on a truncated or
|
||||
malformed payload, so decoders surface a single, catchable error type for any
|
||||
corruption (rather than leaking ``struct.error``/``UnicodeDecodeError``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,48 +11,56 @@ import struct
|
||||
|
||||
|
||||
class ByteCursor:
|
||||
"""Read primitive values from bytes while tracking offset."""
|
||||
"""Read primitive values from bytes while tracking offset (bounds-checked)."""
|
||||
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
"""Create cursor at start of payload."""
|
||||
self.payload = payload
|
||||
self.offset = 0
|
||||
|
||||
def _take(self, size: int) -> bytes:
|
||||
"""Consume ``size`` bytes, raising ValueError if the payload is too short."""
|
||||
end = self.offset + size
|
||||
if size < 0 or end > len(self.payload):
|
||||
raise ValueError(
|
||||
f"truncated payload: need {size} bytes at offset {self.offset}, "
|
||||
f"only {len(self.payload) - self.offset} remain"
|
||||
)
|
||||
data = self.payload[self.offset : end]
|
||||
self.offset = end
|
||||
return data
|
||||
|
||||
def read_u8(self) -> int:
|
||||
"""Read unsigned 8-bit integer."""
|
||||
value = struct.unpack_from("<B", self.payload, self.offset)[0]
|
||||
self.offset += 1
|
||||
return value
|
||||
return struct.unpack("<B", self._take(1))[0]
|
||||
|
||||
def read_u16(self) -> int:
|
||||
"""Read unsigned 16-bit integer."""
|
||||
value = struct.unpack_from("<H", self.payload, self.offset)[0]
|
||||
self.offset += 2
|
||||
return value
|
||||
return struct.unpack("<H", self._take(2))[0]
|
||||
|
||||
def read_u32(self) -> int:
|
||||
"""Read unsigned 32-bit integer."""
|
||||
value = struct.unpack_from("<I", self.payload, self.offset)[0]
|
||||
self.offset += 4
|
||||
return value
|
||||
return struct.unpack("<I", self._take(4))[0]
|
||||
|
||||
def read_u64(self) -> int:
|
||||
"""Read unsigned 64-bit integer."""
|
||||
value = struct.unpack_from("<Q", self.payload, self.offset)[0]
|
||||
self.offset += 8
|
||||
return value
|
||||
return struct.unpack("<Q", self._take(8))[0]
|
||||
|
||||
def read_f32(self) -> float:
|
||||
"""Read 32-bit float."""
|
||||
value = struct.unpack_from("<f", self.payload, self.offset)[0]
|
||||
self.offset += 4
|
||||
return float(value)
|
||||
return float(struct.unpack("<f", self._take(4))[0])
|
||||
|
||||
def read_bytes(self, size: int) -> bytes:
|
||||
"""Read raw byte slice of fixed size."""
|
||||
data = self.payload[self.offset : self.offset + size]
|
||||
self.offset += size
|
||||
return data
|
||||
"""Read raw byte slice of fixed size (bounds-checked)."""
|
||||
return self._take(size)
|
||||
|
||||
def read_str(self, size: int) -> str:
|
||||
"""Read a UTF-8 string of ``size`` bytes, raising ValueError on bad UTF-8."""
|
||||
raw = self._take(size)
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("invalid UTF-8 in payload string") from exc
|
||||
|
||||
def remaining_bytes(self) -> int:
|
||||
"""Return unread byte count."""
|
||||
|
||||
@@ -78,7 +78,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||
"""Decode one result payload from stream."""
|
||||
kind = cursor.read_u8()
|
||||
name_size = cursor.read_u16()
|
||||
name = cursor.read_bytes(name_size).decode("utf-8")
|
||||
name = cursor.read_str(name_size)
|
||||
|
||||
if kind == 1:
|
||||
point_count = cursor.read_u32()
|
||||
|
||||
@@ -35,12 +35,20 @@ class ShmRingReader:
|
||||
self._wait_for_ring_file(timeout_s=open_timeout_s, poll_s=open_poll_s)
|
||||
|
||||
self._file = self._path.open("r+b", buffering=0)
|
||||
self._mmap = mmap.mmap(self._file.fileno(), 0)
|
||||
self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002)
|
||||
self._mmap: mmap.mmap | None = None
|
||||
try:
|
||||
self._mmap = mmap.mmap(self._file.fileno(), 0)
|
||||
self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002)
|
||||
except BaseException:
|
||||
# A fail-fast open (absent/incompatible ring) must not leak the fd/mapping.
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close mmap and file handle."""
|
||||
self._mmap.close()
|
||||
if self._mmap is not None:
|
||||
self._mmap.close()
|
||||
self._mmap = None
|
||||
self._file.close()
|
||||
|
||||
def pop_payload(self) -> bytes | None:
|
||||
@@ -103,6 +111,45 @@ class ShmRingReader:
|
||||
return None
|
||||
return decode_result_collection(payload)
|
||||
|
||||
def peek_latest_payload(self) -> bytes | None:
|
||||
"""Return the most recently published payload WITHOUT consuming it.
|
||||
|
||||
Reads the newest slot through the seqlock and never advances `read_seq`, so a
|
||||
viewer can peek the freshest frame while the ring's real consumer keeps its own
|
||||
cursor — the two coexist without stealing each other's payloads. Inherently
|
||||
latest-wins: always the freshest published frame, or `None` when nothing has
|
||||
been published yet or the slot is being overwritten at this instant.
|
||||
"""
|
||||
write_seq = self._read_u64(24)
|
||||
if write_seq == 0:
|
||||
return None
|
||||
|
||||
latest_seq = write_seq - 1
|
||||
index = latest_seq % self.capacity
|
||||
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self.slot_size_bytes)
|
||||
|
||||
# Seqlock read with NO cursor advance: accept the slot only if its sequence
|
||||
# equals the published value both before and after the copy (i.e. the producer
|
||||
# did not lap this slot mid-read). Never touch read_seq, so the real consumer
|
||||
# is undisturbed.
|
||||
if self._read_u64(slot_offset + 8) != latest_seq + 1:
|
||||
return None
|
||||
payload_size = self._read_u32(slot_offset)
|
||||
if payload_size > self.slot_size_bytes:
|
||||
return None
|
||||
payload_offset = slot_offset + _SLOT_HEADER_SIZE
|
||||
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
|
||||
if self._read_u64(slot_offset + 8) != latest_seq + 1:
|
||||
return None
|
||||
return payload
|
||||
|
||||
def peek_latest_result_collection(self) -> ResultCollection | None:
|
||||
"""Return the most recently published result collection without consuming it."""
|
||||
payload = self.peek_latest_payload()
|
||||
if payload is None:
|
||||
return None
|
||||
return decode_result_collection(payload)
|
||||
|
||||
def drop_all(self) -> int:
|
||||
"""Mark all unread slots as consumed and return number of dropped payloads."""
|
||||
write_seq = self._read_u64(24)
|
||||
|
||||
@@ -98,9 +98,12 @@ class ShmRingWriter:
|
||||
write_seq = self._read_u64(24)
|
||||
read_seq = self._read_u64(32)
|
||||
if max(0, write_seq - read_seq) >= self._capacity:
|
||||
self._write_u64(32, read_seq + 1)
|
||||
dropped = self._read_u64(40)
|
||||
self._write_u64(40, dropped + 1)
|
||||
# Advance the consumer cursor past the slot we are about to overwrite, but
|
||||
# re-read it first and move it only forward: a concurrent reader may have
|
||||
# already advanced it, and clobbering that backward would re-deliver an
|
||||
# already-consumed slot as a duplicate.
|
||||
self._write_u64(32, max(self._read_u64(32), read_seq + 1))
|
||||
self._write_u64(40, self._read_u64(40) + 1)
|
||||
|
||||
index = write_seq % self._capacity
|
||||
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
|
||||
|
||||
@@ -136,6 +136,7 @@ def main() -> int:
|
||||
return 0 # asked to stop before a device became available
|
||||
|
||||
collection_id = 1
|
||||
publish_failures = 0
|
||||
while not stop_requested.is_set():
|
||||
collection_start = time.monotonic()
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
@@ -193,14 +194,17 @@ def main() -> int:
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
payload = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
if not raw_writer.push(payload):
|
||||
raise RuntimeError(
|
||||
f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}"
|
||||
)
|
||||
if not raw_tap_writer.push(payload):
|
||||
raise RuntimeError(
|
||||
f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}"
|
||||
)
|
||||
if raw_writer.push(payload):
|
||||
raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
|
||||
else:
|
||||
# Oversized payload vs the ring slot is a persistent config error, not
|
||||
# a device fault: log (throttled) and skip rather than killing the producer.
|
||||
publish_failures += 1
|
||||
if publish_failures == 1 or publish_failures % 100 == 0:
|
||||
logger.error(
|
||||
"Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)",
|
||||
len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures,
|
||||
)
|
||||
|
||||
if not config.runtime.continuous:
|
||||
break
|
||||
|
||||
@@ -117,6 +117,7 @@ def main() -> int:
|
||||
if radar is None:
|
||||
return 0 # asked to stop before a device became available
|
||||
collection_id = 1
|
||||
publish_failures = 0
|
||||
while not stop_requested.is_set():
|
||||
collection_start = time.monotonic()
|
||||
try:
|
||||
@@ -133,14 +134,18 @@ def main() -> int:
|
||||
continue
|
||||
|
||||
payload = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
if not raw_writer.push(payload):
|
||||
raise RuntimeError(
|
||||
f"Raw payload size {len(payload)} exceeds ring slot size {raw_writer.slot_size_bytes}"
|
||||
)
|
||||
if not raw_tap_writer.push(payload):
|
||||
raise RuntimeError(
|
||||
f"Raw tap payload size {len(payload)} exceeds ring slot size {raw_tap_writer.slot_size_bytes}"
|
||||
)
|
||||
if raw_writer.push(payload):
|
||||
raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
|
||||
else:
|
||||
# An oversized payload vs the ring slot is a persistent config error,
|
||||
# not a device fault: log it (throttled) and skip the collection rather
|
||||
# than letting a RuntimeError escape the loop and kill the producer.
|
||||
publish_failures += 1
|
||||
if publish_failures == 1 or publish_failures % 100 == 0:
|
||||
logger.error(
|
||||
"Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)",
|
||||
len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures,
|
||||
)
|
||||
if not config.runtime.continuous:
|
||||
break
|
||||
collection_duration_s = time.monotonic() - collection_start
|
||||
|
||||
@@ -30,6 +30,42 @@ class GuiProfileCodecTest(unittest.TestCase):
|
||||
self.assertEqual(decoded.gui.processing.pass_through.combo_filter, "0:0,1:0")
|
||||
self.assertEqual(encoded["gui"]["processing"]["pass_through"]["combo_filter"], "0:0,1:0")
|
||||
|
||||
def test_default_profile_round_trips_idempotently(self) -> None:
|
||||
# Full-subtree idempotence catches field-drop/mis-map regressions across every
|
||||
# sub-model, which the single combo_filter round-trip above cannot.
|
||||
once = GuiProfileModel().to_dict()
|
||||
twice = GuiProfileModel.from_dict(once).to_dict()
|
||||
self.assertEqual(once["gui"], twice["gui"])
|
||||
|
||||
def test_missing_gui_section_yields_none(self) -> None:
|
||||
self.assertIsNone(GuiProfileModel.from_dict({}).gui)
|
||||
|
||||
def test_unsupported_version_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "version"):
|
||||
GuiProfileModel.from_dict({"gui": {"version": 2}})
|
||||
|
||||
def test_invalid_combo_mode_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "combo_mode"):
|
||||
GuiProfileModel.from_dict({"gui": {"switches": {"combo_mode": "bogus"}}})
|
||||
|
||||
def test_wrong_typed_field_is_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError): # combos_text must be a JSON string, not a number
|
||||
GuiProfileModel.from_dict({"gui": {"switches": {"combos_text": 123}}})
|
||||
|
||||
def test_legacy_gpr_payload_migrates_selected_mode(self) -> None:
|
||||
# An old payload that selects 'gpr' but carries a legacy root gpr.mode is migrated.
|
||||
decoded = GuiProfileModel.from_dict({
|
||||
"gui": {"processing": {"selected_mode": "gpr"}},
|
||||
"gpr": {"relative_permittivity": 4.0, "mode": "point"},
|
||||
})
|
||||
assert decoded.gui is not None
|
||||
self.assertEqual(decoded.gui.processing.selected_mode, "legacy_gpr")
|
||||
|
||||
def test_modern_gpr_selection_is_not_migrated(self) -> None:
|
||||
decoded = GuiProfileModel.from_dict({"gui": {"processing": {"selected_mode": "gpr"}}})
|
||||
assert decoded.gui is not None
|
||||
self.assertEqual(decoded.gui.processing.selected_mode, "gpr")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -60,6 +60,46 @@ class KamilAdcNeutralPreprocessTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "kamil_adc"):
|
||||
build_kamil_adc_neutral_s21_sets(config, point_count=4)
|
||||
|
||||
@staticmethod
|
||||
def _kamil_config() -> RunConfigModel:
|
||||
return RunConfigModel.from_dict(
|
||||
{
|
||||
"radar": {
|
||||
"model": "kamil_adc",
|
||||
"sweep": {"start_hz": 1_000_000.0, "stop_hz": 4_000_000.0,
|
||||
"if_bandwidth_hz": 1.0, "stimulus_power_dbm": -10.0},
|
||||
},
|
||||
"switches": {"port1": {"positions": 1}, "port2": {"positions": 2}},
|
||||
"run": {"combos": [{"input": 0, "output": 0}, {"input": 1, "output": 0}]},
|
||||
}
|
||||
)
|
||||
|
||||
def test_point_count_zero_or_negative_raises(self) -> None:
|
||||
config = self._kamil_config()
|
||||
for bad in (0, -1):
|
||||
with self.subTest(point_count=bad), self.assertRaisesRegex(ValueError, "point count"):
|
||||
build_kamil_adc_neutral_s21_sets(config, point_count=bad)
|
||||
|
||||
def test_single_point_sweep(self) -> None:
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=1)
|
||||
for trace in calibration.traces:
|
||||
self.assertEqual(trace.frequency_hz.tolist(), [1_000_000.0])
|
||||
self.assertEqual(trace.s21.shape, (1,))
|
||||
|
||||
def test_dtypes_are_float32_and_complex64(self) -> None:
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
|
||||
trace = calibration.traces[0]
|
||||
self.assertEqual(trace.frequency_hz.dtype, np.float32)
|
||||
self.assertEqual(trace.s21.dtype, np.complex64)
|
||||
self.assertEqual(trace.s11.dtype, np.complex64)
|
||||
|
||||
def test_calibration_s21_is_a_nonzero_divisor(self) -> None:
|
||||
# The C++ through-calibrator divides measured/calibration, so calibration S21
|
||||
# must never be zero — that is the whole point of the '1+0j neutral' contract.
|
||||
calibration, _reference = build_kamil_adc_neutral_s21_sets(self._kamil_config(), point_count=4)
|
||||
for trace in calibration.traces:
|
||||
self.assertTrue(bool(np.all(trace.s21 != 0)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -140,6 +140,23 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_corrupt_frame_fails_fast_without_resync(self) -> None:
|
||||
"""A garbage frame (bad marker) mid-stream surfaces on read; the reader does
|
||||
NOT silently resync — fail-fast lets the producer die and the supervisor relaunch."""
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
os.write(
|
||||
master_fd,
|
||||
_start_frame()
|
||||
+ _point_frame(1, 10, -1)
|
||||
+ _point_frame(2, 5, 5, marker=0x001A) # corrupt marker (not 0x000A)
|
||||
+ _start_frame(),
|
||||
)
|
||||
with self.assertRaises((ValueError, RuntimeError)):
|
||||
reader.read_sweep(timeout_s=1.0)
|
||||
finally:
|
||||
self._close(master_fd, slave_fd, reader)
|
||||
|
||||
def test_no_completed_sweep_times_out(self) -> None:
|
||||
master_fd, slave_fd, reader = self._open_pty_reader()
|
||||
try:
|
||||
|
||||
@@ -17,7 +17,9 @@ DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
|
||||
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
|
||||
)
|
||||
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = (
|
||||
"7777ff3702003d2acc2c0500881318ab3d2affa50a00000a8000000a80005106"
|
||||
# Word 5 (step) = current_ma_to_n(0.05)=0x0010, matching LD1 and min/max — see the
|
||||
# step-encoding fix in protocol.py (was the inconsistent int(step*100)=0x0005).
|
||||
"7777ff3702003d2acc2c1000881318ab3d2affa50a00000a8000000a80004406"
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +130,18 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
|
||||
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX)
|
||||
|
||||
def test_ld1_and_ld2_encode_current_step_identically(self) -> None:
|
||||
# Regression guard for the LD2 step-scale bug: both current-variation channels
|
||||
# must encode the step with the same scale (current_ma_to_n), like min/max.
|
||||
params = dict(static_temp1=28.0, static_temp2=28.9, static_current1=33.0, static_current2=35.0,
|
||||
min_value=33.0, max_value=35.0, step=0.05, time_step=50, delay_time=10,
|
||||
message_id=DEVICE_MAIN_MESSAGE_ID, pi_coeff1_p=2560, pi_coeff1_i=128,
|
||||
pi_coeff2_p=2560, pi_coeff2_i=128)
|
||||
ld1 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD1, **params)
|
||||
ld2 = Protocol.encode_task_enable(task_type=TaskType.CHANGE_CURRENT_LD2, **params)
|
||||
# Word 5 (step) is at bytes 10:12 in both frames (sync, header, task, min, max, step).
|
||||
self.assertEqual(ld1[10:12], ld2[10:12])
|
||||
|
||||
def test_start_sequence_matches_device_main_order(self) -> None:
|
||||
fake_protocol = _FakeProtocol()
|
||||
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
|
||||
@@ -235,6 +249,37 @@ class LaserControlProtocolCompatibilityTest(unittest.TestCase):
|
||||
"message_id": DEVICE_MAIN_MESSAGE_ID,
|
||||
},
|
||||
)
|
||||
# The variation handoff itself (type + params), not just call order, must be right.
|
||||
variation_payload = controller.calls[3][1]
|
||||
self.assertEqual(variation_payload["variation_type"], VariationType.CHANGE_CURRENT_LD1)
|
||||
self.assertEqual(variation_payload["params"]["step"], 0.05)
|
||||
self.assertEqual(variation_payload["params"]["min_value"], 33.0)
|
||||
self.assertEqual(variation_payload["params"]["max_value"], 35.0)
|
||||
|
||||
def test_apply_radar_manual_mode_sets_manual_without_variation(self) -> None:
|
||||
config = self._kamil_config(
|
||||
{
|
||||
"enabled": True,
|
||||
"port": "/dev/ttyUSB0",
|
||||
"mode": "manual",
|
||||
"manual": {"temp1": 26.0, "temp2": 27.0, "current1": 30.0, "current2": 31.0},
|
||||
}
|
||||
)
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
|
||||
applied = apply_kamil_adc_laser_control(config)
|
||||
self.assertTrue(applied)
|
||||
controller = _FakeLaserController.instances[0]
|
||||
self.assertEqual([name for name, _ in controller.calls], ["connect", "reset", "set_manual_mode", "disconnect"])
|
||||
self.assertEqual(controller.calls[2][1]["current1"], 30.0)
|
||||
|
||||
def test_apply_radar_unknown_variation_type_raises(self) -> None:
|
||||
config = self._kamil_config(
|
||||
{"enabled": True, "port": "/dev/ttyUSB0", "mode": "variation",
|
||||
"variation": {"variation_type": "NOT_A_REAL_TYPE"}}
|
||||
)
|
||||
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
|
||||
with self.assertRaisesRegex(ValueError, "variation_type"):
|
||||
apply_kamil_adc_laser_control(config)
|
||||
|
||||
def test_apply_radar_skips_disabled_laser_control(self) -> None:
|
||||
config = self._kamil_config({"enabled": False})
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Orchestration & recovery tests.
|
||||
|
||||
Pins the agreed semantics:
|
||||
* crash auto-restart retries FOREVER with capped exponential back-off (never gives
|
||||
up — unattended appliance); the failure streak resets when data flows again;
|
||||
* config writes are atomic — a serialization failure leaves no partial output and
|
||||
never corrupts an existing config;
|
||||
* the acquisition producer command is selected by radar.model;
|
||||
* process exit reports classify clean vs unexpected exits correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.process_supervisor import ProcessExitReport, ProcessSupervisor
|
||||
from python_app.orchestration.restart_policy import RestartPolicy
|
||||
|
||||
|
||||
class RestartPolicyTest(unittest.TestCase):
|
||||
def test_backoff_grows_and_caps(self) -> None:
|
||||
policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0, backoff_factor=2.0)
|
||||
self.assertEqual(policy.backoff_for(0), 3.0)
|
||||
self.assertEqual(policy.backoff_for(1), 6.0)
|
||||
self.assertEqual(policy.backoff_for(2), 12.0)
|
||||
self.assertEqual(policy.backoff_for(3), 24.0)
|
||||
self.assertEqual(policy.backoff_for(100), 60.0) # capped
|
||||
|
||||
def test_never_gives_up_even_after_huge_streak(self) -> None:
|
||||
# No attempt cap: a long failure streak still yields a finite, capped wait.
|
||||
policy = RestartPolicy()
|
||||
self.assertEqual(policy.backoff_for(10_000), policy.max_interval_s)
|
||||
|
||||
def test_should_restart_respects_backoff_window(self) -> None:
|
||||
policy = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0)
|
||||
# First failure (streak 0): needs >= 3s since last restart.
|
||||
self.assertFalse(policy.should_restart_now(now_s=102.0, last_restart_s=100.0, consecutive_failures=0))
|
||||
self.assertTrue(policy.should_restart_now(now_s=103.0, last_restart_s=100.0, consecutive_failures=0))
|
||||
# After 2 failures the window is 12s.
|
||||
self.assertFalse(policy.should_restart_now(now_s=111.0, last_restart_s=100.0, consecutive_failures=2))
|
||||
self.assertTrue(policy.should_restart_now(now_s=112.0, last_restart_s=100.0, consecutive_failures=2))
|
||||
|
||||
def test_first_restart_is_immediate(self) -> None:
|
||||
# last_restart defaults to 0.0 in the mixin, so the very first crash restarts now.
|
||||
self.assertTrue(RestartPolicy().should_restart_now(now_s=5_000.0, last_restart_s=0.0, consecutive_failures=0))
|
||||
|
||||
|
||||
class ConfigWriterTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.root = Path(self._dir.name)
|
||||
self.writer = ConfigWriter(self.root / "runtime")
|
||||
|
||||
def test_writes_loadable_config_and_leaves_no_tmp(self) -> None:
|
||||
out = self.root / "run_config.json"
|
||||
self.writer.write(RunConfigModel(), out)
|
||||
self.assertTrue(out.exists())
|
||||
self.assertFalse(out.with_suffix(".json.tmp").exists())
|
||||
RunConfigModel.from_dict(json.loads(out.read_text())) # round-trips back through the schema
|
||||
|
||||
def test_nan_fails_loudly_without_corrupting_existing(self) -> None:
|
||||
out = self.root / "run_config.json"
|
||||
self.writer.write(RunConfigModel(), out)
|
||||
original = out.read_text()
|
||||
|
||||
broken = RunConfigModel()
|
||||
broken.radar.sweep.if_bandwidth_hz = float("nan")
|
||||
with self.assertRaises(ValueError): # allow_nan=False
|
||||
self.writer.write(broken, out)
|
||||
|
||||
self.assertEqual(out.read_text(), original) # existing config untouched
|
||||
self.assertFalse(out.with_suffix(".json.tmp").exists()) # no half-written tmp left
|
||||
|
||||
|
||||
class RadarModelCommandTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.root = Path(self._dir.name)
|
||||
self.supervisor = ProcessSupervisor(self.root)
|
||||
|
||||
def _config_with_model(self, model: str) -> Path:
|
||||
path = self.root / "cfg.json"
|
||||
path.write_text(json.dumps({"radar": {"model": model}}))
|
||||
return path
|
||||
|
||||
def test_read_radar_model(self) -> None:
|
||||
self.assertEqual(ProcessSupervisor._read_radar_model(self._config_with_model("kamil_adc")), "kamil_adc")
|
||||
|
||||
def test_read_radar_model_defaults_on_malformed(self) -> None:
|
||||
path = self.root / "bad.json"
|
||||
path.write_text("[]") # not an object
|
||||
self.assertEqual(ProcessSupervisor._read_radar_model(path), "librevna")
|
||||
|
||||
def test_matrix_producer_for_multi_and_sn9000(self) -> None:
|
||||
for model in ("librevna_multi", "sn9000"):
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model(model))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
|
||||
def test_kamil_producer_for_kamil_adc(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.kamil_adc_raw_producer"])
|
||||
|
||||
def test_native_orchestrator_for_librevna(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna"))
|
||||
self.assertTrue(cmd[0].endswith("build/bin/sweep_orchestrator"))
|
||||
|
||||
|
||||
class ProcessExitReportTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _report(*, code: int, clean: bool) -> ProcessExitReport:
|
||||
return ProcessExitReport(
|
||||
name="data_processor",
|
||||
command=["x"],
|
||||
working_directory=Path("/tmp"),
|
||||
return_code=code,
|
||||
stdout_path=Path("/nonexistent.out"),
|
||||
stderr_path=Path("/nonexistent.err"),
|
||||
expected_clean_exit=clean,
|
||||
)
|
||||
|
||||
def test_clean_exit_is_info(self) -> None:
|
||||
report = self._report(code=0, clean=True)
|
||||
self.assertEqual(report.level, "INFO")
|
||||
self.assertIn("completed normally", report.format())
|
||||
|
||||
def test_nonzero_exit_is_error(self) -> None:
|
||||
report = self._report(code=3, clean=False)
|
||||
self.assertEqual(report.level, "ERROR")
|
||||
self.assertIn("exited with code 3", report.format())
|
||||
|
||||
def test_unexpected_zero_exit_is_error(self) -> None:
|
||||
report = self._report(code=0, clean=False)
|
||||
self.assertEqual(report.level, "ERROR")
|
||||
self.assertIn("exited unexpectedly with code 0", report.format())
|
||||
|
||||
|
||||
class SupervisorLifecycleTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.supervisor = ProcessSupervisor(Path(self._dir.name))
|
||||
self.addCleanup(self.supervisor.stop_all)
|
||||
|
||||
def _await_reports(self, timeout_s: float = 3.0) -> list[ProcessExitReport]:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
reports = self.supervisor.collect_exit_reports()
|
||||
if reports:
|
||||
return reports
|
||||
time.sleep(0.02)
|
||||
return []
|
||||
|
||||
def test_spawn_alive_then_stop(self) -> None:
|
||||
self.supervisor._spawn("data_preprocessor",
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
allow_clean_exit=False)
|
||||
self.assertTrue(self.supervisor.is_running())
|
||||
self.assertIn("data_preprocessor", self.supervisor.pids())
|
||||
self.supervisor.stop()
|
||||
self.assertFalse(self.supervisor.is_running())
|
||||
|
||||
def test_unexpected_exit_reported_as_error(self) -> None:
|
||||
self.supervisor._spawn("data_processor",
|
||||
[sys.executable, "-c", "import sys; sys.exit(3)"],
|
||||
allow_clean_exit=False)
|
||||
reports = self._await_reports()
|
||||
self.assertEqual(len(reports), 1)
|
||||
self.assertEqual(reports[0].return_code, 3)
|
||||
self.assertEqual(reports[0].level, "ERROR")
|
||||
|
||||
def test_clean_exit_reported_as_info(self) -> None:
|
||||
self.supervisor._spawn("sweep_orchestrator",
|
||||
[sys.executable, "-c", "import sys; sys.exit(0)"],
|
||||
allow_clean_exit=True)
|
||||
reports = self._await_reports()
|
||||
self.assertEqual(len(reports), 1)
|
||||
self.assertTrue(reports[0].expected_clean_exit)
|
||||
self.assertEqual(reports[0].level, "INFO")
|
||||
|
||||
def test_stale_pid_guard_rejects_unrelated_processes(self) -> None:
|
||||
# The reap guard must never kill a recycled PID that is not one of our binaries.
|
||||
self.assertFalse(self.supervisor._is_stale_pipeline_pid(2_000_000_000)) # no such pid
|
||||
self.assertFalse(self.supervisor._is_stale_pipeline_pid(os.getpid())) # the test runner
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Processing-interpretation tests across processor modes.
|
||||
|
||||
Pins the agreed semantics (not just current behaviour):
|
||||
* GPR object filtering is mode-agnostic — keep finite rows with score >= threshold
|
||||
(a normalized float for coherent GPR, a pair count for legacy GPR; same compare)
|
||||
inside the visible X/Z window, then apply draw limits. When more than
|
||||
max_detected_objects survive, ALL are hidden; legacy GPR passes draw_limits=None
|
||||
(count rules disabled) but is otherwise filtered identically — so heatmap markers
|
||||
match the objects-only view in both modes.
|
||||
* B-scan level/colormap/mean-subtraction/history transforms are deterministic.
|
||||
* ProcessingLiveConfig normalizes optional position lists to concrete int lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import (
|
||||
apply_mean_ascan_subtraction,
|
||||
bscan_levels,
|
||||
bscan_lookup_table,
|
||||
build_lut,
|
||||
rebuild_bscan_history_from_results,
|
||||
)
|
||||
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
|
||||
from python_app.models.dataset_model import (
|
||||
ComboKey,
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
)
|
||||
from python_app.orchestration.gpr_locator import (
|
||||
apply_object_draw_limits,
|
||||
collection_has_gpr_payloads,
|
||||
collection_payload_by_name,
|
||||
collection_payloads_by_prefix,
|
||||
filter_object_rows,
|
||||
gpr_object_rows,
|
||||
)
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
|
||||
def _table(name: str, rows) -> ResultPayload:
|
||||
return ResultPayload(processing_name=name, kind=4, table=np.asarray(rows, dtype=np.float32))
|
||||
|
||||
|
||||
def _collection(payloads) -> ResultCollection:
|
||||
return ResultCollection(collection_id=1, monotonic_ns=1, collection_payloads=list(payloads))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Shared result-collection lookup helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
class CollectionLookupTest(unittest.TestCase):
|
||||
def test_payload_by_name_matches_name_and_optional_kind(self) -> None:
|
||||
col = _collection([_table("gpr_points", [[0, 0, 1]]), _table("gpr_region_centers", [[1, 1, 2, 9]])])
|
||||
self.assertIs(collection_payload_by_name(col, "gpr_points"), col.collection_payloads[0])
|
||||
self.assertIsNone(collection_payload_by_name(col, "missing"))
|
||||
self.assertIsNone(collection_payload_by_name(col, "gpr_points", kind=3)) # wrong kind
|
||||
|
||||
def test_payloads_by_prefix_returns_all_in_order(self) -> None:
|
||||
col = _collection([
|
||||
ResultPayload(processing_name="gpr_region_mask_0", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
|
||||
ResultPayload(processing_name="gpr_region_mask_1", kind=3, image=np.zeros((1, 1), dtype=np.float32)),
|
||||
_table("gpr_points", [[0, 0, 1]]),
|
||||
])
|
||||
masks = collection_payloads_by_prefix(col, "gpr_region_mask_", kind=3)
|
||||
self.assertEqual([p.processing_name for p in masks], ["gpr_region_mask_0", "gpr_region_mask_1"])
|
||||
self.assertEqual(collection_payloads_by_prefix(col, "nope_"), [])
|
||||
|
||||
def test_has_gpr_payloads(self) -> None:
|
||||
self.assertTrue(collection_has_gpr_payloads(_collection([_table("gpr_points", [[0, 0, 1]])])))
|
||||
self.assertFalse(collection_has_gpr_payloads(_collection([_table("pass_through", [[0, 0, 1]])])))
|
||||
self.assertFalse(collection_has_gpr_payloads(_collection([])))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GPR object row extraction
|
||||
# --------------------------------------------------------------------------- #
|
||||
class GprObjectRowsTest(unittest.TestCase):
|
||||
def test_extracts_first_three_columns_of_points(self) -> None:
|
||||
rows = gpr_object_rows(_collection([_table("gpr_points", [[1, 2, 3], [4, 5, 6]])]))
|
||||
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)))
|
||||
|
||||
def test_falls_back_to_region_centers_and_drops_extra_columns(self) -> None:
|
||||
# region_centers is [x, z, score, pixel_count]; only the first 3 cols are used.
|
||||
rows = gpr_object_rows(_collection([_table("gpr_region_centers", [[1, 2, 3, 99]])]))
|
||||
self.assertTrue(np.array_equal(rows, np.array([[1, 2, 3]], dtype=np.float32)))
|
||||
|
||||
def test_empty_when_no_object_payloads(self) -> None:
|
||||
self.assertEqual(gpr_object_rows(_collection([_table("gpr_accumulator", [[1, 2, 3]])])).shape, (0, 3))
|
||||
|
||||
def test_rejects_table_with_too_few_columns(self) -> None:
|
||||
self.assertEqual(gpr_object_rows(_collection([_table("gpr_points", [[1, 2]])])).shape, (0, 3))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Object draw limits (hide-all-when-over, then top-M)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ApplyObjectDrawLimitsTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _rows(n: int) -> np.ndarray:
|
||||
return np.column_stack([np.arange(n), np.arange(n), np.arange(n)]).astype(np.float32)
|
||||
|
||||
def test_none_limits_passes_through(self) -> None:
|
||||
rows = self._rows(5)
|
||||
self.assertTrue(np.array_equal(apply_object_draw_limits(rows, None), rows))
|
||||
|
||||
def test_hides_all_when_over_max(self) -> None:
|
||||
self.assertEqual(apply_object_draw_limits(self._rows(6), (5, 3)).shape, (0, 3))
|
||||
|
||||
def test_keeps_top_m_when_within_max(self) -> None:
|
||||
out = apply_object_draw_limits(self._rows(4), (5, 2))
|
||||
self.assertEqual(out.shape, (2, 3))
|
||||
self.assertTrue(np.array_equal(out, self._rows(4)[:2]))
|
||||
|
||||
def test_max_zero_hides_any_objects(self) -> None:
|
||||
# max_detected_objects == 0 means "hide all" (any object exceeds it).
|
||||
self.assertEqual(apply_object_draw_limits(self._rows(1), (0, 5)).shape, (0, 3))
|
||||
|
||||
def test_empty_input_passes_through(self) -> None:
|
||||
empty = np.zeros((0, 3), dtype=np.float32)
|
||||
self.assertEqual(apply_object_draw_limits(empty, (5, 3)).shape, (0, 3))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mode-agnostic object filtering (the core both modes + the locator share)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FilterObjectRowsTest(unittest.TestCase):
|
||||
BOUNDS = {"x_bounds": (-2.0, 2.0), "z_bounds": (0.0, 10.0)}
|
||||
|
||||
def _filter(self, rows, *, min_score, draw_limits=None):
|
||||
return filter_object_rows(np.asarray(rows, dtype=np.float32), min_score=min_score,
|
||||
draw_limits=draw_limits, **self.BOUNDS)
|
||||
|
||||
def test_keeps_in_window_and_at_or_above_threshold(self) -> None:
|
||||
out = self._filter([[0.0, 5.0, 0.5], [1.0, 1.0, 0.9]], min_score=0.5) # score==threshold kept (inclusive)
|
||||
self.assertEqual(out.shape[0], 2)
|
||||
|
||||
def test_drops_below_threshold(self) -> None:
|
||||
out = self._filter([[0.0, 5.0, 0.4]], min_score=0.5)
|
||||
self.assertEqual(out.shape[0], 0)
|
||||
|
||||
def test_window_bounds_are_inclusive(self) -> None:
|
||||
out = self._filter([[2.0, 10.0, 1.0], [-2.0, 0.0, 1.0]], min_score=0.0) # exactly on each edge
|
||||
self.assertEqual(out.shape[0], 2)
|
||||
|
||||
def test_drops_outside_window(self) -> None:
|
||||
out = self._filter([[2.001, 5.0, 1.0], [0.0, 10.001, 1.0]], min_score=0.0)
|
||||
self.assertEqual(out.shape[0], 0)
|
||||
|
||||
def test_drops_non_finite_rows(self) -> None:
|
||||
out = self._filter([[np.nan, 5.0, 1.0], [0.0, np.inf, 1.0], [0.0, 5.0, 1.0]], min_score=0.0)
|
||||
self.assertEqual(out.shape[0], 1)
|
||||
|
||||
def test_legacy_pair_count_threshold_uses_same_compare(self) -> None:
|
||||
# legacy GPR passes an integer pair-count threshold; the >= compare is identical.
|
||||
out = self._filter([[0.0, 5.0, 3.0], [0.0, 5.0, 2.0]], min_score=3, draw_limits=None)
|
||||
self.assertTrue(np.array_equal(out, np.array([[0.0, 5.0, 3.0]], dtype=np.float32)))
|
||||
|
||||
def test_draw_limits_hide_all_when_over(self) -> None:
|
||||
rows = [[0.0, 5.0, 1.0]] * 4
|
||||
self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=(3, 2)).shape[0], 0)
|
||||
|
||||
def test_legacy_none_limits_skips_count_rule(self) -> None:
|
||||
rows = [[0.0, 5.0, 1.0]] * 4
|
||||
self.assertEqual(self._filter(rows, min_score=0.0, draw_limits=None).shape[0], 4)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# B-scan transforms
|
||||
# --------------------------------------------------------------------------- #
|
||||
class BscanTransformTest(unittest.TestCase):
|
||||
def test_mean_ascan_subtraction(self) -> None:
|
||||
history = deque([np.array([1.0, 1.0], dtype=np.float32), np.array([3.0, 3.0], dtype=np.float32)])
|
||||
self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=False),
|
||||
np.array([[1, 1], [3, 3]], dtype=np.float32)))
|
||||
self.assertTrue(np.array_equal(apply_mean_ascan_subtraction(history, enabled=True),
|
||||
np.array([[-1, -1], [1, 1]], dtype=np.float32)))
|
||||
|
||||
def test_levels_abs_mode(self) -> None:
|
||||
self.assertEqual(bscan_levels(np.array([[1.0, 4.0], [2.0, 3.0]], dtype=np.float32), "abs"), (1.0, 4.0))
|
||||
|
||||
def test_levels_abs_degenerate(self) -> None:
|
||||
low, high = bscan_levels(np.full((2, 2), 5.0, dtype=np.float32), "abs")
|
||||
self.assertEqual(low, 5.0)
|
||||
self.assertGreater(high, low)
|
||||
|
||||
def test_levels_signed_mode_symmetric(self) -> None:
|
||||
self.assertEqual(bscan_levels(np.array([[-3.0, 1.0]], dtype=np.float32), "real"), (-3.0, 3.0))
|
||||
|
||||
def test_build_lut_shape_and_endpoints(self) -> None:
|
||||
lut = build_lut(["#000000", "#ffffff"])
|
||||
self.assertEqual(lut.shape, (256, 3))
|
||||
self.assertEqual(lut.dtype, np.uint8)
|
||||
self.assertTrue(np.array_equal(lut[0], [0, 0, 0]))
|
||||
self.assertTrue(np.array_equal(lut[-1], [255, 255, 255]))
|
||||
|
||||
def test_lookup_table_for_each_axis_mode(self) -> None:
|
||||
for mode in ("abs", "real", "phase"):
|
||||
self.assertEqual(bscan_lookup_table(mode).shape, (256, 3))
|
||||
|
||||
|
||||
class RebuildBscanHistoryTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _bscan_collection(cid: int, combo, depth, amps, *, name="bscan", kind=1) -> ResultCollection:
|
||||
trace = np.asarray(amps, dtype=np.float32).astype(np.complex64)
|
||||
payload = ResultPayload(processing_name=name, kind=kind,
|
||||
frequency_hz=np.asarray(depth, dtype=np.float32), trace=trace)
|
||||
block = ResultBlock(combo=ComboKey(input=combo[0], output=combo[1]), payloads=[payload])
|
||||
return ResultCollection(collection_id=cid, monotonic_ns=cid, blocks=[block])
|
||||
|
||||
def test_accumulates_sweeps_per_combo(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
||||
self._bscan_collection(2, (0, 0), [1.0, 2.0], [11.0, 21.0]),
|
||||
]
|
||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
||||
self.assertEqual(len(by_combo[(0, 0)]), 2)
|
||||
self.assertTrue(np.array_equal(axes[(0, 0)], np.array([1.0, 2.0], dtype=np.float32)))
|
||||
|
||||
def test_skips_non_bscan_and_mismatched_payloads(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [1.0, 2.0], name="other"), # wrong name
|
||||
self._bscan_collection(2, (0, 0), [1.0, 2.0], [1.0, 2.0], kind=2), # wrong kind
|
||||
self._bscan_collection(3, (0, 0), [1.0, 2.0, 3.0], [1.0, 2.0]), # size mismatch
|
||||
]
|
||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
||||
self.assertEqual(by_combo, {})
|
||||
|
||||
def test_depth_axis_change_resets_history(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0, 2.0], [10.0, 20.0]),
|
||||
self._bscan_collection(2, (0, 0), [1.0, 2.0, 3.0], [11.0, 21.0, 31.0]), # new depth axis
|
||||
]
|
||||
by_combo, axes = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=0)
|
||||
self.assertEqual(len(by_combo[(0, 0)]), 1) # reset on axis change; only the latest sweep remains
|
||||
self.assertEqual(axes[(0, 0)].shape, (3,))
|
||||
|
||||
def test_floor_collection_id_excludes_older(self) -> None:
|
||||
history = [
|
||||
self._bscan_collection(1, (0, 0), [1.0], [10.0]),
|
||||
self._bscan_collection(2, (0, 0), [1.0], [20.0]),
|
||||
]
|
||||
by_combo, _ = rebuild_bscan_history_from_results(history, history_limit=10, floor_collection_id=1)
|
||||
self.assertEqual(len(by_combo[(0, 0)]), 1) # only collection_id > 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GPR display-range helpers (pure static math on the mixin)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class GprDisplayHelperTest(unittest.TestCase):
|
||||
def test_normalized_range_orders_and_expands(self) -> None:
|
||||
self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(2.0, 5.0), (2.0, 5.0))
|
||||
self.assertEqual(AppWindowGprPlotMixin._normalized_display_range(5.0, 2.0), (2.0, 5.0)) # reordered
|
||||
low, high = AppWindowGprPlotMixin._normalized_display_range(3.0, 3.0) # zero span expands to 0.1
|
||||
self.assertAlmostEqual(high - low, 0.1)
|
||||
self.assertAlmostEqual(0.5 * (low + high), 3.0)
|
||||
|
||||
def test_display_y_min_keeps_surface_margin(self) -> None:
|
||||
self.assertEqual(AppWindowGprPlotMixin._gpr_display_y_min(2.0, 5.0), 2.0) # surface not visible
|
||||
self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(0.0, 10.0), -0.3) # 3% of span
|
||||
self.assertAlmostEqual(AppWindowGprPlotMixin._gpr_display_y_min(-1.0, 1.0), -1.06) # min 0.06 margin
|
||||
|
||||
def test_object_label_candidates_are_distinct_positions(self) -> None:
|
||||
candidates = AppWindowGprPlotMixin._gpr_object_label_candidates(0.0, 0.0, x_span=1.0, z_span=1.0)
|
||||
self.assertEqual(len(candidates), 10)
|
||||
self.assertTrue(all(math.isfinite(x) and math.isfinite(z) for x, z, _ in candidates))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ProcessingLiveConfig normalization
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ProcessingLiveConfigTest(unittest.TestCase):
|
||||
def test_none_positions_become_empty_lists(self) -> None:
|
||||
cfg = ProcessingLiveConfig(gpr_input_positions=None, gpr_output_positions=None)
|
||||
self.assertEqual(cfg.gpr_input_positions, [])
|
||||
self.assertEqual(cfg.gpr_output_positions, [])
|
||||
|
||||
def test_positions_coerced_to_ints(self) -> None:
|
||||
cfg = ProcessingLiveConfig(gpr_input_positions=[1.5, 2.9], gpr_output_positions=[0.0])
|
||||
self.assertEqual(cfg.gpr_input_positions, [1, 2])
|
||||
self.assertEqual(cfg.gpr_output_positions, [0])
|
||||
|
||||
def test_to_dict_is_json_typed(self) -> None:
|
||||
data = ProcessingLiveConfig().to_dict()
|
||||
self.assertIsInstance(data["processor_mode"], str)
|
||||
self.assertIsInstance(data["gpr_input_positions"], list)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Run-config decode/validation tests.
|
||||
|
||||
Pins the agreed semantics (not just current behaviour):
|
||||
* every config integer must be a genuine JSON int — "5", 5.0, true are errors;
|
||||
an explicit JSON null means "use the default";
|
||||
* a sweep is a real range — stop_hz must be strictly greater than start_hz, and
|
||||
points a positive integer;
|
||||
* combos must index real switch positions and contain no duplicate input:output;
|
||||
* ring/gpr structural bounds are enforced in Python (so a bad config fails here,
|
||||
not in the C++ pipeline at boot).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from python_app.models.run_config_schema import (
|
||||
ComboModel,
|
||||
GprModel,
|
||||
GprRxGeometryModel,
|
||||
GprTxGeometryModel,
|
||||
RadarSweepModel,
|
||||
RingEndpointModel,
|
||||
RunConfigModel,
|
||||
)
|
||||
from python_app.models.run_config_validation import (
|
||||
parse_combos_from_text,
|
||||
validate_combos,
|
||||
validate_gpr_model,
|
||||
validate_ring_endpoint,
|
||||
validate_sweep_model,
|
||||
)
|
||||
|
||||
|
||||
def _valid_payload() -> dict:
|
||||
"""A canonical, valid run-config payload (the schema defaults serialized)."""
|
||||
return RunConfigModel().to_dict()
|
||||
|
||||
|
||||
class StrictNumericTypingTest(unittest.TestCase):
|
||||
"""Every config integer reads strictly; null falls back to the default."""
|
||||
|
||||
def _reject_int(self, section: list[str], key: str, value: object) -> None:
|
||||
payload = _valid_payload()
|
||||
node = payload
|
||||
for part in section:
|
||||
node = node[part]
|
||||
node[key] = value
|
||||
with self.assertRaises(ValueError):
|
||||
RunConfigModel.from_dict(payload)
|
||||
|
||||
def test_codec_int_rejects_string_float_bool(self) -> None:
|
||||
# radar.sweep.points is read by the codec's strict _read_int.
|
||||
for value in ("201", 201.0, True):
|
||||
with self.subTest(value=value):
|
||||
self._reject_int(["radar", "sweep"], "points", value)
|
||||
|
||||
def test_validation_int_rejects_string_float_bool(self) -> None:
|
||||
# switch positions are read by run_config_validation._require_int.
|
||||
for value in ("4", 4.0, True):
|
||||
with self.subTest(value=value):
|
||||
self._reject_int(["switches", "port1"], "positions", value)
|
||||
|
||||
def test_genuine_int_accepted(self) -> None:
|
||||
payload = _valid_payload()
|
||||
payload["radar"]["sweep"]["points"] = 256
|
||||
self.assertEqual(RunConfigModel.from_dict(payload).radar.sweep.points, 256)
|
||||
|
||||
def test_null_uses_default(self) -> None:
|
||||
payload = _valid_payload()
|
||||
payload["radar"]["sweep"]["points"] = None
|
||||
self.assertEqual(
|
||||
RunConfigModel.from_dict(payload).radar.sweep.points,
|
||||
RunConfigModel().radar.sweep.points,
|
||||
)
|
||||
|
||||
|
||||
class StructuralTypingTest(unittest.TestCase):
|
||||
"""A present-but-wrong-shaped section fails loudly instead of being dropped."""
|
||||
|
||||
def test_combos_must_be_an_array(self) -> None:
|
||||
payload = _valid_payload()
|
||||
payload["run"]["combos"] = {"input": 0, "output": 0}
|
||||
with self.assertRaisesRegex(ValueError, "run.combos must be a JSON array"):
|
||||
RunConfigModel.from_dict(payload)
|
||||
|
||||
|
||||
class SweepValidationTest(unittest.TestCase):
|
||||
def test_points_must_be_positive(self) -> None:
|
||||
for points in (0, -1):
|
||||
with self.subTest(points=points), self.assertRaisesRegex(ValueError, "points"):
|
||||
validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=points))
|
||||
|
||||
def test_stop_must_be_strictly_greater_than_start(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "stop_hz"): # equal is not a range
|
||||
validate_sweep_model(RadarSweepModel(start_hz=2.0, stop_hz=2.0, points=1))
|
||||
with self.assertRaisesRegex(ValueError, "stop_hz"): # inverted
|
||||
validate_sweep_model(RadarSweepModel(start_hz=3.0, stop_hz=2.0, points=1))
|
||||
|
||||
def test_valid_sweep_passes(self) -> None:
|
||||
validate_sweep_model(RadarSweepModel(start_hz=1.0, stop_hz=2.0, points=201))
|
||||
|
||||
|
||||
class ComboValidationTest(unittest.TestCase):
|
||||
def test_within_bounds_passes(self) -> None:
|
||||
validate_combos(
|
||||
[ComboModel(input=0, output=0), ComboModel(input=3, output=1)],
|
||||
input_positions=4,
|
||||
output_positions=2,
|
||||
)
|
||||
|
||||
def test_input_out_of_range_rejected(self) -> None:
|
||||
for inp in (-1, 4):
|
||||
with self.subTest(input=inp), self.assertRaisesRegex(ValueError, "input"):
|
||||
validate_combos([ComboModel(input=inp, output=0)], input_positions=4, output_positions=2)
|
||||
|
||||
def test_output_out_of_range_rejected(self) -> None:
|
||||
for out in (-1, 2):
|
||||
with self.subTest(output=out), self.assertRaisesRegex(ValueError, "output"):
|
||||
validate_combos([ComboModel(input=0, output=out)], input_positions=4, output_positions=2)
|
||||
|
||||
def test_duplicate_combo_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
validate_combos(
|
||||
[ComboModel(input=0, output=0), ComboModel(input=0, output=0)],
|
||||
input_positions=4,
|
||||
output_positions=2,
|
||||
)
|
||||
|
||||
def test_out_of_range_combo_rejected_on_config_load(self) -> None:
|
||||
payload = _valid_payload()
|
||||
out_of_range = payload["run"]["combos"][0]["output"] + payload["switches"]["port2"]["positions"]
|
||||
payload["run"]["combos"].append({"input": 0, "output": out_of_range})
|
||||
with self.assertRaisesRegex(ValueError, "out of range"):
|
||||
RunConfigModel.from_dict(payload)
|
||||
|
||||
|
||||
class RingValidationTest(unittest.TestCase):
|
||||
def test_capacity_and_slot_must_be_positive(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "capacity"):
|
||||
validate_ring_endpoint(RingEndpointModel(name="r", capacity=0, slot_size_bytes=16))
|
||||
with self.assertRaisesRegex(ValueError, "slot_size"):
|
||||
validate_ring_endpoint(RingEndpointModel(name="r", capacity=4, slot_size_bytes=0))
|
||||
|
||||
def test_slot_size_uint32_limit(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "uint32"):
|
||||
validate_ring_endpoint(RingEndpointModel(name="r", capacity=1, slot_size_bytes=1 << 32))
|
||||
|
||||
def test_segment_size_limit(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "maximum ring segment"):
|
||||
validate_ring_endpoint(RingEndpointModel(name="r", capacity=1 << 40, slot_size_bytes=1024))
|
||||
|
||||
def test_valid_ring_passes(self) -> None:
|
||||
validate_ring_endpoint(RingEndpointModel(name="r", capacity=8, slot_size_bytes=4096))
|
||||
|
||||
|
||||
class GprValidationTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _gpr(**overrides: object) -> GprModel:
|
||||
base = {"relative_permittivity": 4.0, "tx_geometry": [], "rx_geometry": []}
|
||||
base.update(overrides)
|
||||
return GprModel(**base) # type: ignore[arg-type]
|
||||
|
||||
def _validate(self, gpr: GprModel) -> None:
|
||||
validate_gpr_model(gpr, input_switch_positions=4, output_switch_positions=2)
|
||||
|
||||
def test_permittivity_must_be_positive(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "relative_permittivity"):
|
||||
self._validate(self._gpr(relative_permittivity=0.0))
|
||||
|
||||
def test_tx_output_pos_out_of_range(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "output_pos is out of range"):
|
||||
self._validate(self._gpr(tx_geometry=[GprTxGeometryModel(output_pos=5, x_m=0.0, y_m=0.0, z_m=0.0)]))
|
||||
|
||||
def test_tx_duplicate_output_pos(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "duplicate output_pos"):
|
||||
self._validate(self._gpr(tx_geometry=[
|
||||
GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0),
|
||||
GprTxGeometryModel(output_pos=0, x_m=1.0, y_m=0.0, z_m=0.0),
|
||||
]))
|
||||
|
||||
def test_rx_input_pos_out_of_range(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "input_pos is out of range"):
|
||||
self._validate(self._gpr(rx_geometry=[GprRxGeometryModel(input_pos=9, x_m=0.0, y_m=0.0, z_m=0.0)]))
|
||||
|
||||
def test_valid_geometry_passes(self) -> None:
|
||||
self._validate(self._gpr(
|
||||
tx_geometry=[GprTxGeometryModel(output_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)],
|
||||
rx_geometry=[GprRxGeometryModel(input_pos=0, x_m=0.0, y_m=0.0, z_m=0.0)],
|
||||
))
|
||||
|
||||
|
||||
class ParseCombosFromTextTest(unittest.TestCase):
|
||||
def test_parses_pairs(self) -> None:
|
||||
combos = parse_combos_from_text("0:0,1:0,3:1")
|
||||
self.assertEqual([(c.input, c.output) for c in combos], [(0, 0), (1, 0), (3, 1)])
|
||||
|
||||
def test_blank_text_is_empty_list(self) -> None:
|
||||
self.assertEqual(parse_combos_from_text(" "), [])
|
||||
|
||||
def test_missing_colon_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Expected input:output"):
|
||||
parse_combos_from_text("00")
|
||||
|
||||
def test_empty_side_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
parse_combos_from_text("0:")
|
||||
with self.assertRaises(ValueError):
|
||||
parse_combos_from_text(":0")
|
||||
|
||||
def test_non_integer_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "not an integer"):
|
||||
parse_combos_from_text("x:0")
|
||||
|
||||
|
||||
class RoundTripTest(unittest.TestCase):
|
||||
def test_default_config_round_trips_idempotently(self) -> None:
|
||||
once = RunConfigModel().to_dict()
|
||||
twice = RunConfigModel.from_dict(once).to_dict()
|
||||
self.assertEqual(once, twice)
|
||||
|
||||
def test_set_values_are_preserved(self) -> None:
|
||||
payload = _valid_payload()
|
||||
payload["radar"]["sweep"].update(points=401, start_hz=1.0e9, stop_hz=5.0e9)
|
||||
model = RunConfigModel.from_dict(payload)
|
||||
self.assertEqual(model.radar.sweep.points, 401)
|
||||
self.assertEqual(model.radar.sweep.start_hz, 1.0e9)
|
||||
self.assertEqual(model.radar.sweep.stop_hz, 5.0e9)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""IPC/SHM tests: encode/decode round-trips, corruption handling, ring semantics.
|
||||
|
||||
Pins the agreed contract:
|
||||
* encode -> decode is lossless for raw/preprocessed traces and result payloads;
|
||||
* a corrupt/truncated frame raises a single, catchable ValueError (never a bare
|
||||
struct.error / UnicodeDecodeError);
|
||||
* the ring is latest-wins: on overflow the oldest slot is overwritten and a slow
|
||||
reader keeps the freshest frames;
|
||||
* peek_latest returns the newest frame without consuming it;
|
||||
* opening a missing/incompatible ring fails fast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import (
|
||||
ComboKey,
|
||||
ResultBlock,
|
||||
ResultCollection,
|
||||
ResultPayload,
|
||||
SweepCollection,
|
||||
TraceData,
|
||||
)
|
||||
from python_app.orchestration.shm.decoder import (
|
||||
PREPROC_MAGIC,
|
||||
RAW_MAGIC,
|
||||
RESULT_MAGIC,
|
||||
decode_result_collection,
|
||||
decode_trace_collection,
|
||||
)
|
||||
from python_app.orchestration.shm.ring_reader import ShmRingReader
|
||||
from python_app.orchestration.shm.ring_writer import ShmRingWriter
|
||||
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
|
||||
|
||||
|
||||
def _trace(in_pos: int, out_pos: int, n: int) -> TraceData:
|
||||
"""Build a trace with float32-exact data so round-trips compare exactly."""
|
||||
freq = np.arange(n, dtype=np.float32) + 1.0
|
||||
s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||
s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||
return TraceData(combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=freq, s11=s11, s21=s21)
|
||||
|
||||
|
||||
class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
def _assert_round_trips(self, magic: int) -> None:
|
||||
collection = SweepCollection(
|
||||
collection_id=7,
|
||||
monotonic_ns=123,
|
||||
traces=[_trace(0, 0, 4), _trace(3, 1, 2)],
|
||||
capture_start_ns=10,
|
||||
capture_end_ns=20,
|
||||
)
|
||||
decoded = decode_trace_collection(serialize_trace_collection(collection, magic), magic)
|
||||
self.assertEqual(decoded.collection_id, 7)
|
||||
self.assertEqual(decoded.monotonic_ns, 123)
|
||||
self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (10, 20))
|
||||
self.assertEqual(len(decoded.traces), 2)
|
||||
for original, got in zip(collection.traces, decoded.traces):
|
||||
self.assertEqual((got.combo.input, got.combo.output), (original.combo.input, original.combo.output))
|
||||
self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz))
|
||||
self.assertTrue(np.array_equal(got.s11, original.s11))
|
||||
self.assertTrue(np.array_equal(got.s21, original.s21))
|
||||
|
||||
def test_raw_round_trips(self) -> None:
|
||||
self._assert_round_trips(RAW_MAGIC)
|
||||
|
||||
def test_preprocessed_round_trips(self) -> None:
|
||||
self._assert_round_trips(PREPROC_MAGIC)
|
||||
|
||||
def test_empty_traces_round_trip(self) -> None:
|
||||
collection = SweepCollection(collection_id=1, monotonic_ns=2, traces=[])
|
||||
decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC)
|
||||
self.assertEqual(decoded.traces, [])
|
||||
|
||||
|
||||
class ResultCollectionRoundTripTest(unittest.TestCase):
|
||||
def test_all_payload_kinds_round_trip(self) -> None:
|
||||
image = np.arange(6, dtype=np.float32).reshape((2, 3))
|
||||
table = np.array([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]], dtype=np.float32)
|
||||
collection = ResultCollection(
|
||||
collection_id=9,
|
||||
monotonic_ns=42,
|
||||
processing_duration_ns=1000,
|
||||
collection_payloads=[
|
||||
ResultPayload(
|
||||
processing_name="gpr_accumulator", kind=3,
|
||||
image_x_axis=np.array([0.0, 1.0, 2.0], dtype=np.float32),
|
||||
image_y_axis=np.array([0.0, 1.0], dtype=np.float32),
|
||||
image=image,
|
||||
),
|
||||
ResultPayload(processing_name="gpr_points", kind=4, table=table),
|
||||
],
|
||||
blocks=[
|
||||
ResultBlock(combo=ComboKey(input=1, output=0), payloads=[
|
||||
ResultPayload(
|
||||
processing_name="bscan", kind=1,
|
||||
frequency_hz=np.array([1.0, 2.0], dtype=np.float32),
|
||||
trace=np.array([1 + 1j, 2 - 2j], dtype=np.complex64),
|
||||
),
|
||||
ResultPayload(processing_name="snr", kind=2, scalar_value=2.5),
|
||||
]),
|
||||
],
|
||||
)
|
||||
decoded = decode_result_collection(serialize_result_collection(collection))
|
||||
self.assertEqual((decoded.collection_id, decoded.monotonic_ns, decoded.processing_duration_ns), (9, 42, 1000))
|
||||
acc, points = decoded.collection_payloads
|
||||
self.assertEqual(acc.processing_name, "gpr_accumulator")
|
||||
self.assertTrue(np.array_equal(acc.image, image))
|
||||
self.assertTrue(np.array_equal(points.table, table))
|
||||
block = decoded.blocks[0]
|
||||
self.assertEqual((block.combo.input, block.combo.output), (1, 0))
|
||||
self.assertTrue(np.array_equal(block.payloads[0].trace, np.array([1 + 1j, 2 - 2j], dtype=np.complex64)))
|
||||
self.assertAlmostEqual(block.payloads[1].scalar_value, 2.5)
|
||||
|
||||
|
||||
class CorruptFrameTest(unittest.TestCase):
|
||||
"""Any corruption surfaces as ValueError (the single catchable contract)."""
|
||||
|
||||
def _valid_trace_bytes(self) -> bytes:
|
||||
return serialize_trace_collection(
|
||||
SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(0, 0, 3)]), RAW_MAGIC
|
||||
)
|
||||
|
||||
def test_bad_magic(self) -> None:
|
||||
corrupt = b"\x00\x00\x00\x00" + self._valid_trace_bytes()[4:]
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(corrupt, RAW_MAGIC)
|
||||
|
||||
def test_truncated_buffer(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(self._valid_trace_bytes()[:18], RAW_MAGIC)
|
||||
|
||||
def test_absurd_trace_count(self) -> None:
|
||||
# Claims 1e6 traces but supplies none -> the first trace read runs off the end.
|
||||
corrupt = struct.pack("<IQQI", RAW_MAGIC, 1, 1, 1_000_000)
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(corrupt, RAW_MAGIC)
|
||||
|
||||
def test_unsupported_payload_kind(self) -> None:
|
||||
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 99, 0)
|
||||
with self.assertRaises(ValueError):
|
||||
decode_result_collection(corrupt)
|
||||
|
||||
def test_invalid_utf8_name(self) -> None:
|
||||
corrupt = struct.pack("<IQQQII", RESULT_MAGIC, 1, 1, 0, 1, 0) + struct.pack("<BH", 2, 1) + b"\xff"
|
||||
with self.assertRaises(ValueError):
|
||||
decode_result_collection(corrupt)
|
||||
|
||||
|
||||
class _RingTestCase(unittest.TestCase):
|
||||
"""Base case that creates/cleans named /dev/shm rings."""
|
||||
|
||||
def _ring_name(self, suffix: str = "") -> str:
|
||||
return f"/radar_test_{self._testMethodName}{suffix}"
|
||||
|
||||
def _writer(self, name: str, capacity: int, slot_size: int) -> ShmRingWriter:
|
||||
with suppress(OSError):
|
||||
(Path("/dev/shm") / name[1:]).unlink() # drop a leftover from a crashed run
|
||||
writer = ShmRingWriter(name, capacity, slot_size)
|
||||
self.addCleanup(self._cleanup, name, writer)
|
||||
return writer
|
||||
|
||||
def _reader(self, name: str, **kw: object) -> ShmRingReader:
|
||||
reader = ShmRingReader(name, **kw)
|
||||
self.addCleanup(self._safe_close, reader)
|
||||
return reader
|
||||
|
||||
@staticmethod
|
||||
def _safe_close(obj: object) -> None:
|
||||
with suppress(Exception):
|
||||
obj.close() # type: ignore[attr-defined]
|
||||
|
||||
@staticmethod
|
||||
def _cleanup(name: str, writer: ShmRingWriter) -> None:
|
||||
with suppress(Exception):
|
||||
writer.close()
|
||||
with suppress(OSError):
|
||||
(Path("/dev/shm") / name[1:]).unlink()
|
||||
|
||||
|
||||
class RingRoundTripTest(_RingTestCase):
|
||||
def test_fifo_round_trip(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=8, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
payloads = [f"frame{i}".encode() for i in range(5)]
|
||||
for p in payloads:
|
||||
self.assertTrue(writer.push(p))
|
||||
self.assertEqual([reader.pop_payload() for _ in payloads], payloads)
|
||||
self.assertIsNone(reader.pop_payload()) # empty afterwards
|
||||
|
||||
def test_oversized_payload_rejected(self) -> None:
|
||||
writer = self._writer(self._ring_name(), capacity=4, slot_size=8)
|
||||
self.assertFalse(writer.push(b"x" * 9)) # larger than the slot
|
||||
|
||||
|
||||
class RingOverflowTest(_RingTestCase):
|
||||
def test_latest_wins_drops_oldest(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=4, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
for i in range(7): # 3 more than capacity
|
||||
self.assertTrue(writer.push(f"f{i}".encode()))
|
||||
# The 4 newest survive; the 3 oldest were overwritten.
|
||||
survivors = []
|
||||
while (item := reader.pop_payload()) is not None:
|
||||
survivors.append(item)
|
||||
self.assertEqual(survivors, [b"f3", b"f4", b"f5", b"f6"])
|
||||
|
||||
|
||||
class PeekLatestTest(_RingTestCase):
|
||||
def test_peek_is_latest_and_non_consuming(self) -> None:
|
||||
name = self._ring_name()
|
||||
writer = self._writer(name, capacity=8, slot_size=64)
|
||||
reader = self._reader(name)
|
||||
self.assertIsNone(reader.peek_latest_payload()) # nothing published yet
|
||||
for i in range(3):
|
||||
writer.push(f"f{i}".encode())
|
||||
self.assertEqual(reader.peek_latest_payload(), b"f2") # newest
|
||||
self.assertEqual(reader.peek_latest_payload(), b"f2") # stable, not consumed
|
||||
self.assertEqual(reader.pop_payload(), b"f0") # consumer cursor untouched
|
||||
writer.push(b"f3")
|
||||
self.assertEqual(reader.peek_latest_payload(), b"f3") # follows the newest
|
||||
|
||||
|
||||
class RingOpenTest(_RingTestCase):
|
||||
def test_missing_ring_fails_fast(self) -> None:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
ShmRingReader("/radar_test_definitely_missing", open_timeout_s=0.1, open_poll_s=0.02)
|
||||
|
||||
def test_incompatible_header_rejected(self) -> None:
|
||||
name = "/radar_test_bad_header"
|
||||
path = Path("/dev/shm") / name[1:]
|
||||
path.write_bytes(b"\x00" * 128) # right size, wrong magic
|
||||
self.addCleanup(lambda: path.unlink(missing_ok=True))
|
||||
with self.assertRaises(RuntimeError):
|
||||
ShmRingReader(name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Storage + embedded WebUI tests.
|
||||
|
||||
Pins the agreed semantics:
|
||||
* NPZ set persistence round-trips traces + metadata (float32 wire precision);
|
||||
* vna_history export fails loud (ValueError) when no matching traces / bad stage;
|
||||
* the web bridge rejects unknown live-settings fields (HTTP 400), serves immutable
|
||||
snapshot copies, and forwards controls as Qt signals;
|
||||
* the web fan-out is latest-wins — a full client queue drops its oldest frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # before any Qt import
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
|
||||
AppWindowWebController,
|
||||
AppWindowWebMixin,
|
||||
)
|
||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402
|
||||
from python_app.storage.npz.store import NpzStore # noqa: E402
|
||||
from python_app.storage.npz.vna_history_json import ( # noqa: E402
|
||||
_complex_to_points,
|
||||
_normalize_channel,
|
||||
build_vna_history_payload,
|
||||
)
|
||||
from python_app.webui.streaming import RingBroadcaster # noqa: E402
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
global _app
|
||||
_app = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _trace(in_pos: int, out_pos: int) -> TraceData:
|
||||
return TraceData(
|
||||
combo=ComboKey(input=in_pos, output=out_pos),
|
||||
frequency_hz=np.array([1.0, 2.0], dtype=np.float32),
|
||||
s11=np.array([1 + 1j, 2 + 2j], dtype=np.complex64),
|
||||
s21=np.array([3 + 3j, 4 - 4j], dtype=np.complex64),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# NPZ set persistence
|
||||
# --------------------------------------------------------------------------- #
|
||||
class NpzStoreRoundTripTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
self.store = NpzStore(Path(self._dir.name))
|
||||
|
||||
def test_save_then_load_round_trips_traces_and_metadata(self) -> None:
|
||||
col = SweepCollection(collection_id=7, monotonic_ns=11, traces=[_trace(0, 0), _trace(1, 0)],
|
||||
capture_start_ns=100, capture_end_ns=200)
|
||||
self.store.save_set("calibration", "radar1", "set1", col)
|
||||
loaded = self.store.load_set("calibration", "radar1", "set1")
|
||||
|
||||
self.assertEqual(loaded.collection_id, 7)
|
||||
self.assertEqual((loaded.capture_start_ns, loaded.capture_end_ns), (100, 200))
|
||||
self.assertEqual(len(loaded.traces), 2)
|
||||
by_combo = {(t.combo.input, t.combo.output): t for t in loaded.traces}
|
||||
self.assertTrue(np.array_equal(by_combo[(0, 0)].s21, _trace(0, 0).s21))
|
||||
self.assertTrue(np.array_equal(by_combo[(1, 0)].frequency_hz, _trace(1, 0).frequency_hz))
|
||||
|
||||
def test_set_appears_in_listing_and_leaves_no_tmp(self) -> None:
|
||||
self.store.save_set("calibration", "radar1", "set1", SweepCollection(collection_id=1, monotonic_ns=1,
|
||||
traces=[_trace(0, 0)]))
|
||||
self.assertIn("set1", self.store.list_sets("calibration", "radar1"))
|
||||
leftover = list(Path(self._dir.name).rglob("*.tmp"))
|
||||
self.assertEqual(leftover, [])
|
||||
|
||||
def test_load_missing_set_raises(self) -> None:
|
||||
with self.assertRaises(Exception):
|
||||
self.store.load_set("calibration", "radar1", "absent")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# vna_history export
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VnaHistoryTest(unittest.TestCase):
|
||||
def _sweeps(self, *combos) -> list[SweepCollection]:
|
||||
return [SweepCollection(collection_id=1, monotonic_ns=1, traces=[_trace(i, o) for i, o in combos])]
|
||||
|
||||
def test_builds_payload_for_matching_combo(self) -> None:
|
||||
payload = build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0)
|
||||
self.assertEqual(payload["input_index"], 0)
|
||||
self.assertEqual(payload["channel"], "s21")
|
||||
self.assertEqual(payload["preprocessed_record_count"], 1)
|
||||
self.assertTrue(payload["sweep_history"])
|
||||
|
||||
def test_no_matching_traces_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=9, output_index=9)
|
||||
|
||||
def test_invalid_primary_stage_raises(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "primary_stage"):
|
||||
build_vna_history_payload([], self._sweeps((0, 0)), [], input_index=0, output_index=0, primary_stage="x")
|
||||
|
||||
def test_normalize_channel(self) -> None:
|
||||
self.assertEqual(_normalize_channel("S21"), "s21")
|
||||
self.assertEqual(_normalize_channel(" s11 "), "s11")
|
||||
with self.assertRaises(ValueError):
|
||||
_normalize_channel("s99")
|
||||
|
||||
def test_complex_to_points(self) -> None:
|
||||
self.assertEqual(_complex_to_points(np.array([1 + 2j, 3 - 4j])), [[1.0, 2.0], [3.0, -4.0]])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Web bridge controller
|
||||
# --------------------------------------------------------------------------- #
|
||||
class WebControllerTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.controller = AppWindowWebController()
|
||||
self.addCleanup(self.controller.deleteLater)
|
||||
|
||||
def test_rejects_unknown_field(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Unknown live-settings"):
|
||||
self.controller.apply_live_settings({"definitely_not_a_field": 1})
|
||||
|
||||
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}])
|
||||
self.assertIsInstance(out, list)
|
||||
|
||||
def test_snapshot_is_replaced_and_returned_as_copy(self) -> None:
|
||||
self.controller.update_snapshot(status={"running": True}, live_settings=[{"name": "x"}], frame={"seq": 1})
|
||||
status = self.controller.status()
|
||||
self.assertEqual(status, {"running": True})
|
||||
status["running"] = False # mutating the copy must not affect the controller
|
||||
self.assertTrue(self.controller.status()["running"])
|
||||
self.assertEqual(self.controller.peek_frame(), {"seq": 1})
|
||||
|
||||
def test_frame_only_updates_when_present(self) -> None:
|
||||
self.controller.update_snapshot(status={}, live_settings=[], frame={"seq": 1})
|
||||
self.controller.update_snapshot(status={}, live_settings=[], frame=None) # no new grab
|
||||
self.assertEqual(self.controller.peek_frame(), {"seq": 1}) # keeps the last frame
|
||||
|
||||
def test_controls_emit_signals(self) -> None:
|
||||
fired: list[str] = []
|
||||
self.controller.start_requested.connect(lambda: fired.append("start"))
|
||||
self.controller.stop_requested.connect(lambda: fired.append("stop"))
|
||||
self.controller.single_capture_requested.connect(lambda: fired.append("single"))
|
||||
self.controller.capture_requested.connect(lambda: fired.append("capture"))
|
||||
self.controller.start()
|
||||
self.controller.stop()
|
||||
self.controller.single_capture()
|
||||
self.controller.capture_tmp_reference()
|
||||
self.assertEqual(fired, ["start", "stop", "single", "capture"])
|
||||
|
||||
|
||||
class WebPortTest(unittest.TestCase):
|
||||
def _port_with_env(self, value: str | None) -> int:
|
||||
env = {} if value is None else {"RADAR_SYSTEM_WEBUI_PORT": value}
|
||||
with mock.patch.dict(os.environ, env, clear=False):
|
||||
if value is None:
|
||||
os.environ.pop("RADAR_SYSTEM_WEBUI_PORT", None)
|
||||
return AppWindowWebMixin._web_ui_port()
|
||||
|
||||
def test_default_when_unset_or_invalid(self) -> None:
|
||||
self.assertEqual(self._port_with_env(None), 8080)
|
||||
self.assertEqual(self._port_with_env("abc"), 8080)
|
||||
self.assertEqual(self._port_with_env("99999"), 8080) # out of range
|
||||
self.assertEqual(self._port_with_env("0"), 8080)
|
||||
|
||||
def test_valid_port(self) -> None:
|
||||
self.assertEqual(self._port_with_env("9000"), 9000)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Latest-wins fan-out
|
||||
# --------------------------------------------------------------------------- #
|
||||
class RingBroadcasterFanOutTest(unittest.TestCase):
|
||||
def test_full_client_queue_drops_oldest(self) -> None:
|
||||
async def scenario() -> None:
|
||||
broadcaster = RingBroadcaster(controller=object())
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=1)
|
||||
broadcaster.register(queue)
|
||||
broadcaster._publish({"type": "frame", "seq": 1})
|
||||
broadcaster._publish({"type": "frame", "seq": 2}) # evicts seq 1
|
||||
self.assertEqual(queue.qsize(), 1)
|
||||
self.assertEqual(queue.get_nowait(), {"type": "frame", "seq": 2}) # newest survives
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_unregister_stops_delivery(self) -> None:
|
||||
async def scenario() -> None:
|
||||
broadcaster = RingBroadcaster(controller=object())
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=4)
|
||||
broadcaster.register(queue)
|
||||
broadcaster.unregister(queue)
|
||||
broadcaster.unregister(queue) # idempotent
|
||||
broadcaster._publish({"type": "frame", "seq": 1})
|
||||
self.assertEqual(queue.qsize(), 0)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Isolated, Qt-free web frontend for the radar_system Pi appliance.
|
||||
|
||||
The web UI streams the live GPR view and exposes the pipeline controls of the
|
||||
desktop app without any Qt dependency. It depends only on the small
|
||||
:class:`~python_app.webui.controller.WebController` contract; the embedded Qt
|
||||
bridge (``gui/controllers/app_window_web_mixin.py``) implements that contract by
|
||||
forwarding to the AppWindow's existing buttons, so no control flow is duplicated.
|
||||
|
||||
- :mod:`controller` — the Qt-free control/read contract.
|
||||
- :mod:`streaming` — latest-wins fan-out of plot frames/status/settings to clients.
|
||||
- :mod:`routes` / :mod:`app` — FastAPI surface and application factory.
|
||||
- :mod:`server` — run the app on a background thread inside the host process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,43 @@
|
||||
"""FastAPI application factory for the embedded radar web UI.
|
||||
|
||||
The controller (the Qt bridge that forwards to the AppWindow) is created and
|
||||
owned by the host process and injected here. The app's only owned resource is the
|
||||
:class:`RingBroadcaster` polling task, created and torn down by the lifespan. The
|
||||
static single-page frontend is mounted at ``/`` and the JSON/WS API under
|
||||
``/api`` and ``/ws``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from python_app.webui.controller import WebController
|
||||
from python_app.webui.routes import router
|
||||
from python_app.webui.streaming import RingBroadcaster
|
||||
|
||||
_STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
|
||||
|
||||
def create_app(controller: WebController) -> FastAPI:
|
||||
"""Build the FastAPI app that serves and streams for ``controller``."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
broadcaster = RingBroadcaster(controller)
|
||||
app.state.controller = controller
|
||||
app.state.broadcaster = broadcaster
|
||||
broadcaster.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await broadcaster.stop()
|
||||
|
||||
app = FastAPI(title="Radar Web UI", lifespan=lifespan)
|
||||
app.include_router(router)
|
||||
# Mount the SPA last so the API routes above always take precedence.
|
||||
app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="static")
|
||||
return app
|
||||
@@ -0,0 +1,42 @@
|
||||
"""The Qt-free contract the web layer depends on.
|
||||
|
||||
The web layer (``app``/``routes``/``streaming``) is deliberately free of any Qt
|
||||
or hardware knowledge: it talks only to a :class:`WebController`. The embedded
|
||||
bridge in ``gui/controllers/app_window_web_mixin.py`` implements this protocol by
|
||||
forwarding control actions to the AppWindow's *existing* buttons and exposing
|
||||
read-only snapshots — so the very same desktop logic backs the browser, with no
|
||||
duplicated control flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WebController(Protocol):
|
||||
"""Control + read surface the web layer needs; implemented by the Qt bridge."""
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start a continuous run (the desktop "Start" button)."""
|
||||
|
||||
def single_capture(self) -> None:
|
||||
"""Run a single-capture acquisition (the desktop "Single Capture" button)."""
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the running pipeline (the desktop "Stop" button)."""
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
"""Capture and select a temporary reference (the desktop button)."""
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> list:
|
||||
"""Apply live processor settings; returns the current settings schema."""
|
||||
|
||||
def current_live_settings(self) -> list:
|
||||
"""Return the live-settings schema (built from the Qt widgets)."""
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Return a snapshot of pipeline/run state."""
|
||||
|
||||
def peek_frame(self) -> dict | None:
|
||||
"""Return the latest rendered-plot frame (PNG of the Qt plot), or ``None``."""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""HTTP and WebSocket routes for the embedded radar web UI.
|
||||
|
||||
Every handler is a thin shell over the :class:`WebController` (which forwards to
|
||||
the AppWindow's existing buttons) and the :class:`RingBroadcaster` (the single
|
||||
frame source). There is no ownership gating: the web UI lives inside the process
|
||||
that already owns the hardware, so its controls are simply that process's buttons.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from python_app.webui.controller import WebController
|
||||
from python_app.webui.streaming import RingBroadcaster
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _controller(request: Request) -> WebController:
|
||||
return request.app.state.controller
|
||||
|
||||
|
||||
@router.get("/api/status")
|
||||
async def get_status(request: Request) -> dict:
|
||||
return _controller(request).status()
|
||||
|
||||
|
||||
@router.post("/api/start")
|
||||
async def post_start(request: Request) -> dict:
|
||||
controller = _controller(request)
|
||||
controller.start()
|
||||
return controller.status()
|
||||
|
||||
|
||||
@router.post("/api/single_capture")
|
||||
async def post_single_capture(request: Request) -> dict:
|
||||
controller = _controller(request)
|
||||
controller.single_capture()
|
||||
return controller.status()
|
||||
|
||||
|
||||
@router.post("/api/stop")
|
||||
async def post_stop(request: Request) -> dict:
|
||||
controller = _controller(request)
|
||||
controller.stop()
|
||||
return controller.status()
|
||||
|
||||
|
||||
@router.post("/api/tmp_reference")
|
||||
async def post_tmp_reference(request: Request) -> dict:
|
||||
controller = _controller(request)
|
||||
controller.capture_tmp_reference()
|
||||
return controller.status()
|
||||
|
||||
|
||||
@router.get("/api/live_settings")
|
||||
async def get_live_settings(request: Request) -> list:
|
||||
return _controller(request).current_live_settings()
|
||||
|
||||
|
||||
@router.post("/api/live_settings")
|
||||
async def post_live_settings(request: Request, fields: dict = Body(default={})) -> list:
|
||||
try:
|
||||
return _controller(request).apply_live_settings(fields)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def ws(websocket: WebSocket) -> None:
|
||||
"""Stream frames and status to one client until it disconnects."""
|
||||
await websocket.accept()
|
||||
broadcaster: RingBroadcaster = websocket.app.state.broadcaster
|
||||
queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1)
|
||||
broadcaster.register(queue)
|
||||
try:
|
||||
while True:
|
||||
await websocket.send_json(await queue.get())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
broadcaster.unregister(queue)
|
||||
with contextlib.suppress(Exception):
|
||||
await websocket.close()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Run the web UI's FastAPI app on a background thread.
|
||||
|
||||
The radar app already owns the hardware and the Qt event loop, so the web server
|
||||
lives in a daemon thread inside that process (uvicorn brings its own asyncio loop
|
||||
for the thread). Control requests hop back to the Qt main thread via the bridge's
|
||||
queued signals — the web thread never touches Qt directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import uvicorn
|
||||
|
||||
from python_app.webui.app import create_app
|
||||
from python_app.webui.controller import WebController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebUiServer:
|
||||
"""Owns a uvicorn server bound to a controller, run on a daemon thread."""
|
||||
|
||||
def __init__(self, controller: WebController, *, host: str = "0.0.0.0", port: int = 8080) -> None:
|
||||
"""Build the server for ``controller`` (not started until :meth:`start`)."""
|
||||
config = uvicorn.Config(create_app(controller), host=host, port=port, log_level="warning")
|
||||
self._server = uvicorn.Server(config)
|
||||
self._thread = threading.Thread(target=self._serve, name="radar-webui", daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start serving on the background thread."""
|
||||
self._thread.start()
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Return whether the server thread is still running."""
|
||||
return self._thread.is_alive()
|
||||
|
||||
def _serve(self) -> None:
|
||||
"""Run uvicorn, surfacing a startup/runtime failure instead of dying silently.
|
||||
|
||||
The bind happens on this thread after ``start()`` has already returned, so a
|
||||
failure (e.g. the port is taken) would otherwise be invisible.
|
||||
"""
|
||||
try:
|
||||
self._server.run()
|
||||
except Exception: # noqa: BLE001 - log, never crash the host process
|
||||
logger.exception("Web UI server thread exited with an error")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Ask uvicorn to exit and wait briefly for the thread to unwind."""
|
||||
self._server.should_exit = True
|
||||
self._thread.join(timeout=5.0)
|
||||
@@ -0,0 +1,298 @@
|
||||
"use strict";
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Radar System web client.
|
||||
* Streams the live Qt plot as an image (latest-wins) + REST controls +
|
||||
* the processor live-settings panel, synced both ways with the desktop.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/* ---- DOM handles ------------------------------------------------- */
|
||||
const plotImg = document.getElementById("plot");
|
||||
|
||||
const btnStart = document.getElementById("btn-start");
|
||||
const btnSingle = document.getElementById("btn-single");
|
||||
const btnStop = document.getElementById("btn-stop");
|
||||
const btnTmpRef = document.getElementById("btn-tmp-ref");
|
||||
const btnApply = document.getElementById("btn-apply");
|
||||
const btnResetHistory = document.getElementById("btn-reset-history");
|
||||
|
||||
const settingsToggle = document.getElementById("settings-toggle");
|
||||
const sidePanel = document.querySelector(".side-panel");
|
||||
const settingsFields = document.getElementById("settings-fields");
|
||||
const settingsNote = document.getElementById("settings-note");
|
||||
|
||||
const runningEl = document.getElementById("stat-running");
|
||||
const processorEl = document.getElementById("stat-processor");
|
||||
const ringEl = document.getElementById("stat-ring");
|
||||
const staleEl = document.getElementById("stat-stale");
|
||||
const toastEl = document.getElementById("toast");
|
||||
|
||||
/* ---- state ------------------------------------------------------- */
|
||||
let pendingPng = null; // newest PNG (base64), shown on the next animation frame
|
||||
let lastFrameTs = 0; // performance.now() of the last received frame
|
||||
|
||||
/* ---- helpers ----------------------------------------------------- */
|
||||
function toast(message, isError) {
|
||||
toastEl.textContent = message;
|
||||
toastEl.classList.toggle("error", !!isError);
|
||||
toastEl.classList.add("show");
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => toastEl.classList.remove("show"), 2600);
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const opts = { method: body === undefined ? "GET" : "POST" };
|
||||
if (body !== undefined) {
|
||||
opts.headers = { "Content-Type": "application/json" };
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) { /* empty body */ }
|
||||
if (!res.ok) {
|
||||
const detail = (data && data.detail) || `HTTP ${res.status}`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ---- controls ---------------------------------------------------- */
|
||||
function bindControl(button, path, body) {
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api(path, body);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
bindControl(btnStart, "/api/start", {});
|
||||
bindControl(btnSingle, "/api/single_capture", {});
|
||||
bindControl(btnStop, "/api/stop", {});
|
||||
bindControl(btnTmpRef, "/api/tmp_reference", {});
|
||||
|
||||
/* ---- settings panel --------------------------------------------- */
|
||||
settingsToggle.addEventListener("click", () => sidePanel.classList.toggle("collapsed"));
|
||||
|
||||
// The form is built ENTIRELY from the schema the server derives from the Qt widgets
|
||||
// (field, group, kind, options, ranges, value, enabled). The web hardcodes nothing
|
||||
// and shows only the active mode's fields, so it always mirrors the desktop.
|
||||
const fieldInputs = {}; // field name -> { el, kind, dirty }
|
||||
let formSignature = ""; // field names currently in the form (detect mode/structure change)
|
||||
|
||||
function makeFieldRow(entry) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "field";
|
||||
const label = document.createElement("label");
|
||||
label.textContent = entry.name;
|
||||
label.htmlFor = "f_" + entry.name;
|
||||
row.appendChild(label);
|
||||
|
||||
let el;
|
||||
if (entry.kind === "bool") {
|
||||
el = document.createElement("input");
|
||||
el.type = "checkbox";
|
||||
el.checked = !!entry.value;
|
||||
} else if (entry.kind === "select") {
|
||||
el = document.createElement("select");
|
||||
for (const opt of entry.options || []) {
|
||||
const o = document.createElement("option");
|
||||
o.value = opt;
|
||||
o.textContent = opt;
|
||||
el.appendChild(o);
|
||||
}
|
||||
el.value = String(entry.value);
|
||||
} else if (entry.kind === "int" || entry.kind === "float") {
|
||||
el = document.createElement("input");
|
||||
el.type = "number";
|
||||
if (entry.min != null) el.min = entry.min;
|
||||
if (entry.max != null) el.max = entry.max;
|
||||
el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1);
|
||||
el.value = entry.value;
|
||||
} else {
|
||||
el = document.createElement("input");
|
||||
el.type = "text";
|
||||
el.value = entry.value == null ? "" : String(entry.value);
|
||||
}
|
||||
el.id = "f_" + entry.name;
|
||||
if (entry.enabled === false) el.disabled = true;
|
||||
|
||||
// Mark dirty while edited so a live push never overwrites a half-entered value.
|
||||
const markDirty = () => { fieldInputs[entry.name].dirty = true; };
|
||||
el.addEventListener("input", markDirty);
|
||||
el.addEventListener("change", markDirty);
|
||||
// Switching mode changes which settings are shown — apply it immediately.
|
||||
if (entry.name === "processor_mode") {
|
||||
el.addEventListener("change", () => applyOne("processor_mode", el.value));
|
||||
}
|
||||
row.appendChild(el);
|
||||
fieldInputs[entry.name] = { el, kind: entry.kind, dirty: false };
|
||||
return row;
|
||||
}
|
||||
|
||||
function buildSettingsForm(schema) {
|
||||
settingsFields.innerHTML = "";
|
||||
for (const key in fieldInputs) delete fieldInputs[key];
|
||||
let lastGroup = null;
|
||||
for (const entry of schema) {
|
||||
if (entry.group !== lastGroup) {
|
||||
lastGroup = entry.group;
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "group-title";
|
||||
heading.textContent = entry.group;
|
||||
settingsFields.appendChild(heading);
|
||||
}
|
||||
settingsFields.appendChild(makeFieldRow(entry));
|
||||
}
|
||||
formSignature = schema.map((e) => e.name).join(",");
|
||||
}
|
||||
|
||||
function collectFields() {
|
||||
const out = {};
|
||||
for (const key in fieldInputs) {
|
||||
const { el, kind } = fieldInputs[key];
|
||||
if (kind === "bool") out[key] = el.checked;
|
||||
else if (kind === "int") out[key] = parseInt(el.value, 10);
|
||||
else if (kind === "float") out[key] = parseFloat(el.value);
|
||||
else out[key] = el.value; // select + text (positions are sent as CSV text)
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setFieldValue(input, value) {
|
||||
const { el, kind } = input;
|
||||
if (kind === "bool") el.checked = !!value;
|
||||
else if (kind === "select") el.value = String(value);
|
||||
else el.value = value == null ? "" : value;
|
||||
}
|
||||
|
||||
function clearDirty() {
|
||||
for (const key in fieldInputs) fieldInputs[key].dirty = false;
|
||||
}
|
||||
|
||||
async function applyOne(field, value) {
|
||||
try {
|
||||
await api("/api/live_settings", { [field]: value });
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the form from a schema pushed by the desktop. Rebuild if the field set
|
||||
// changed (e.g. the mode switched); otherwise update values in place without
|
||||
// clobbering a field the operator is editing here.
|
||||
function applySettings(schema) {
|
||||
if (schema.map((e) => e.name).join(",") !== formSignature) {
|
||||
buildSettingsForm(schema);
|
||||
return;
|
||||
}
|
||||
for (const entry of schema) {
|
||||
const input = fieldInputs[entry.name];
|
||||
if (!input || input.el === document.activeElement || input.dirty) continue;
|
||||
setFieldValue(input, entry.value);
|
||||
if (entry.enabled !== undefined) input.el.disabled = entry.enabled === false;
|
||||
}
|
||||
}
|
||||
|
||||
btnApply.addEventListener("click", async () => {
|
||||
btnApply.disabled = true;
|
||||
try {
|
||||
await api("/api/live_settings", collectFields());
|
||||
clearDirty(); // applied; let live pushes update the form again
|
||||
settingsNote.textContent = "Applied.";
|
||||
} catch (err) {
|
||||
settingsNote.textContent = err.message;
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
btnApply.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
btnResetHistory.addEventListener("click", async () => {
|
||||
btnResetHistory.disabled = true;
|
||||
try {
|
||||
await api("/api/live_settings", { history_command: "clear_all" });
|
||||
settingsNote.textContent = "History reset requested.";
|
||||
} catch (err) {
|
||||
settingsNote.textContent = err.message;
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
btnResetHistory.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const cfg = await api("/api/live_settings");
|
||||
buildSettingsForm(cfg);
|
||||
} catch (err) {
|
||||
settingsNote.textContent = "Could not load settings: " + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- status ------------------------------------------------------ */
|
||||
function setStat(el, label, value, cls) {
|
||||
el.className = "stat" + (cls ? " " + cls : "");
|
||||
el.innerHTML = label + ": <b></b>";
|
||||
el.querySelector("b").textContent = value;
|
||||
}
|
||||
|
||||
function applyStatus(s) {
|
||||
if ("running" in s)
|
||||
setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off");
|
||||
if ("processor_running" in s)
|
||||
setStat(processorEl, "processor", s.processor_running ? "yes" : "no",
|
||||
s.processor_running ? "ok" : "off");
|
||||
if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—",
|
||||
s.ring_name ? "" : "off");
|
||||
}
|
||||
|
||||
/* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */
|
||||
function renderLoop() {
|
||||
if (pendingPng !== null) {
|
||||
// Show exactly what the desktop draws; the browser scales it to fit (CSS).
|
||||
plotImg.src = "data:image/png;base64," + pendingPng;
|
||||
pendingPng = null;
|
||||
}
|
||||
// Stale indicator (>2s without a frame).
|
||||
const stale = performance.now() - lastFrameTs > 2000;
|
||||
staleEl.classList.toggle("stale", stale && lastFrameTs > 0);
|
||||
staleEl.textContent = lastFrameTs === 0 ? "no data" : stale ? "stale" : "live";
|
||||
requestAnimationFrame(renderLoop);
|
||||
}
|
||||
|
||||
/* ---- WebSocket --------------------------------------------------- */
|
||||
function connectWs() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch (_) { return; }
|
||||
if (msg.type === "frame") {
|
||||
pendingPng = msg.png_b64; // latest-wins; rAF swaps the image
|
||||
lastFrameTs = performance.now();
|
||||
} else if (msg.type === "status") {
|
||||
applyStatus(msg);
|
||||
} else if (msg.type === "settings") {
|
||||
applySettings(msg.schema); // live desktop schema mirrors into the form
|
||||
}
|
||||
};
|
||||
ws.onclose = () => setTimeout(connectWs, 1500);
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
/* ---- boot -------------------------------------------------------- */
|
||||
async function init() {
|
||||
requestAnimationFrame(renderLoop);
|
||||
try {
|
||||
applyStatus(await api("/api/status"));
|
||||
} catch (err) {
|
||||
settingsNote.textContent = "Status unavailable: " + err.message;
|
||||
}
|
||||
await loadSettings();
|
||||
connectWs();
|
||||
}
|
||||
init();
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radar System</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">Radar System</div>
|
||||
<div class="controls">
|
||||
<button id="btn-start" class="btn">Start</button>
|
||||
<button id="btn-single" class="btn">Single Capture</button>
|
||||
<button id="btn-stop" class="btn">Stop</button>
|
||||
<button id="btn-tmp-ref" class="btn">Tmp Reference</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="plot-panel">
|
||||
<div class="plot-head">
|
||||
<span class="plot-title">Processor output</span>
|
||||
</div>
|
||||
<div class="canvas-wrap">
|
||||
<img id="plot" class="plot-img" alt="Live processor plot" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="side-panel">
|
||||
<div class="panel-head" id="settings-toggle">
|
||||
<span class="panel-title">Processor settings</span>
|
||||
<span class="chevron" id="settings-chevron">▾</span>
|
||||
</div>
|
||||
<div class="panel-body" id="settings-body">
|
||||
<div id="settings-fields" class="settings-fields"></div>
|
||||
<div class="settings-actions">
|
||||
<button id="btn-apply" class="btn primary">Apply</button>
|
||||
<button id="btn-reset-history" class="btn">Reset history</button>
|
||||
</div>
|
||||
<div id="settings-note" class="note"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span class="stat" id="stat-running">running: —</span>
|
||||
<span class="stat" id="stat-processor">processor: —</span>
|
||||
<span class="stat" id="stat-ring">ring: —</span>
|
||||
<span class="stat" id="stat-stale">live</span>
|
||||
</footer>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
/* Theme mirrors python_app/gui/theme.py (light Fusion palette). */
|
||||
:root {
|
||||
--bg: #f3f6fb;
|
||||
--panel: #ffffff;
|
||||
--panel-alt: #fbfdff;
|
||||
--border: #c9d4e1;
|
||||
--border-soft: #d7dee8;
|
||||
--text: #1f2937;
|
||||
--muted: #6c7b8d;
|
||||
--status: #35507a;
|
||||
--accent: #2f7ee6;
|
||||
--accent-hover: #3b8bf4;
|
||||
--btn-bg: #f8fafc;
|
||||
--btn-hover: #eef3f9;
|
||||
--btn-press: #e4ebf4;
|
||||
--disabled-text: #98a4b3;
|
||||
--disabled-bg: #f3f5f8;
|
||||
--danger: #f94144;
|
||||
--mono: "DejaVu Sans Mono", ui-monospace, monospace;
|
||||
--sans: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--status);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: var(--btn-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: var(--btn-hover); }
|
||||
.btn:active:not(:disabled) { background: var(--btn-press); }
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.btn:disabled {
|
||||
color: var(--disabled-text);
|
||||
background: var(--disabled-bg);
|
||||
border-color: var(--border-soft);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Plot */
|
||||
.plot-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.plot-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.plot-title { font-weight: 600; color: var(--status); }
|
||||
.axes-label { color: var(--muted); font-family: var(--mono); font-size: 12px; }
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #0f141c; /* matches the pyqtgraph plot background behind letterboxing */
|
||||
overflow: hidden;
|
||||
}
|
||||
#plot { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* Side panel */
|
||||
.side-panel {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.panel-title { font-weight: 600; color: var(--status); }
|
||||
.chevron { color: var(--muted); transition: transform 0.15s ease; }
|
||||
.side-panel.collapsed .chevron { transform: rotate(-90deg); }
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.side-panel.collapsed .panel-body { display: none; }
|
||||
|
||||
.settings-fields {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.group-title {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.group-title:first-child { margin-top: 4px; }
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.field label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field input[type="number"],
|
||||
.field select {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 4px 7px;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
.settings-actions .btn { flex: 1; }
|
||||
.note {
|
||||
padding: 0 12px 10px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.statusbar {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 7px 16px;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--status);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat b { color: var(--text); font-weight: 600; }
|
||||
.stat.ok b { color: #1b7a3d; }
|
||||
.stat.off b { color: var(--muted); }
|
||||
#stat-stale {
|
||||
margin-left: auto;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: #e4f0e6;
|
||||
color: #1b7a3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
#stat-stale.stale {
|
||||
background: #fde2e3;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 56px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--status);
|
||||
color: #ffffff;
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
max-width: 70vw;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
.toast.error { background: var(--danger); }
|
||||
|
||||
/* Narrow screens / phones: stack the plot above the settings, full-width controls. */
|
||||
@media (max-width: 760px) {
|
||||
.topbar { flex-wrap: wrap; }
|
||||
.controls { width: 100%; }
|
||||
.controls .btn { flex: 1 1 auto; }
|
||||
.layout { flex-direction: column; padding: 10px; gap: 10px; }
|
||||
.plot-panel { flex: none; height: 45vh; }
|
||||
.side-panel { width: auto; flex: 1; min-height: 0; }
|
||||
.field input[type="text"],
|
||||
.field input[type="number"],
|
||||
.field select { width: 130px; }
|
||||
.statusbar { gap: 12px; }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Fan-out of pipeline result frames and status to connected web clients.
|
||||
|
||||
A single broadcaster task polls the :class:`WebController` off the event loop and
|
||||
pushes the freshest frame (latest-wins) plus a slower status heartbeat to every
|
||||
registered client. Each client is a bounded ``asyncio.Queue`` with a drop-oldest
|
||||
policy, so a slow socket can never stall the producer or the loop — if a client
|
||||
falls behind it simply skips intermediate frames and always gets the newest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from python_app.webui.controller import WebController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Poll the controller this often; the C++ pipeline publishes well below this rate,
|
||||
# so this is a comfortable latest-wins cadence without busy-spinning the loop.
|
||||
_FRAME_INTERVAL_S = 0.05
|
||||
# Status is cheap but rarely changes; emit it about once a second.
|
||||
_STATUS_INTERVAL_S = 1.0
|
||||
|
||||
|
||||
class RingBroadcaster:
|
||||
"""Polls the controller and fans frames/status out to all WebSocket clients."""
|
||||
|
||||
def __init__(self, controller: WebController) -> None:
|
||||
self._controller = controller
|
||||
self._clients: set[asyncio.Queue[dict]] = set()
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._last_frame_seq: int | None = None
|
||||
self._last_settings: dict | None = None
|
||||
|
||||
def register(self, queue: asyncio.Queue[dict]) -> None:
|
||||
"""Add a client queue to receive subsequent frames and status."""
|
||||
self._clients.add(queue)
|
||||
|
||||
def unregister(self, queue: asyncio.Queue[dict]) -> None:
|
||||
"""Remove a client queue; safe to call more than once."""
|
||||
self._clients.discard(queue)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Launch the single polling task (idempotent)."""
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self._run(), name="ring-broadcaster")
|
||||
self._task.add_done_callback(self._on_task_done)
|
||||
|
||||
@staticmethod
|
||||
def _on_task_done(task: "asyncio.Task[None]") -> None:
|
||||
"""Surface an unexpected broadcaster death (the loop should never exit)."""
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
logger.error("ring broadcaster task exited unexpectedly: %r", task.exception())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the polling task and wait for it to unwind."""
|
||||
if self._task is None:
|
||||
return
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
|
||||
def _publish(self, message: dict) -> None:
|
||||
"""Push a message to every client, dropping the oldest on a full queue."""
|
||||
for queue in self._clients:
|
||||
if queue.full():
|
||||
with contextlib.suppress(asyncio.QueueEmpty):
|
||||
queue.get_nowait()
|
||||
with contextlib.suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(message)
|
||||
|
||||
def _status_message(self) -> dict:
|
||||
"""Build a status broadcast from the controller's current state."""
|
||||
return {"type": "status", **self._controller.status()}
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Poll on a fixed cadence; never block the loop or die on a bad frame."""
|
||||
loop = asyncio.get_running_loop()
|
||||
next_status = loop.time()
|
||||
while True:
|
||||
try:
|
||||
# peek_frame may touch shared memory / NumPy, so keep it off the loop.
|
||||
frame = await loop.run_in_executor(None, self._controller.peek_frame)
|
||||
if frame is not None and frame["seq"] != self._last_frame_seq:
|
||||
self._last_frame_seq = frame["seq"]
|
||||
self._publish(frame)
|
||||
|
||||
now = loop.time()
|
||||
if now >= next_status:
|
||||
self._publish(self._status_message())
|
||||
# Push live settings (Qt -> web) only when they change, so the
|
||||
# browser form mirrors desktop edits in real time without churn.
|
||||
settings = self._controller.current_live_settings()
|
||||
if settings != self._last_settings:
|
||||
self._last_settings = settings
|
||||
self._publish({"type": "settings", "schema": settings})
|
||||
next_status = now + _STATUS_INTERVAL_S
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - one bad frame must not stop streaming
|
||||
logger.warning("ring broadcaster iteration failed; continuing", exc_info=True)
|
||||
await asyncio.sleep(_FRAME_INTERVAL_S)
|
||||
Reference in New Issue
Block a user