some fixes

This commit is contained in:
Ayzen
2026-06-06 01:42:18 +03:00
parent 69a96e4c26
commit b3a8cd834f
7 changed files with 238 additions and 89 deletions
@@ -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();
}
+1 -1
View File
@@ -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
@@ -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:
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)
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,
@@ -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):
@@ -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()
+12
View File
@@ -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";
+15
View File
@@ -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;