some fixes
This commit is contained in:
@@ -192,18 +192,22 @@ auto build_payload_json(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix #48: stamp every packet with a numeric build-time generation
|
Json root{
|
||||||
// (`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{
|
|
||||||
{"ver", protocol_version},
|
{"ver", protocol_version},
|
||||||
{"tim", format_timestamp_now()},
|
{"tim", format_timestamp_now()},
|
||||||
{"gen", epoch_millis_now()},
|
|
||||||
{"sts", status},
|
{"sts", status},
|
||||||
{"obs", std::move(obs_array)},
|
{"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();
|
return root.dump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Tuple
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
HOST = "192.168.8.2"
|
HOST = "192.168.2.6"
|
||||||
PORT = 8888
|
PORT = 8888
|
||||||
CLIENT_DEVICE_ID = 0
|
CLIENT_DEVICE_ID = 0
|
||||||
MIN_TEST_VLC = 5.0
|
MIN_TEST_VLC = 5.0
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PyQt6.QtCore import QSignalBlocker
|
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.gui.runtime.constraints import validate_processing_mode_constraints
|
||||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
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_thresh", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_thresh")),
|
||||||
("gpr_snr_comp_max", "Detection", ("legacy_gpr",), _attr("_legacy_gpr_snr_comp_max")),
|
("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")),
|
("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_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_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")),
|
("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}
|
_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:
|
def _web_field_schema(field: str, group: str, widget) -> dict | None:
|
||||||
"""Describe one widget for the web form (type, options/range, value, enabled)."""
|
"""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()),
|
return {**base, "kind": "float", "value": float(widget.value()),
|
||||||
"min": float(widget.minimum()), "max": float(widget.maximum()),
|
"min": float(widget.minimum()), "max": float(widget.maximum()),
|
||||||
"step": float(widget.singleStep()), "decimals": int(widget.decimals())}
|
"step": float(widget.singleStep()), "decimals": int(widget.decimals())}
|
||||||
|
if isinstance(widget, QPlainTextEdit):
|
||||||
|
return {**base, "kind": "textarea", "value": widget.toPlainText()}
|
||||||
if isinstance(widget, QLineEdit):
|
if isinstance(widget, QLineEdit):
|
||||||
return {**base, "kind": "text", "value": widget.text()}
|
return {**base, "kind": "text", "value": widget.text()}
|
||||||
return None
|
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]:
|
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()
|
mode = window._processing_mode.currentText()
|
||||||
schema: list[dict] = []
|
schema: list[dict] = []
|
||||||
for field, group, modes, getter in _WEB_LIVE_SCHEMA:
|
for source, extra in (
|
||||||
if modes is not None and mode not in modes:
|
(_WEB_LIVE_SCHEMA, {}),
|
||||||
continue
|
(_WEB_DISPLAY_SCHEMA, {}),
|
||||||
entry = _web_field_schema(field, group, getter(window))
|
(_WEB_STABLE_SCHEMA, {"applies_on_start": True}),
|
||||||
if entry is not None:
|
):
|
||||||
schema.append(entry)
|
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
|
return schema
|
||||||
|
|
||||||
|
|
||||||
def _set_web_live_field(window, field: str, value) -> None:
|
def _set_web_live_field(window, field: str, value) -> None:
|
||||||
"""Write one web value into the desktop widget that feeds ``field``."""
|
"""Write one web value into the desktop widget that feeds ``field`` (any category)."""
|
||||||
getter = _WEB_LIVE_GETTERS.get(field)
|
getter = _WEB_FORM_GETTERS.get(field)
|
||||||
if getter is None:
|
if getter is None:
|
||||||
return
|
return
|
||||||
widget = getter(window)
|
widget = getter(window)
|
||||||
@@ -119,6 +205,8 @@ def _set_web_live_field(window, field: str, value) -> None:
|
|||||||
widget.setValue(int(float(value)))
|
widget.setValue(int(float(value)))
|
||||||
elif isinstance(widget, QDoubleSpinBox):
|
elif isinstance(widget, QDoubleSpinBox):
|
||||||
widget.setValue(float(value))
|
widget.setValue(float(value))
|
||||||
|
elif isinstance(widget, QPlainTextEdit):
|
||||||
|
widget.setPlainText(str(value))
|
||||||
elif isinstance(widget, QLineEdit):
|
elif isinstance(widget, QLineEdit):
|
||||||
widget.setText(",".join(str(int(v)) for v in value) if isinstance(value, list) else str(value))
|
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",
|
history_command: str = "none",
|
||||||
reprocess_current_result: bool = True,
|
reprocess_current_result: bool = True,
|
||||||
) -> ProcessingLiveConfig:
|
) -> 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_bscan_frequency_limits_with_radar()
|
||||||
self._sync_gpr_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())
|
values = {
|
||||||
y_max_db = float(self._pass_through_y_max_db.value())
|
field: _read_live_widget_value(getter(self))
|
||||||
visible_x_min, visible_x_max, visible_z_min, visible_z_max = self._gpr_visible_bounds()
|
for field, _group, _modes, getter in _WEB_LIVE_SCHEMA
|
||||||
config = ProcessingLiveConfig(
|
}
|
||||||
processor_mode=mode,
|
|
||||||
|
# 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_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_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),
|
reprocess_current_result=bool(reprocess_current_result),
|
||||||
history_command_seq=int(self._history_command_seq),
|
|
||||||
history_command=str(history_command),
|
history_command=str(history_command),
|
||||||
|
history_command_seq=int(self._history_command_seq),
|
||||||
|
**values,
|
||||||
)
|
)
|
||||||
return config
|
|
||||||
|
|
||||||
def _write_live_processing_config(
|
def _write_live_processing_config(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -17,13 +17,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import contextlib
|
import contextlib
|
||||||
import dataclasses
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
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"
|
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
|
||||||
_DEFAULT_PORT = 8080
|
_DEFAULT_PORT = 8080
|
||||||
@@ -32,7 +31,9 @@ _DEFAULT_PORT = 8080
|
|||||||
_HEADLESS_PLOT_SIZE = (1600, 900)
|
_HEADLESS_PLOT_SIZE = (1600, 900)
|
||||||
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
|
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
|
||||||
_GRAB_INTERVAL_S = 0.2
|
_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):
|
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()
|
||||||
@@ -89,6 +89,14 @@ function makeFieldRow(entry) {
|
|||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.textContent = entry.name;
|
label.textContent = entry.name;
|
||||||
label.htmlFor = "f_" + 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);
|
row.appendChild(label);
|
||||||
|
|
||||||
let el;
|
let el;
|
||||||
@@ -112,6 +120,10 @@ function makeFieldRow(entry) {
|
|||||||
if (entry.max != null) el.max = entry.max;
|
if (entry.max != null) el.max = entry.max;
|
||||||
el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1);
|
el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1);
|
||||||
el.value = entry.value;
|
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 {
|
} else {
|
||||||
el = document.createElement("input");
|
el = document.createElement("input");
|
||||||
el.type = "text";
|
el.type = "text";
|
||||||
|
|||||||
@@ -208,6 +208,21 @@ body {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
accent-color: var(--accent);
|
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 {
|
.settings-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user