web UI added and refactoring done

This commit is contained in:
Ayzen
2026-06-06 00:06:30 +03:00
parent 3c30a12d4a
commit af6005d68f
65 changed files with 3630 additions and 4720 deletions
+5
View File
@@ -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
+6
View File
@@ -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
+4 -3
View File
@@ -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
+36 -10
View File
@@ -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()
+49
View File
@@ -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)
+34 -21
View File
@@ -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."""
+1 -1
View File
@@ -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()
+50 -3
View File
@@ -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)
+6 -3
View File
@@ -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)
+12 -8
View File
@@ -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
+13 -8
View File
@@ -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()
+296
View File
@@ -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()
+232
View File
@@ -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()
+247
View File
@@ -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()
+212
View File
@@ -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()
+15
View File
@@ -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
+43
View File
@@ -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
+42
View File
@@ -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``."""
+87
View File
@@ -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()
+53
View File
@@ -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)
+298
View File
@@ -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();
+57
View File
@@ -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>
+289
View File
@@ -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; }
}
+105
View File
@@ -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)