diff --git a/data_acq_and_processing/processing/locator/src/payload_builder.cpp b/data_acq_and_processing/processing/locator/src/payload_builder.cpp index a55bb1d..7a4bf77 100644 --- a/data_acq_and_processing/processing/locator/src/payload_builder.cpp +++ b/data_acq_and_processing/processing/locator/src/payload_builder.cpp @@ -192,18 +192,22 @@ auto build_payload_json( }); } - // Fix #48: stamp every packet with a numeric build-time generation - // (`gen`, epoch ms). A cached snapshot re-sent verbatim to a new client - // keeps its original `gen`, so a consumer can compute the snapshot's true - // age and flag a stalled pipeline (which `tim` alone cannot express, having - // no date and rolling over at midnight). - const Json root{ + Json root{ {"ver", protocol_version}, {"tim", format_timestamp_now()}, - {"gen", epoch_millis_now()}, {"sts", status}, {"obs", std::move(obs_array)}, }; + + // `gen` is a build-time generation stamp (epoch ms) that lets a consumer compute + // a packet's true age and flag a cached/stalled snapshot — something `tim` cannot + // express, having no date and rolling over at midnight. It is currently NOT sent; + // set this flag to true to re-enable it (epoch_millis_now() is kept ready for that). + constexpr bool kEmitGenerationStamp = false; + if (kEmitGenerationStamp) { + root["gen"] = epoch_millis_now(); + } + return root.dump(); } diff --git a/locator_test_client.py b/locator_test_client.py index 47ad0ad..fd13381 100644 --- a/locator_test_client.py +++ b/locator_test_client.py @@ -6,7 +6,7 @@ import threading import time from typing import Any, Dict, Tuple -HOST = "192.168.8.2" +HOST = "192.168.2.6" PORT = 8888 CLIENT_DEVICE_ID = 0 MIN_TEST_VLC = 5.0 diff --git a/python_app/gui/controllers/app_window_config/live_processing_mixin.py b/python_app/gui/controllers/app_window_config/live_processing_mixin.py index 5233395..08c4946 100644 --- a/python_app/gui/controllers/app_window_config/live_processing_mixin.py +++ b/python_app/gui/controllers/app_window_config/live_processing_mixin.py @@ -3,7 +3,14 @@ from __future__ import annotations from PyQt6.QtCore import QSignalBlocker -from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QLineEdit, + QPlainTextEdit, + QSpinBox, +) from python_app.gui.runtime.constraints import validate_processing_mode_constraints from python_app.orchestration.live_processing_config import ProcessingLiveConfig @@ -63,6 +70,10 @@ _WEB_LIVE_SCHEMA = [ ("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_visible_x_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_min_m", "_legacy_gpr_visible_x_min_m")), + ("gpr_visible_x_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_max_m", "_legacy_gpr_visible_x_max_m")), + ("gpr_visible_z_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_min_m", "_legacy_gpr_visible_z_min_m")), + ("gpr_visible_z_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_max_m", "_legacy_gpr_visible_z_max_m")), ("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")), @@ -71,6 +82,50 @@ _WEB_LIVE_SCHEMA = [ ] _WEB_LIVE_GETTERS = {field: getter for field, _group, _modes, getter in _WEB_LIVE_SCHEMA} +# ProcessingLiveConfig fields that are NOT backed by a settings widget and are set +# directly in `_live_processing_config`: the fixed acquisition channels, the runtime +# command fields, and a constant default. Together with the schema's fields these must +# cover every config field (guarded by a unit test), so the desktop config and the web +# form can never drift apart. +_NON_WIDGET_LIVE_FIELDS = frozenset({ + "pass_through_channel", + "bscan_channel", + "reprocess_current_result", + "history_command", + "history_command_seq", + "gpr_direction_sign", +}) + +# Display toggles: GUI-only rendering choices (kept in the GUI profile, not the live +# config). They apply immediately through the same redraw as a live edit but write +# nothing to processing_live.json. +_WEB_DISPLAY_SCHEMA = [ + ("render_mode", "Display", _GPR_MODES, _dual("_gpr_render_mode", "_legacy_gpr_render_mode")), + ("subtract_mean_ascan", "Display", ("bscan",), _attr("_bscan_subtract_mean_ascan")), + ("combo_filter", "Display", ("pass_through",), _attr("_pass_through_combo_filter_input")), +] + +# Stable run_config fields (GPR medium and antenna geometry). They live in run_config and +# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them +# "applies on Start" and editing them just updates the widget for the next start. +_WEB_STABLE_SCHEMA = [ + ("relative_permittivity", "Geometry & medium", _GPR_MODES, _attr("_gpr_relative_permittivity")), + ("tx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_tx_geometry_input")), + ("rx_geometry", "Geometry & medium", _GPR_MODES, _attr("_gpr_rx_geometry_input")), +] + +# Every field the web form renders and may write back, across all three categories. +_WEB_FORM_GETTERS = { + field: getter + for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA, _WEB_STABLE_SCHEMA) + for field, _group, _modes, getter in source +} + + +def web_apply_field_names() -> frozenset[str]: + """Return the field names a web client may apply (form fields plus runtime commands).""" + return frozenset(_WEB_FORM_GETTERS) | {"history_command"} + def _web_field_schema(field: str, group: str, widget) -> dict | None: """Describe one widget for the web form (type, options/range, value, enabled).""" @@ -87,27 +142,58 @@ def _web_field_schema(field: str, group: str, widget) -> dict | None: 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, QPlainTextEdit): + return {**base, "kind": "textarea", "value": widget.toPlainText()} if isinstance(widget, QLineEdit): return {**base, "kind": "text", "value": widget.text()} return None +def _read_live_widget_value(widget): + """Return a widget's current value for the live config, by widget type. + + Shares the same type dispatch as the web schema/setter, so the desktop config + build and the browser form read every widget identically. + """ + if isinstance(widget, QComboBox): + return widget.currentText() + if isinstance(widget, QCheckBox): + return bool(widget.isChecked()) + if isinstance(widget, QSpinBox): + return int(widget.value()) + if isinstance(widget, QDoubleSpinBox): + return float(widget.value()) + if isinstance(widget, QLineEdit): + return widget.text() + raise TypeError(f"Unsupported live-settings widget: {type(widget).__name__}") + + def _build_web_settings_schema(window) -> list[dict]: - """Build the settings schema for the active mode straight from the Qt widgets.""" + """Build the settings schema for the active mode straight from the Qt widgets. + + Covers all three widget categories — live settings, GUI display toggles, and stable + run_config fields — so the web form shows exactly what the desktop processing panel + does. Stable fields are tagged ``applies_on_start`` (they take effect on the next run). + """ 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) + for source, extra in ( + (_WEB_LIVE_SCHEMA, {}), + (_WEB_DISPLAY_SCHEMA, {}), + (_WEB_STABLE_SCHEMA, {"applies_on_start": True}), + ): + for field, group, modes, getter in source: + 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, **extra}) 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) + """Write one web value into the desktop widget that feeds ``field`` (any category).""" + getter = _WEB_FORM_GETTERS.get(field) if getter is None: return widget = getter(window) @@ -119,6 +205,8 @@ def _set_web_live_field(window, field: str, value) -> None: widget.setValue(int(float(value))) elif isinstance(widget, QDoubleSpinBox): widget.setValue(float(value)) + elif isinstance(widget, QPlainTextEdit): + widget.setPlainText(str(value)) elif isinstance(widget, QLineEdit): widget.setText(",".join(str(int(v)) for v in value) if isinstance(value, list) else str(value)) @@ -145,82 +233,46 @@ class AppWindowLiveProcessingMixin: history_command: str = "none", reprocess_current_result: bool = True, ) -> ProcessingLiveConfig: - """Build live processing config from current processing widgets.""" + """Build the live processing config from the current processing widgets. + + Widget-backed fields are read through the single ``_WEB_LIVE_SCHEMA`` map — the + same source the embedded web form uses — so the desktop config and the browser + form can never expose different settings. The few fields that need + post-processing (position lists, the ordered pass-through Y range, the + normalized visible window) or are not widget-backed (the fixed acquisition + channels, the history command, the reprocess flag) are applied explicitly here. + """ self._sync_bscan_frequency_limits_with_radar() self._sync_gpr_frequency_limits_with_radar() - mode = self._processing_mode.currentText() - if mode == "legacy_gpr": - gpr_input_positions_text = self._legacy_gpr_input_positions_input.text() - gpr_output_positions_text = self._legacy_gpr_output_positions_input.text() - gpr_min_depth_m = float(self._legacy_gpr_min_depth_m.value()) - gpr_max_depth_m = float(self._legacy_gpr_max_depth_m.value()) - gpr_start_freq_mhz = float(self._legacy_gpr_start_freq_mhz.value()) - gpr_stop_freq_mhz = float(self._legacy_gpr_stop_freq_mhz.value()) - gpr_background_enabled = bool(self._legacy_gpr_background_subtract_enabled.isChecked()) - gpr_background_mean_count = int(self._legacy_gpr_background_mean_count.value()) - else: - gpr_input_positions_text = self._gpr_input_positions_input.text() - gpr_output_positions_text = self._gpr_output_positions_input.text() - gpr_min_depth_m = float(self._gpr_min_depth_m.value()) - gpr_max_depth_m = float(self._gpr_max_depth_m.value()) - gpr_start_freq_mhz = float(self._gpr_start_freq_mhz.value()) - gpr_stop_freq_mhz = float(self._gpr_stop_freq_mhz.value()) - gpr_background_enabled = bool(self._gpr_background_subtract_enabled.isChecked()) - gpr_background_mean_count = int(self._gpr_background_mean_count.value()) - y_min_db = float(self._pass_through_y_min_db.value()) - y_max_db = float(self._pass_through_y_max_db.value()) - visible_x_min, visible_x_max, visible_z_min, visible_z_max = self._gpr_visible_bounds() - config = ProcessingLiveConfig( - processor_mode=mode, + values = { + field: _read_live_widget_value(getter(self)) + for field, _group, _modes, getter in _WEB_LIVE_SCHEMA + } + + # Switch-position CSV text -> integer lists. + values["gpr_input_positions"] = self._parse_csv_int_list(values["gpr_input_positions"]) + values["gpr_output_positions"] = self._parse_csv_int_list(values["gpr_output_positions"]) + # Order-normalize the pass-through Y range so a reversed entry still works. + y_min_db, y_max_db = values["pass_through_y_min_db"], values["pass_through_y_max_db"] + values["pass_through_y_min_db"] = min(y_min_db, y_max_db) + values["pass_through_y_max_db"] = max(y_min_db, y_max_db) + # Visible window: ordered with a non-zero span, for the active mode's widgets. + ( + values["gpr_visible_x_min_m"], + values["gpr_visible_x_max_m"], + values["gpr_visible_z_min_m"], + values["gpr_visible_z_max_m"], + ) = self._gpr_visible_bounds() + + return ProcessingLiveConfig( pass_through_channel="s21", - pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()), - pass_through_y_min_db=min(y_min_db, y_max_db), - pass_through_y_max_db=max(y_min_db, y_max_db), - bscan_axis=self._bscan_axis.currentText(), bscan_channel="s21", - bscan_cut_m=float(self._bscan_cut_m.value()), - bscan_max_depth_m=float(self._bscan_max_depth_m.value()), - bscan_gain=float(self._bscan_gain.value()), - bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()), - bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()), - legacy_gpr_mode=self._legacy_gpr_config_mode.currentText(), - gpr_input_positions=self._parse_csv_int_list(gpr_input_positions_text), - gpr_output_positions=self._parse_csv_int_list(gpr_output_positions_text), - gpr_min_depth_m=gpr_min_depth_m, - gpr_max_depth_m=gpr_max_depth_m, - gpr_range_comp_power=float(self._gpr_range_comp_power.value()), - gpr_angle_comp_power=float(self._gpr_angle_comp_power.value()), - gpr_comp_power=float(self._legacy_gpr_comp_power.value()), - gpr_score_mode=self._gpr_score_mode.currentText(), - gpr_max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()), - gpr_draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()), - gpr_speed_m_s=float(self._legacy_gpr_speed_m_s.value()), - gpr_look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()), - gpr_apply_freq_phase_correction=bool( - self._legacy_gpr_apply_freq_phase_correction.isChecked() - ), - gpr_reference_mode=self._legacy_gpr_reference_mode.currentText(), - gpr_snr_thresh=float(self._legacy_gpr_snr_thresh.value()), - gpr_snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()), - gpr_start_freq_mhz=gpr_start_freq_mhz, - gpr_stop_freq_mhz=gpr_stop_freq_mhz, - gpr_background_subtract_enabled=gpr_background_enabled, - gpr_background_mean_count=gpr_background_mean_count, - gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), - 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), + history_command_seq=int(self._history_command_seq), + **values, ) - return config def _write_live_processing_config( self, diff --git a/python_app/gui/controllers/app_window_web_mixin.py b/python_app/gui/controllers/app_window_web_mixin.py index 4ac30e6..2635379 100644 --- a/python_app/gui/controllers/app_window_web_mixin.py +++ b/python_app/gui/controllers/app_window_web_mixin.py @@ -17,13 +17,12 @@ 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 +from python_app.gui.controllers.app_window_config.live_processing_mixin import web_apply_field_names _WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT" _DEFAULT_PORT = 8080 @@ -32,7 +31,9 @@ _DEFAULT_PORT = 8080 _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)) +# Field names a web client may apply: the form's live/display/stable fields plus the +# history command. Derived from the single schema so it can never drift from the form. +_LIVE_FIELD_NAMES = web_apply_field_names() class AppWindowWebController(QObject): diff --git a/python_app/tests/test_live_settings_schema.py b/python_app/tests/test_live_settings_schema.py new file mode 100644 index 0000000..d49ad13 --- /dev/null +++ b/python_app/tests/test_live_settings_schema.py @@ -0,0 +1,65 @@ +"""Guard tests keeping the live-settings schema and the config builder in lock-step. + +`_live_processing_config` builds ProcessingLiveConfig by reading every widget through +`_WEB_LIVE_SCHEMA` (the same map the embedded web form renders from), plus a small set +of fields set explicitly (`_NON_WIDGET_LIVE_FIELDS`). These tests fail the moment a new +config field is added without being placed in one of those two sets — which is exactly +the drift that previously hid GPR settings from the web form. +""" + +from __future__ import annotations + +import dataclasses as dc +import unittest + +from python_app.gui.controllers.app_window_config.live_processing_mixin import ( + _NON_WIDGET_LIVE_FIELDS, + _WEB_DISPLAY_SCHEMA, + _WEB_LIVE_GETTERS, + _WEB_STABLE_SCHEMA, + web_apply_field_names, +) +from python_app.orchestration.live_processing_config import ProcessingLiveConfig + + +class LiveSettingsSchemaCoverageTest(unittest.TestCase): + @staticmethod + def _config_fields() -> set[str]: + return {field.name for field in dc.fields(ProcessingLiveConfig)} + + def test_schema_plus_non_widget_covers_every_config_field(self) -> None: + schema_fields = set(_WEB_LIVE_GETTERS) + covered = schema_fields | _NON_WIDGET_LIVE_FIELDS + config_fields = self._config_fields() + self.assertEqual( + covered, + config_fields, + msg=( + f"uncovered config fields: {sorted(config_fields - covered)}; " + f"stray names: {sorted(covered - config_fields)}" + ), + ) + + def test_widget_and_non_widget_sets_are_disjoint(self) -> None: + # A field is either widget-backed (in the schema) or explicitly non-widget — never both. + self.assertEqual(set(_WEB_LIVE_GETTERS) & _NON_WIDGET_LIVE_FIELDS, set()) + + def test_display_and_stable_fields_are_not_live_config(self) -> None: + # Display toggles (GUI profile) and stable fields (run_config) must never collide + # with live ProcessingLiveConfig fields — they take different write paths. + extra = {f for f, *_ in _WEB_DISPLAY_SCHEMA} | {f for f, *_ in _WEB_STABLE_SCHEMA} + self.assertEqual(extra & self._config_fields(), set()) + self.assertEqual(extra & set(_WEB_LIVE_GETTERS), set()) + + def test_web_apply_names_cover_the_whole_form(self) -> None: + form_fields = ( + set(_WEB_LIVE_GETTERS) + | {f for f, *_ in _WEB_DISPLAY_SCHEMA} + | {f for f, *_ in _WEB_STABLE_SCHEMA} + ) + # The web may apply every form field plus the history command, and nothing else. + self.assertEqual(web_apply_field_names(), form_fields | {"history_command"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_app/webui/static/app.js b/python_app/webui/static/app.js index 751b98b..49d5219 100644 --- a/python_app/webui/static/app.js +++ b/python_app/webui/static/app.js @@ -89,6 +89,14 @@ function makeFieldRow(entry) { const label = document.createElement("label"); label.textContent = entry.name; label.htmlFor = "f_" + entry.name; + if (entry.applies_on_start) { + // Stable run_config field: editing it here takes effect on the next pipeline start. + const hint = document.createElement("span"); + hint.className = "on-start-hint"; + hint.textContent = " (on Start)"; + hint.title = "Applied when the pipeline next starts"; + label.appendChild(hint); + } row.appendChild(label); let el; @@ -112,6 +120,10 @@ function makeFieldRow(entry) { if (entry.max != null) el.max = entry.max; el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1); el.value = entry.value; + } else if (entry.kind === "textarea") { + el = document.createElement("textarea"); + el.rows = 3; + el.value = entry.value == null ? "" : String(entry.value); } else { el = document.createElement("input"); el.type = "text"; diff --git a/python_app/webui/static/style.css b/python_app/webui/static/style.css index 7047342..d57cd2b 100644 --- a/python_app/webui/static/style.css +++ b/python_app/webui/static/style.css @@ -208,6 +208,21 @@ body { flex-shrink: 0; accent-color: var(--accent); } +.field textarea { + width: 160px; + flex-shrink: 0; + min-height: 52px; + resize: vertical; + 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 textarea:focus { outline: none; border-color: var(--accent); } +.on-start-hint { color: var(--muted); font-size: 11px; font-style: italic; } .settings-actions { display: flex;