Rework radar system
This commit is contained in:
@@ -1 +1 @@
|
||||
"""Controller mixins and orchestration helpers for GUI windows."""
|
||||
"""Controllers with explicit GUI and runtime dependencies."""
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Focused mixins used by the AppWindow config facade."""
|
||||
|
||||
from python_app.gui.controllers.app_window_config.live_processing_mixin import (
|
||||
AppWindowLiveProcessingMixin,
|
||||
)
|
||||
from python_app.gui.controllers.app_window_config.profile_io_mixin import (
|
||||
AppWindowConfigProfileIOMixin,
|
||||
)
|
||||
from python_app.gui.controllers.app_window_config.radar_limits_mixin import (
|
||||
AppWindowRadarLimitsMixin,
|
||||
)
|
||||
from python_app.gui.controllers.app_window_config.state_builders import (
|
||||
AppWindowConfigStateBuildersMixin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AppWindowConfigProfileIOMixin",
|
||||
"AppWindowConfigStateBuildersMixin",
|
||||
"AppWindowLiveProcessingMixin",
|
||||
"AppWindowRadarLimitsMixin",
|
||||
]
|
||||
@@ -1,675 +0,0 @@
|
||||
"""Live-processing bindings and runtime reactions for the main window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
import math
|
||||
import time
|
||||
|
||||
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.gui.antenna_geometry_editor import AntennaGeometryEditor
|
||||
from python_app.models.run_config_model import GprRxGeometryModel, GprTxGeometryModel
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
_GPR_MODES = ("gpr", "legacy_gpr")
|
||||
# Modes that consume the shared gpr_* live-config fields (band, depth gate, antenna
|
||||
# selection, motion): both GPR imagers plus the 3D SAR processor.
|
||||
_GPR_LIKE_MODES = ("gpr", "legacy_gpr", "sar3d")
|
||||
|
||||
|
||||
def _attr(name: str):
|
||||
"""Widget getter for a field backed by a single fixed widget."""
|
||||
return lambda window: getattr(window, name)
|
||||
|
||||
|
||||
def _dual(default_attr: str, mode_attrs: dict[str, str] | None = None):
|
||||
"""Widget getter for a field with per-mode widgets (gpr / legacy_gpr / sar3d).
|
||||
|
||||
`mode_attrs` maps a processor mode to its dedicated widget attribute; any
|
||||
other mode (including inactive ones) falls back to `default_attr`.
|
||||
"""
|
||||
attrs = dict(mode_attrs or {})
|
||||
return lambda window: getattr(
|
||||
window, attrs.get(window._processing_mode.currentText(), default_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")),
|
||||
("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")),
|
||||
("tx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_tx_geometry_input")),
|
||||
("rx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_rx_geometry_input")),
|
||||
("gpr_input_positions", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_input_positions_input", {"legacy_gpr": "_legacy_gpr_input_positions_input", "sar3d": "_sar3d_input_positions_input"})),
|
||||
("gpr_output_positions", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_output_positions_input", {"legacy_gpr": "_legacy_gpr_output_positions_input", "sar3d": "_sar3d_output_positions_input"})),
|
||||
("gpr_min_depth_m", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_min_depth_m", {"legacy_gpr": "_legacy_gpr_min_depth_m", "sar3d": "_sar3d_min_depth_m"})),
|
||||
("gpr_max_depth_m", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_max_depth_m", {"legacy_gpr": "_legacy_gpr_max_depth_m", "sar3d": "_sar3d_max_depth_m"})),
|
||||
("gpr_start_freq_mhz", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_start_freq_mhz", {"legacy_gpr": "_legacy_gpr_start_freq_mhz", "sar3d": "_sar3d_start_freq_mhz"})),
|
||||
("gpr_stop_freq_mhz", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_stop_freq_mhz", {"legacy_gpr": "_legacy_gpr_stop_freq_mhz", "sar3d": "_sar3d_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_score_mode", "Imaging", ("gpr",), _attr("_gpr_score_mode")),
|
||||
("gpr_background_subtract_enabled", "Imaging", _GPR_MODES, _dual("_gpr_background_subtract_enabled", {"legacy_gpr": "_legacy_gpr_background_subtract_enabled"})),
|
||||
("gpr_background_mean_count", "Imaging", _GPR_MODES, _dual("_gpr_background_mean_count", {"legacy_gpr": "_legacy_gpr_background_mean_count"})),
|
||||
("gpr_remove_sidelobe_objects_enabled", "Imaging", ("gpr",), _attr("_gpr_remove_sidelobe_objects_enabled")),
|
||||
("gpr_object_min_frac", "Detection", ("gpr",), _attr("_gpr_object_min_frac")),
|
||||
("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_object_approach_min_frames", "Detection", ("gpr",), _attr("_gpr_object_approach_min_frames")),
|
||||
("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_visible_x_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_min_m", {"legacy_gpr": "_legacy_gpr_visible_x_min_m"})),
|
||||
("gpr_visible_x_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_x_max_m", {"legacy_gpr": "_legacy_gpr_visible_x_max_m"})),
|
||||
("gpr_visible_z_min_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_min_m", {"legacy_gpr": "_legacy_gpr_visible_z_min_m"})),
|
||||
("gpr_visible_z_max_m", "Visible window", _GPR_MODES, _dual("_gpr_visible_z_max_m", {"legacy_gpr": "_legacy_gpr_visible_z_max_m"})),
|
||||
("gpr_motion_mode", "Motion", ("gpr",), _attr("_gpr_motion_mode")),
|
||||
("gpr_look_angle_deg", "Motion", _GPR_LIKE_MODES, _dual("_gpr_look_angle_deg", {"legacy_gpr": "_legacy_gpr_look_angle_deg", "sar3d": "_sar3d_look_angle_deg"})),
|
||||
("gpr_direction_sign", "Motion", ("gpr",), _attr("_gpr_direction_sign")),
|
||||
("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", _GPR_LIKE_MODES, _dual("_gpr_ignore_socket_speed_enabled", {"legacy_gpr": "_legacy_gpr_ignore_socket_speed_enabled", "sar3d": "_sar3d_ignore_socket_speed_enabled"})),
|
||||
("gpr_speed_m_s", "Motion", _GPR_LIKE_MODES, _dual("_gpr_speed_m_s", {"legacy_gpr": "_legacy_gpr_speed_m_s", "sar3d": "_sar3d_speed_m_s"})),
|
||||
("sar3d_frame_radius", "3D SAR", ("sar3d",), _attr("_sar3d_frame_radius")),
|
||||
("sar3d_voxel_m", "3D SAR", ("sar3d",), _attr("_sar3d_voxel_m")),
|
||||
("sar3d_bp_ground_depth_m", "3D SAR", ("sar3d",), _attr("_sar3d_bp_ground_depth_m")),
|
||||
("sar3d_comp_range_power", "3D SAR", ("sar3d",), _attr("_sar3d_comp_range_power")),
|
||||
("sar3d_use_compensation", "3D SAR", ("sar3d",), _attr("_sar3d_use_compensation")),
|
||||
("sar3d_normalize_each_frame", "3D SAR", ("sar3d",), _attr("_sar3d_normalize_each_frame")),
|
||||
]
|
||||
_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",
|
||||
"settings_revision",
|
||||
})
|
||||
|
||||
# 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": "_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")),
|
||||
]
|
||||
|
||||
# Every field the web form renders and may write back.
|
||||
_WEB_FORM_GETTERS = {
|
||||
field: getter
|
||||
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_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)."""
|
||||
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, AntennaGeometryEditor):
|
||||
return {**base, "kind": "geometry", "value": widget.records(),
|
||||
"position_field": widget.position_field, "position_count": widget.position_count}
|
||||
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()
|
||||
if isinstance(widget, AntennaGeometryEditor):
|
||||
return widget.records()
|
||||
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.
|
||||
|
||||
Covers live settings and GUI display toggles, matching the desktop panel.
|
||||
"""
|
||||
mode = window._processing_mode.currentText()
|
||||
schema: list[dict] = []
|
||||
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA):
|
||||
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)
|
||||
return schema
|
||||
|
||||
|
||||
def _set_widget_value(window, widget, value) -> None:
|
||||
"""Validate and write a web value, also used when rolling back a rejected batch."""
|
||||
if isinstance(widget, QComboBox):
|
||||
if widget.findText(str(value)) < 0:
|
||||
raise ValueError(f"Unknown setting option: {value}")
|
||||
window._set_combo_current_text(widget, str(value))
|
||||
elif isinstance(widget, QCheckBox):
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("Checkbox value must be true or false")
|
||||
widget.setChecked(value)
|
||||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
numeric = float(value)
|
||||
if not math.isfinite(numeric) or not widget.minimum() <= numeric <= widget.maximum():
|
||||
raise ValueError("Numeric setting is outside its allowed range")
|
||||
if isinstance(widget, QSpinBox) and numeric != int(numeric):
|
||||
raise ValueError("Setting requires an integer")
|
||||
widget.setValue(int(numeric) if isinstance(widget, QSpinBox) else numeric)
|
||||
elif isinstance(widget, AntennaGeometryEditor):
|
||||
widget.set_records(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."""
|
||||
|
||||
@staticmethod
|
||||
def _is_gpr_processing_mode(mode: str) -> bool:
|
||||
"""Return whether a mode uses GPR result payloads and plot surface."""
|
||||
return mode in {"gpr", "legacy_gpr"}
|
||||
|
||||
@staticmethod
|
||||
def _is_gpr_like_processing_mode(mode: str) -> bool:
|
||||
"""Return whether a mode consumes the shared gpr_* fields and Tx/Rx geometry.
|
||||
|
||||
Covers both GPR imagers and the 3D SAR processor. The latter renders on its
|
||||
own plot surface, so GPR-object/locator branches must keep using
|
||||
:meth:`_is_gpr_processing_mode`; this predicate gates only the shared
|
||||
behaviors (result drain on settings changes, geometry page visibility).
|
||||
"""
|
||||
return mode in _GPR_LIKE_MODES
|
||||
|
||||
def _validate_processing_mode_selection(self, mode: str) -> None:
|
||||
"""Validate requested processing mode against current stable/live GUI state."""
|
||||
validate_processing_mode_constraints(
|
||||
mode,
|
||||
self._build_config(),
|
||||
self._live_processing_config(),
|
||||
)
|
||||
|
||||
def _live_processing_config(
|
||||
self,
|
||||
*,
|
||||
history_command: str = "none",
|
||||
reprocess_current_result: bool = True,
|
||||
) -> ProcessingLiveConfig:
|
||||
"""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()
|
||||
|
||||
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",
|
||||
bscan_channel="s21",
|
||||
reprocess_current_result=bool(reprocess_current_result),
|
||||
history_command=str(history_command),
|
||||
history_command_seq=int(self._history_command_seq),
|
||||
settings_revision=getattr(self, "_processing_settings_revision", 0),
|
||||
**values,
|
||||
)
|
||||
|
||||
def _write_live_processing_config(
|
||||
self,
|
||||
*,
|
||||
history_command: str = "none",
|
||||
bump_history_seq: bool = False,
|
||||
) -> None:
|
||||
"""Persist current live processing config to runtime JSON file.
|
||||
|
||||
Every change of the file makes the processor reprocess its retained sweeps,
|
||||
except while the GUI recomputes the B-scan itself by feeding its own, longer
|
||||
history back (AppWindowBscanReplayMixin): the processor's replay would then
|
||||
only duplicate that work and race it for the results ring.
|
||||
"""
|
||||
if bump_history_seq:
|
||||
self._history_command_seq += 1
|
||||
self._processing_settings_revision = time.monotonic_ns()
|
||||
self._live_config_writer.write(
|
||||
self._live_processing_config(
|
||||
history_command=history_command,
|
||||
reprocess_current_result=not self._can_reprocess_history(),
|
||||
)
|
||||
)
|
||||
active = getattr(self, "_active_run_config", None)
|
||||
if active is not None and self._supervisor.is_running():
|
||||
active.gpr.tx_geometry = [GprTxGeometryModel(**entry) for entry in self._gpr_tx_geometry_input.records()]
|
||||
active.gpr.rx_geometry = [GprRxGeometryModel(**entry) for entry in self._gpr_rx_geometry_input.records()]
|
||||
if getattr(self, "_await_processing_revision", False):
|
||||
self._required_processing_revision = self._processing_settings_revision
|
||||
|
||||
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 destructive history command is not a settings edit: it routes to the very
|
||||
same handler the desktop's "Clear History" / "Remove Last" button uses, so the
|
||||
browser drains the ring readers, empties the runtime histories, clears the
|
||||
render caches and the processor's replay state, and redraws — rather than only
|
||||
nudging the processor while the on-screen history stays put.
|
||||
"""
|
||||
try:
|
||||
history_command = str(fields.get("history_command", "none"))
|
||||
if history_command not in {"none", "clear_all", "remove_last"}:
|
||||
raise ValueError("Unknown history command")
|
||||
if history_command != "none" and set(fields) != {"history_command"}:
|
||||
raise ValueError("History commands must be sent separately from settings")
|
||||
if history_command == "clear_all":
|
||||
self._clear_all_runtime_history()
|
||||
return
|
||||
if history_command == "remove_last":
|
||||
self._remove_last_runtime_history()
|
||||
return
|
||||
|
||||
settings = {
|
||||
name: value
|
||||
for name, value in fields.items()
|
||||
if name not in {"history_command", "history_command_seq"}
|
||||
}
|
||||
# Log only field names (not values) so remote edits are traceable
|
||||
# without recording arbitrary client-supplied payloads.
|
||||
self._log_debug(f"Applying web live settings: fields={sorted(settings)}.")
|
||||
self._suppress_live_settings_handler = True
|
||||
try:
|
||||
with ExitStack() as blockers:
|
||||
snapshots = []
|
||||
try:
|
||||
# Set the mode under a blocker before resolving per-mode fields.
|
||||
ordered = sorted(settings, key=lambda name: name != "processor_mode")
|
||||
for name in ordered:
|
||||
widget = _WEB_FORM_GETTERS[name](self)
|
||||
blockers.enter_context(QSignalBlocker(widget))
|
||||
snapshots.append((widget, _read_live_widget_value(widget)))
|
||||
_set_widget_value(self, widget, settings[name])
|
||||
self._validate_processing_mode_selection(self._processing_mode.currentText())
|
||||
except Exception:
|
||||
for widget, value in reversed(snapshots):
|
||||
_set_widget_value(self, widget, value)
|
||||
raise
|
||||
finally:
|
||||
self._suppress_live_settings_handler = False
|
||||
self._sync_socket_speed_controls()
|
||||
if {"tx_geometry", "rx_geometry"} & settings.keys():
|
||||
self._invalidate_object_calibration_after_geometry_change()
|
||||
if {"tx_geometry", "rx_geometry", "relative_permittivity"} & settings.keys():
|
||||
self._await_processing_revision = True
|
||||
if "processor_mode" in settings:
|
||||
self._on_processing_mode_changed(self._processing_mode.currentText())
|
||||
else:
|
||||
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_gpr_geometry_changed(self) -> None:
|
||||
if getattr(self, "_suppress_live_settings_handler", False):
|
||||
return
|
||||
self._invalidate_object_calibration_after_geometry_change()
|
||||
self._await_processing_revision = True
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _invalidate_object_calibration_after_geometry_change(self) -> None:
|
||||
"""Disable a correction tied to another antenna layout and reload the preprocessor."""
|
||||
selected = self._selected_preprocess_sets.get("s21_object_calibration", "")
|
||||
was_running = self._supervisor.is_running()
|
||||
active_config = getattr(self, "_active_run_config", None)
|
||||
active = active_config.preprocess.s21.object_calibration.set_name if was_running and active_config else ""
|
||||
if not selected and not active:
|
||||
return
|
||||
from python_app.workflows.mimo_calibration import validate_for_config
|
||||
|
||||
geometry = {"gpr": {
|
||||
"tx_geometry": self._gpr_tx_geometry_input.records(),
|
||||
"rx_geometry": self._gpr_rx_geometry_input.records(),
|
||||
}}
|
||||
selections = [(selected, self._selected_preprocess_radar_key)]
|
||||
if active:
|
||||
selections.append((active, self._radar_key(active_config)))
|
||||
invalid = set()
|
||||
for name, radar_key in dict.fromkeys(selections):
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
validate_for_config(self._store.load_object_calibration(radar_key, name), geometry)
|
||||
except Exception as exc: # noqa: BLE001 - reject unavailable or incompatible saved corrections
|
||||
invalid.add(name)
|
||||
self._log_warning(f"Object calibration {name} disabled for changed antenna geometry: {exc}")
|
||||
if not invalid:
|
||||
return
|
||||
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
if active in invalid:
|
||||
self._stop_run()
|
||||
if selected in invalid:
|
||||
self._selected_preprocess_sets["s21_object_calibration"] = ""
|
||||
self._refresh_preprocess_summary_labels()
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
|
||||
if active in invalid and self._pipeline_resume_allowed(resume_seq):
|
||||
self._start_run()
|
||||
|
||||
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_like_processing_mode(current_mode):
|
||||
self._drain_results_until_quiet(timeout_s=0.05, poll_s=0.005)
|
||||
|
||||
self._write_live_processing_config()
|
||||
if current_mode == "bscan":
|
||||
if self._can_reprocess_history():
|
||||
# Recompute every column on screen from the GUI's history. Debounced,
|
||||
# so dragging a spin box sends one burst instead of one per step.
|
||||
self._schedule_bscan_history_reprocess()
|
||||
else:
|
||||
# The processor reprocesses its own newest sweeps; take them in.
|
||||
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||
self._sync_bscan_history_from_results()
|
||||
self._draw_bscan_heatmap_from_history()
|
||||
elif self._is_gpr_like_processing_mode(current_mode):
|
||||
latest = self._drain_results_until_quiet(timeout_s=0.8, poll_s=0.02)
|
||||
collection = latest
|
||||
if collection is None and self._result_history:
|
||||
collection = self._result_history[-1]
|
||||
if current_mode == "sar3d":
|
||||
self._clear_sar3d_plot()
|
||||
if collection is not None:
|
||||
self._draw_results(collection)
|
||||
elif collection is None or not self._draw_results(collection):
|
||||
self._clear_gpr_plot()
|
||||
elif self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
else:
|
||||
self._clear_trace_plots()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to update live processing settings", exc)
|
||||
|
||||
def _sync_socket_speed_controls(self) -> None:
|
||||
"""Enable each GPR-like speed field only while its page ignores the socket speed.
|
||||
|
||||
Unchecked, the speed a locator client streams drives the processing and the
|
||||
field only stands in while no client sends one, so it must not look editable.
|
||||
"""
|
||||
for checkbox, speed in (
|
||||
(self._gpr_ignore_socket_speed_enabled, self._gpr_speed_m_s),
|
||||
(self._legacy_gpr_ignore_socket_speed_enabled, self._legacy_gpr_speed_m_s),
|
||||
(self._sar3d_ignore_socket_speed_enabled, self._sar3d_speed_m_s),
|
||||
):
|
||||
speed.setEnabled(checkbox.isChecked())
|
||||
|
||||
def _on_ignore_socket_speed_toggled(self, *_args) -> None:
|
||||
"""Switch the speed authority between the socket client and the GUI field."""
|
||||
self._sync_socket_speed_controls()
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _on_gpr_visual_settings_changed(self, *_args) -> None:
|
||||
"""Redraw current GPR result using updated GUI-only render settings."""
|
||||
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
|
||||
return
|
||||
try:
|
||||
if self._result_history and self._draw_results(self._result_history[-1]):
|
||||
return
|
||||
self._clear_gpr_plot()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to update GPR render settings", exc)
|
||||
|
||||
def _on_gpr_locator_threshold_changed(self, *_args) -> None:
|
||||
"""Redraw GPR view; locator delivery lives in the C++ data_processor."""
|
||||
self._on_gpr_visual_settings_changed()
|
||||
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
|
||||
return
|
||||
try:
|
||||
self._write_live_processing_config()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to update locator GPR threshold", exc)
|
||||
|
||||
def _on_gpr_locator_window_changed(self, *_args) -> None:
|
||||
"""Redraw GPR view; locator delivery lives in the C++ data_processor."""
|
||||
self._on_gpr_visual_settings_changed()
|
||||
if not self._is_gpr_processing_mode(self._processing_mode.currentText()):
|
||||
return
|
||||
try:
|
||||
self._write_live_processing_config()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to update locator GPR window", exc)
|
||||
|
||||
def _set_processing_mode_page(self, mode: str) -> None:
|
||||
"""Show the parameter page for `mode` without changing runtime state."""
|
||||
mode_to_page = {
|
||||
"pass_through": 0,
|
||||
"bscan": 1,
|
||||
"gpr": 2,
|
||||
"legacy_gpr": 3,
|
||||
"sar3d": 4,
|
||||
}
|
||||
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
|
||||
self._gpr_common_page.setVisible(self._is_gpr_like_processing_mode(mode))
|
||||
current_page = self._processing_mode_pages.currentWidget()
|
||||
if current_page is not None:
|
||||
self._processing_mode_pages.setFixedHeight(current_page.sizeHint().height())
|
||||
self._processing_mode_pages.updateGeometry()
|
||||
|
||||
def _on_processing_mode_changed(self, mode: str) -> None:
|
||||
"""Switch processing parameter page and refresh corresponding visualization."""
|
||||
previous_mode = getattr(self, "_active_processing_mode", "pass_through")
|
||||
if mode != previous_mode:
|
||||
try:
|
||||
self._validate_processing_mode_selection(mode)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
with QSignalBlocker(self._processing_mode):
|
||||
self._set_combo_current_text(self._processing_mode, previous_mode)
|
||||
self._show_error(
|
||||
f"Cannot switch processing mode to {mode}",
|
||||
details=str(exc) or type(exc).__name__,
|
||||
)
|
||||
return
|
||||
|
||||
self._active_processing_mode = mode
|
||||
self._set_plot_mode(mode)
|
||||
self._set_processing_mode_page(mode)
|
||||
self._on_processing_live_settings_changed()
|
||||
if mode == "pass_through":
|
||||
self._log(
|
||||
"Processing mode selected: pass_through "
|
||||
f"(show_magnitude={self._show_magnitude_checkbox.isChecked()}, "
|
||||
f"show_phase={self._show_phase_checkbox.isChecked()}, "
|
||||
f"combos={self._pass_through_combo_filter_input.text().strip() or '<all>'}, "
|
||||
f"fixed_y={self._pass_through_fixed_y_enabled.isChecked()}, "
|
||||
f"y_range={self._pass_through_y_min_db.value():g}..{self._pass_through_y_max_db.value():g} dB)"
|
||||
)
|
||||
elif mode == "bscan":
|
||||
self._log(
|
||||
"Processing mode selected: bscan "
|
||||
f"(axis={self._bscan_axis.currentText()}, "
|
||||
f"cut={self._bscan_cut_m.value():g} m, "
|
||||
f"max_depth={self._bscan_max_depth_m.value():g} m, "
|
||||
f"gain={self._bscan_gain.value():g}, "
|
||||
f"freq={self._bscan_start_freq_mhz.value():g}..{self._bscan_stop_freq_mhz.value():g} MHz, "
|
||||
f"subtract_mean_ascan={self._bscan_subtract_mean_ascan.isChecked()})"
|
||||
)
|
||||
elif mode == "gpr":
|
||||
self._log(
|
||||
"Processing mode selected: gpr "
|
||||
f"(inputs={self._gpr_input_positions_input.text().strip() or '<all>'}, "
|
||||
f"outputs={self._gpr_output_positions_input.text().strip() or '<all>'}, "
|
||||
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
|
||||
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
|
||||
f"range_comp={self._gpr_range_comp_power.value():g}, "
|
||||
f"object_min_frac={self._gpr_object_min_frac.value():g}, "
|
||||
f"score_mode={self._gpr_score_mode.currentText()}, "
|
||||
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
||||
f"mean_count={self._gpr_background_mean_count.value()}, "
|
||||
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
|
||||
f"imaging_plane_y={self._gpr_imaging_plane_y_m.value():g} m, "
|
||||
f"render_mode={self._gpr_render_mode.currentText()}, "
|
||||
f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, "
|
||||
f"draw_top={self._gpr_draw_top_m_objects.value()})"
|
||||
)
|
||||
elif mode == "legacy_gpr":
|
||||
self._log(
|
||||
"Processing mode selected: legacy_gpr "
|
||||
f"(mode={self._legacy_gpr_config_mode.currentText()}, "
|
||||
f"inputs={self._legacy_gpr_input_positions_input.text().strip() or '<all>'}, "
|
||||
f"outputs={self._legacy_gpr_output_positions_input.text().strip() or '<all>'}, "
|
||||
f"depth={self._legacy_gpr_min_depth_m.value():g}..{self._legacy_gpr_max_depth_m.value():g} m, "
|
||||
f"freq={self._legacy_gpr_start_freq_mhz.value():g}..{self._legacy_gpr_stop_freq_mhz.value():g} MHz, "
|
||||
f"comp={self._legacy_gpr_comp_power.value():g}, "
|
||||
f"snr={self._legacy_gpr_snr_thresh.value():g}, "
|
||||
f"snr_comp_max={self._legacy_gpr_snr_comp_max.value():g}, "
|
||||
f"speed={self._legacy_gpr_speed_m_s.value():g} m/s, "
|
||||
f"look_angle={self._legacy_gpr_look_angle_deg.value():g} deg, "
|
||||
f"background_subtract={self._legacy_gpr_background_subtract_enabled.isChecked()}, "
|
||||
f"mean_count={self._legacy_gpr_background_mean_count.value()}, "
|
||||
f"render_mode={self._legacy_gpr_render_mode.currentText()}, "
|
||||
f"min_pairs={self._legacy_gpr_min_visible_pair_count.value()})"
|
||||
)
|
||||
elif mode == "sar3d":
|
||||
self._log(
|
||||
"Processing mode selected: sar3d "
|
||||
f"(INPUT_IDX={self._sar3d_input_positions_input.text().strip() or '<all>'}, "
|
||||
f"OUTPUT_IDX={self._sar3d_output_positions_input.text().strip() or '<all>'}, "
|
||||
f"F={self._sar3d_start_freq_mhz.value():g}..{self._sar3d_stop_freq_mhz.value():g} MHz, "
|
||||
f"DEPTH={self._sar3d_min_depth_m.value():g}..{self._sar3d_max_depth_m.value():g} m, "
|
||||
f"SAR_VOXEL_M={self._sar3d_voxel_m.value():g}, "
|
||||
f"LOOK_ANGLE_DEG={self._sar3d_look_angle_deg.value():g}, "
|
||||
f"SAR_FRAME_RADIUS={self._sar3d_frame_radius.value()})"
|
||||
)
|
||||
|
||||
def _clear_history_mode_caches(self) -> None:
|
||||
"""Drop cached render state for pass-through, B-scan, GPR, and 3D SAR views."""
|
||||
self._clear_bscan_plot_history()
|
||||
if hasattr(self, "_bscan_plot"):
|
||||
self._bscan_plot.clear()
|
||||
self._configure_bscan_plot_axes()
|
||||
self._clear_trace_plots()
|
||||
if hasattr(self, "_gpr_plot"):
|
||||
self._clear_gpr_plot()
|
||||
if hasattr(self, "_sar3d_plots"):
|
||||
self._clear_sar3d_plot()
|
||||
|
||||
def _redraw_after_history_deletion(self) -> None:
|
||||
"""Refresh plot immediately after destructive history deletion."""
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
if self._result_history:
|
||||
self._sync_bscan_history_from_results()
|
||||
if not self._draw_bscan_heatmap_from_history():
|
||||
self._bscan_plot.clear()
|
||||
self._configure_bscan_plot_axes()
|
||||
return
|
||||
if self._is_gpr_processing_mode(self._processing_mode.currentText()):
|
||||
if self._result_history and self._draw_results(self._result_history[-1]):
|
||||
return
|
||||
self._clear_gpr_plot()
|
||||
return
|
||||
if self._processing_mode.currentText() == "sar3d":
|
||||
self._clear_sar3d_plot()
|
||||
if self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
return
|
||||
if self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
return
|
||||
self._bscan_plot.clear()
|
||||
self._configure_bscan_plot_axes()
|
||||
self._clear_trace_plots()
|
||||
@@ -1,628 +0,0 @@
|
||||
"""Profile load/save workflows for the main window config facade."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from contextlib import ExitStack
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
|
||||
from python_app.file_io import atomic_write_json
|
||||
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
|
||||
from python_app.models.gui_profile_model import GuiProfileModel
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||
preprocess_asset_model,
|
||||
)
|
||||
|
||||
|
||||
class AppWindowConfigProfileIOMixin:
|
||||
"""Persist and restore full GUI profiles without restarting the app."""
|
||||
|
||||
def _write_gui_profile_to_path(
|
||||
self, output_path: Path, *, allow_overwrite: bool = True, dataset: bool = False,
|
||||
) -> GuiProfileModel:
|
||||
"""Save UI settings, preserving the acquisition provenance for recorded data."""
|
||||
if not allow_overwrite and output_path.exists():
|
||||
raise FileExistsError(f"Config profile output already exists: {output_path}")
|
||||
|
||||
profile = self._build_gui_profile()
|
||||
active = getattr(self, "_active_run_config", None)
|
||||
if dataset and active is not None:
|
||||
profile.run_config.radar = deepcopy(active.radar)
|
||||
profile.run_config.input_switch = deepcopy(active.input_switch)
|
||||
profile.run_config.output_switch = deepcopy(active.output_switch)
|
||||
profile.run_config.combos = deepcopy(active.combos)
|
||||
profile.run_config.preprocess = deepcopy(active.preprocess)
|
||||
profile.run_config.gpr.tx_geometry = deepcopy(active.gpr.tx_geometry)
|
||||
profile.run_config.gpr.rx_geometry = deepcopy(active.gpr.rx_geometry)
|
||||
atomic_write_json(output_path, profile.to_dict(), durable=True)
|
||||
return profile
|
||||
|
||||
def _set_combo_selection_mode(self, mode: str) -> None:
|
||||
"""Highlight current combo mode and enable only the relevant editors."""
|
||||
if self._is_matrix_radar_model_selected():
|
||||
self._run_combos_select_button.setChecked(True)
|
||||
self._single_combo_select_button.setChecked(False)
|
||||
self._combos_text.setText(self._fixed_matrix_radar_combo_text())
|
||||
self._combos_text.setEnabled(False)
|
||||
self._single_combo_output.setEnabled(True)
|
||||
self._single_combo_input.setEnabled(True)
|
||||
self._run_combos_select_button.setEnabled(False)
|
||||
self._single_combo_select_button.setEnabled(False)
|
||||
self._next_combo_button.setEnabled(True)
|
||||
return
|
||||
|
||||
text_selected = mode != "single"
|
||||
self._run_combos_select_button.setChecked(text_selected)
|
||||
self._single_combo_select_button.setChecked(not text_selected)
|
||||
self._combos_text.setEnabled(text_selected)
|
||||
self._single_combo_output.setEnabled(not text_selected)
|
||||
self._single_combo_input.setEnabled(not text_selected)
|
||||
self._run_combos_select_button.setEnabled(True)
|
||||
self._single_combo_select_button.setEnabled(True)
|
||||
self._next_combo_button.setEnabled(not text_selected)
|
||||
|
||||
def _select_display_combo(self, input_pos: int, output_pos: int) -> None:
|
||||
"""Select an available pair and redraw its cached B-scan immediately."""
|
||||
key = (int(input_pos), int(output_pos))
|
||||
if key not in self._available_display_combo_keys():
|
||||
raise ValueError(f"Unknown channel combination: {key[0]}:{key[1]}")
|
||||
self._single_combo_input.setText(str(key[0]))
|
||||
self._single_combo_output.setText(str(key[1]))
|
||||
self._on_bscan_display_combo_changed()
|
||||
|
||||
def _select_next_combo(self) -> None:
|
||||
"""Advance output first, then input, wrapping after the last configured pair."""
|
||||
available = self._available_display_combo_keys()
|
||||
if not available:
|
||||
return
|
||||
try:
|
||||
current = (int(self._single_combo_input.text()), int(self._single_combo_output.text()))
|
||||
index = available.index(current) + 1
|
||||
except ValueError:
|
||||
index = 0
|
||||
self._select_display_combo(*available[index % len(available)])
|
||||
|
||||
def _available_display_combo_keys(self) -> list[tuple[int, int]]:
|
||||
"""Return configured pairs ordered by input, then output for display navigation."""
|
||||
return sorted((combo.input, combo.output) for combo in self._defaults_config.build_runtime_combos())
|
||||
|
||||
def _is_matrix_radar_model_selected(self) -> bool:
|
||||
"""Return whether the loaded config targets a matrix-mode radar acquisition."""
|
||||
return bool(self._defaults_config.is_matrix_radar)
|
||||
|
||||
def _fixed_matrix_radar_combo_text(self) -> str:
|
||||
"""Return the canonical virtual combo matrix shown for matrix-mode radars."""
|
||||
return ",".join(
|
||||
f"{int(combo.input)}:{int(combo.output)}"
|
||||
for combo in self._defaults_config.build_runtime_combos()
|
||||
)
|
||||
|
||||
def _sync_pass_through_y_controls(self) -> None:
|
||||
"""Enable Y-range editors only when fixed Y mode is active."""
|
||||
enabled = bool(self._pass_through_fixed_y_enabled.isChecked())
|
||||
self._pass_through_y_min_db.setEnabled(enabled)
|
||||
self._pass_through_y_max_db.setEnabled(enabled)
|
||||
|
||||
def _set_radar_settings_mode(self, adc_mode: bool) -> None:
|
||||
"""Show the radar settings panel appropriate for the loaded radar model."""
|
||||
self._vna_radar_settings_panel.setVisible(not adc_mode)
|
||||
self._adc_radar_settings_panel.setVisible(adc_mode)
|
||||
|
||||
def _sync_adc_settings_controls(self) -> None:
|
||||
"""Enable optical-board controls according to enabled flag and mode."""
|
||||
if not hasattr(self, "_optical_enabled_checkbox"):
|
||||
return
|
||||
enabled = bool(self._optical_enabled_checkbox.isChecked())
|
||||
mode = self._optical_mode_combo.currentText()
|
||||
common_widgets = [
|
||||
self._optical_port_input,
|
||||
self._optical_mode_combo,
|
||||
self._optical_pi_coeff1_p_input,
|
||||
self._optical_pi_coeff1_i_input,
|
||||
self._optical_pi_coeff2_p_input,
|
||||
self._optical_pi_coeff2_i_input,
|
||||
]
|
||||
manual_widgets = [
|
||||
self._optical_manual_temp1_input,
|
||||
self._optical_manual_temp2_input,
|
||||
self._optical_manual_current1_input,
|
||||
self._optical_manual_current2_input,
|
||||
]
|
||||
variation_widgets = [
|
||||
self._optical_variation_type_combo,
|
||||
self._optical_static_temp1_input,
|
||||
self._optical_static_temp2_input,
|
||||
self._optical_static_current1_input,
|
||||
self._optical_static_current2_input,
|
||||
self._optical_min_value_input,
|
||||
self._optical_max_value_input,
|
||||
self._optical_step_input,
|
||||
self._optical_time_step_input,
|
||||
self._optical_delay_time_input,
|
||||
]
|
||||
for widget in common_widgets:
|
||||
widget.setEnabled(enabled)
|
||||
for widget in manual_widgets:
|
||||
widget.setEnabled(enabled and mode == "manual")
|
||||
for widget in variation_widgets:
|
||||
widget.setEnabled(enabled and mode == "variation")
|
||||
self._optical_manual_panel.setVisible(mode == "manual")
|
||||
self._optical_variation_panel.setVisible(mode == "variation")
|
||||
|
||||
def _apply_history_limit_from_config(self, config) -> None:
|
||||
"""Resize in-memory history buffers to match the loaded config.
|
||||
|
||||
Save-side deques use a config-independent limit so that processing-side
|
||||
ring capacities can stay small without truncating the save buffer. The
|
||||
C++ replay window still follows ring capacities, since it bounds how much
|
||||
of the history the processor can re-publish coherently.
|
||||
"""
|
||||
bscan_cpp_replay_window = self._cpp_bscan_replay_window_for_config(config)
|
||||
self._raw_history = deque(self._raw_history, maxlen=GUI_SAVE_HISTORY_LIMIT)
|
||||
self._pre_history = deque(self._pre_history, maxlen=GUI_SAVE_HISTORY_LIMIT)
|
||||
self._result_history = deque(self._result_history, maxlen=GUI_SAVE_HISTORY_LIMIT)
|
||||
self._bscan_cpp_replay_window = bscan_cpp_replay_window
|
||||
self._clear_bscan_plot_history()
|
||||
|
||||
def _save_current_config(self) -> None:
|
||||
"""Persist current full GUI profile to a user-selected JSON file."""
|
||||
try:
|
||||
suggested_path = str(self._active_profile_path)
|
||||
selected_path, _selected_filter = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save Config Profile",
|
||||
suggested_path,
|
||||
"JSON Files (*.json);;All Files (*)",
|
||||
)
|
||||
if not selected_path:
|
||||
return
|
||||
|
||||
output_path = self._normalize_profile_path(Path(selected_path))
|
||||
if not output_path.suffix:
|
||||
output_path = output_path.with_suffix(".json")
|
||||
|
||||
profile = self._write_gui_profile_to_path(output_path)
|
||||
|
||||
self._defaults_config = profile.run_config.clone()
|
||||
self._gui_defaults = profile.gui
|
||||
self._remember_active_profile_path(output_path)
|
||||
points_text = (
|
||||
"points=from Kamil ADC stream"
|
||||
if profile.run_config.is_kamil_adc
|
||||
else f"points={profile.run_config.radar.sweep.points}"
|
||||
)
|
||||
self._log(
|
||||
f"Config profile saved: path={output_path}, "
|
||||
f"combos={len(profile.run_config.combos)}, "
|
||||
f"sweep={profile.run_config.radar.sweep.start_hz:g}.."
|
||||
f"{profile.run_config.radar.sweep.stop_hz:g} Hz, "
|
||||
f"{points_text}, "
|
||||
f"ifbw={profile.run_config.radar.sweep.if_bandwidth_hz:g} Hz, "
|
||||
f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, "
|
||||
f"processing_mode={profile.gui.processing.selected_mode}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to save config profile", exc)
|
||||
|
||||
def _ensure_ready_to_load_config(self) -> bool:
|
||||
"""Return whether a config load may proceed, reporting the blocker if not.
|
||||
|
||||
Loading rebuilds every widget and resizes history, so it is refused while a
|
||||
capture sequence or the pipeline is active — the operator must stop first.
|
||||
Shared by the desktop dialog and the web loader so both honor the same rule.
|
||||
"""
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return False
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Cannot load config during active capture sequence",
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return False
|
||||
if self._supervisor.is_running():
|
||||
self._show_error(
|
||||
"Stop all pipeline processes before loading a config profile",
|
||||
details=self._process_state_details(),
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _load_config_from_path(self, config_path: Path) -> None:
|
||||
"""Load a full GUI profile from ``config_path`` and apply it to the UI.
|
||||
|
||||
This is the single authoritative load path: the desktop "Load Config" dialog
|
||||
and the web config picker both funnel here, differing only in how they obtain
|
||||
the path. The pipeline is left untouched; live settings reach a running
|
||||
data_processor immediately while stable settings stage for the next Start.
|
||||
"""
|
||||
if not self._ensure_ready_to_load_config():
|
||||
return
|
||||
try:
|
||||
normalized_path = self._normalize_profile_path(config_path)
|
||||
profile = GuiProfileModel.load_from_path(normalized_path)
|
||||
processor_running = self._supervisor.is_processor_running()
|
||||
self._apply_loaded_profile(profile, normalized_path)
|
||||
profile_kind = "legacy run config" if profile.gui is None else "full GUI profile"
|
||||
message = (
|
||||
f"Config profile loaded: path={normalized_path}, "
|
||||
f"kind={profile_kind}, "
|
||||
f"combos={len(self._defaults_config.combos)}, "
|
||||
f"processing_mode={self._processing_mode.currentText()}"
|
||||
)
|
||||
if processor_running:
|
||||
message += (
|
||||
"; data_processor is still running, so live processing settings were applied immediately "
|
||||
"and stable settings are now staged in the UI for the next Start"
|
||||
)
|
||||
self._log(message)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to load config profile", exc)
|
||||
|
||||
def _load_config_from_dialog(self) -> None:
|
||||
"""Load a full GUI profile from a user-selected JSON file (desktop button)."""
|
||||
if not self._ensure_ready_to_load_config():
|
||||
return
|
||||
selected_path, _selected_filter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Load Config Profile",
|
||||
str(self._active_profile_path),
|
||||
"JSON Files (*.json);;All Files (*)",
|
||||
)
|
||||
if not selected_path:
|
||||
return
|
||||
self._load_config_from_path(Path(selected_path))
|
||||
|
||||
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
|
||||
"""Apply an already parsed profile to GUI state without restarting the pipeline.
|
||||
|
||||
Repopulates every radar/processing/preprocess widget under signal blockers,
|
||||
resizes history buffers, and refreshes derived state. The pipeline is left
|
||||
untouched; callers handle user-facing logging and error reporting.
|
||||
"""
|
||||
self._log_debug(
|
||||
f"Applying loaded profile: path={profile_path}, "
|
||||
f"has_gui_state={profile.gui is not None}."
|
||||
)
|
||||
config = profile.run_config.clone()
|
||||
gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config)
|
||||
self._defaults_config = config
|
||||
selected_preprocess_sets = {
|
||||
key: str(preprocess_asset_model(config, key).set_name)
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS
|
||||
}
|
||||
|
||||
radio_widgets = (
|
||||
self._start_hz_input,
|
||||
self._stop_hz_input,
|
||||
self._points_input,
|
||||
self._ifbw_input,
|
||||
self._power_input,
|
||||
self._settling_ms,
|
||||
self._combos_text,
|
||||
self._single_combo_output,
|
||||
self._single_combo_input,
|
||||
self._run_combos_select_button,
|
||||
self._single_combo_select_button,
|
||||
self._processing_mode,
|
||||
self._show_magnitude_checkbox,
|
||||
self._show_phase_checkbox,
|
||||
self._pass_through_combo_filter_input,
|
||||
self._pass_through_fixed_y_enabled,
|
||||
self._pass_through_y_min_db,
|
||||
self._pass_through_y_max_db,
|
||||
self._bscan_axis,
|
||||
self._bscan_cut_m,
|
||||
self._bscan_max_depth_m,
|
||||
self._bscan_gain,
|
||||
self._bscan_start_freq_mhz,
|
||||
self._bscan_stop_freq_mhz,
|
||||
self._bscan_subtract_mean_ascan,
|
||||
self._bscan_history_window,
|
||||
self._gpr_relative_permittivity,
|
||||
self._gpr_tx_geometry_input,
|
||||
self._gpr_rx_geometry_input,
|
||||
self._gpr_input_positions_input,
|
||||
self._gpr_output_positions_input,
|
||||
self._gpr_min_depth_m,
|
||||
self._gpr_max_depth_m,
|
||||
self._gpr_range_comp_power,
|
||||
self._gpr_object_min_frac,
|
||||
self._gpr_score_mode,
|
||||
self._gpr_motion_mode,
|
||||
self._gpr_look_angle_deg,
|
||||
self._gpr_direction_sign,
|
||||
self._gpr_ignore_socket_speed_enabled,
|
||||
self._gpr_speed_m_s,
|
||||
self._gpr_max_detected_objects_to_draw,
|
||||
self._gpr_draw_top_m_objects,
|
||||
self._gpr_object_approach_min_frames,
|
||||
self._gpr_start_freq_mhz,
|
||||
self._gpr_stop_freq_mhz,
|
||||
self._gpr_background_subtract_enabled,
|
||||
self._gpr_background_mean_count,
|
||||
self._gpr_remove_sidelobe_objects_enabled,
|
||||
self._gpr_imaging_plane_y_m,
|
||||
self._gpr_render_mode,
|
||||
self._gpr_visible_x_min_m,
|
||||
self._gpr_visible_x_max_m,
|
||||
self._gpr_visible_z_min_m,
|
||||
self._gpr_visible_z_max_m,
|
||||
self._legacy_gpr_config_mode,
|
||||
self._legacy_gpr_input_positions_input,
|
||||
self._legacy_gpr_output_positions_input,
|
||||
self._legacy_gpr_min_depth_m,
|
||||
self._legacy_gpr_max_depth_m,
|
||||
self._legacy_gpr_comp_power,
|
||||
self._legacy_gpr_snr_thresh,
|
||||
self._legacy_gpr_snr_comp_max,
|
||||
self._legacy_gpr_start_freq_mhz,
|
||||
self._legacy_gpr_stop_freq_mhz,
|
||||
self._legacy_gpr_speed_m_s,
|
||||
self._legacy_gpr_ignore_socket_speed_enabled,
|
||||
self._legacy_gpr_look_angle_deg,
|
||||
self._legacy_gpr_apply_freq_phase_correction,
|
||||
self._legacy_gpr_reference_mode,
|
||||
self._legacy_gpr_background_subtract_enabled,
|
||||
self._legacy_gpr_background_mean_count,
|
||||
self._legacy_gpr_render_mode,
|
||||
self._legacy_gpr_min_visible_pair_count,
|
||||
self._legacy_gpr_visible_x_min_m,
|
||||
self._legacy_gpr_visible_x_max_m,
|
||||
self._legacy_gpr_visible_z_min_m,
|
||||
self._legacy_gpr_visible_z_max_m,
|
||||
self._sar3d_input_positions_input,
|
||||
self._sar3d_output_positions_input,
|
||||
self._sar3d_min_depth_m,
|
||||
self._sar3d_max_depth_m,
|
||||
self._sar3d_start_freq_mhz,
|
||||
self._sar3d_stop_freq_mhz,
|
||||
self._sar3d_speed_m_s,
|
||||
self._sar3d_ignore_socket_speed_enabled,
|
||||
self._sar3d_look_angle_deg,
|
||||
self._sar3d_frame_radius,
|
||||
self._sar3d_voxel_m,
|
||||
self._sar3d_bp_ground_depth_m,
|
||||
self._sar3d_comp_range_power,
|
||||
self._sar3d_use_compensation,
|
||||
self._sar3d_normalize_each_frame,
|
||||
self._record_count,
|
||||
self._save_path_input,
|
||||
self._save_name_input,
|
||||
self._adc_project_dir_input,
|
||||
self._adc_executable_path_input,
|
||||
self._adc_tty_path_input,
|
||||
self._adc_args_input,
|
||||
self._adc_env_input,
|
||||
self._adc_startup_timeout_s_input,
|
||||
self._adc_sweep_timeout_s_input,
|
||||
self._adc_stop_timeout_s_input,
|
||||
self._optical_enabled_checkbox,
|
||||
self._optical_port_input,
|
||||
self._optical_mode_combo,
|
||||
self._optical_pi_coeff1_p_input,
|
||||
self._optical_pi_coeff1_i_input,
|
||||
self._optical_pi_coeff2_p_input,
|
||||
self._optical_pi_coeff2_i_input,
|
||||
self._optical_manual_temp1_input,
|
||||
self._optical_manual_temp2_input,
|
||||
self._optical_manual_current1_input,
|
||||
self._optical_manual_current2_input,
|
||||
self._optical_variation_type_combo,
|
||||
self._optical_static_temp1_input,
|
||||
self._optical_static_temp2_input,
|
||||
self._optical_static_current1_input,
|
||||
self._optical_static_current2_input,
|
||||
self._optical_min_value_input,
|
||||
self._optical_max_value_input,
|
||||
self._optical_step_input,
|
||||
self._optical_time_step_input,
|
||||
self._optical_delay_time_input,
|
||||
)
|
||||
|
||||
with ExitStack() as blockers:
|
||||
for widget in radio_widgets:
|
||||
blockers.enter_context(QSignalBlocker(widget))
|
||||
|
||||
self._start_hz_input.setText(f"{config.radar.sweep.start_hz:g}")
|
||||
self._stop_hz_input.setText(f"{config.radar.sweep.stop_hz:g}")
|
||||
self._points_input.setText(str(int(config.radar.sweep.points)))
|
||||
self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}")
|
||||
self._power_input.setText(f"{config.radar.sweep.power_dbm:g}")
|
||||
self._settling_ms.setText(str(int(config.runtime.settling_ms)))
|
||||
self._set_radar_settings_mode(config.is_kamil_adc)
|
||||
|
||||
adc = config.radar.kamil_adc
|
||||
optical = config.radar.laser_control
|
||||
manual = optical.manual
|
||||
variation = optical.variation
|
||||
self._adc_project_dir_input.setText(str(adc.project_dir))
|
||||
self._adc_executable_path_input.setText(str(adc.executable_path))
|
||||
self._adc_tty_path_input.setText(str(adc.tty_path))
|
||||
self._adc_args_input.setPlainText("\n".join(adc.args))
|
||||
self._adc_env_input.setPlainText(json.dumps(adc.env, indent=2, sort_keys=True) if adc.env else "{}")
|
||||
self._adc_startup_timeout_s_input.setText(f"{float(adc.startup_timeout_s):g}")
|
||||
self._adc_sweep_timeout_s_input.setText(f"{float(adc.sweep_timeout_s):g}")
|
||||
self._adc_stop_timeout_s_input.setText(f"{float(adc.stop_timeout_s):g}")
|
||||
self._optical_enabled_checkbox.setChecked(bool(optical.enabled))
|
||||
self._optical_port_input.setText(str(optical.port))
|
||||
self._set_combo_current_text(self._optical_mode_combo, str(optical.mode))
|
||||
self._optical_pi_coeff1_p_input.setText(str(int(optical.pi_coeff1_p)))
|
||||
self._optical_pi_coeff1_i_input.setText(str(int(optical.pi_coeff1_i)))
|
||||
self._optical_pi_coeff2_p_input.setText(str(int(optical.pi_coeff2_p)))
|
||||
self._optical_pi_coeff2_i_input.setText(str(int(optical.pi_coeff2_i)))
|
||||
self._optical_manual_temp1_input.setText(f"{float(manual.temp1):g}")
|
||||
self._optical_manual_temp2_input.setText(f"{float(manual.temp2):g}")
|
||||
self._optical_manual_current1_input.setText(f"{float(manual.current1):g}")
|
||||
self._optical_manual_current2_input.setText(f"{float(manual.current2):g}")
|
||||
self._set_combo_current_text(self._optical_variation_type_combo, str(variation.variation_type))
|
||||
self._optical_static_temp1_input.setText(f"{float(variation.static_temp1):g}")
|
||||
self._optical_static_temp2_input.setText(f"{float(variation.static_temp2):g}")
|
||||
self._optical_static_current1_input.setText(f"{float(variation.static_current1):g}")
|
||||
self._optical_static_current2_input.setText(f"{float(variation.static_current2):g}")
|
||||
self._optical_min_value_input.setText(f"{float(variation.min_value):g}")
|
||||
self._optical_max_value_input.setText(f"{float(variation.max_value):g}")
|
||||
self._optical_step_input.setText(f"{float(variation.step):g}")
|
||||
self._optical_time_step_input.setText(str(int(variation.time_step)))
|
||||
self._optical_delay_time_input.setText(str(int(variation.delay_time)))
|
||||
self._sync_adc_settings_controls()
|
||||
|
||||
self._combos_text.setText(str(gui_state.switches.combos_text))
|
||||
self._single_combo_output.setText(str(gui_state.switches.single_output))
|
||||
self._single_combo_input.setText(str(gui_state.switches.single_input))
|
||||
self._set_combo_selection_mode(gui_state.switches.combo_mode)
|
||||
|
||||
self._set_combo_current_text(self._processing_mode, gui_state.processing.selected_mode)
|
||||
self._show_magnitude_checkbox.setChecked(bool(gui_state.processing.pass_through.show_magnitude))
|
||||
self._show_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.show_phase))
|
||||
self._unwrap_phase_checkbox.setChecked(bool(gui_state.processing.pass_through.unwrap_phase))
|
||||
self._pass_through_combo_filter_input.setText(str(gui_state.processing.pass_through.combo_filter))
|
||||
self._pass_through_fixed_y_enabled.setChecked(bool(gui_state.processing.pass_through.fixed_y_enabled))
|
||||
self._pass_through_y_min_db.setValue(float(gui_state.processing.pass_through.y_min_db))
|
||||
self._pass_through_y_max_db.setValue(float(gui_state.processing.pass_through.y_max_db))
|
||||
|
||||
self._set_combo_current_text(self._bscan_axis, gui_state.processing.bscan.axis)
|
||||
self._bscan_cut_m.setValue(float(gui_state.processing.bscan.cut_m))
|
||||
self._bscan_max_depth_m.setValue(float(gui_state.processing.bscan.max_depth_m))
|
||||
self._bscan_gain.setValue(float(gui_state.processing.bscan.gain))
|
||||
self._bscan_start_freq_mhz.setValue(float(gui_state.processing.bscan.start_freq_mhz))
|
||||
self._bscan_stop_freq_mhz.setValue(float(gui_state.processing.bscan.stop_freq_mhz))
|
||||
self._bscan_subtract_mean_ascan.setChecked(bool(gui_state.processing.bscan.subtract_mean_ascan))
|
||||
self._bscan_history_window.setValue(int(gui_state.processing.bscan.history_window_scans))
|
||||
|
||||
self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
|
||||
self._gpr_tx_geometry_input.set_position_count(config.output_switch.positions)
|
||||
self._gpr_rx_geometry_input.set_position_count(config.input_switch.positions)
|
||||
self._gpr_tx_geometry_input.set_records(config.gpr.tx_geometry)
|
||||
self._gpr_rx_geometry_input.set_records(config.gpr.rx_geometry)
|
||||
self._gpr_input_positions_input.setText(str(gui_state.processing.gpr.input_positions))
|
||||
self._gpr_output_positions_input.setText(str(gui_state.processing.gpr.output_positions))
|
||||
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
|
||||
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
|
||||
self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power))
|
||||
self._gpr_object_min_frac.setValue(float(gui_state.processing.gpr.object_min_frac))
|
||||
self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode)
|
||||
self._set_combo_current_text(self._gpr_motion_mode, gui_state.processing.gpr.motion_mode)
|
||||
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
|
||||
self._gpr_direction_sign.setValue(float(gui_state.processing.gpr.direction_sign))
|
||||
self._gpr_ignore_socket_speed_enabled.setChecked(
|
||||
bool(gui_state.processing.gpr.ignore_socket_speed_enabled)
|
||||
)
|
||||
self._gpr_speed_m_s.setValue(float(gui_state.processing.gpr.speed_m_s))
|
||||
self._gpr_max_detected_objects_to_draw.setValue(
|
||||
int(gui_state.processing.gpr.max_detected_objects_to_draw)
|
||||
)
|
||||
self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects))
|
||||
self._gpr_object_approach_min_frames.setValue(int(gui_state.processing.gpr.object_approach_min_frames))
|
||||
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
|
||||
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
|
||||
self._gpr_background_subtract_enabled.setChecked(
|
||||
bool(gui_state.processing.gpr.background_subtract_enabled)
|
||||
)
|
||||
self._gpr_background_mean_count.setValue(int(gui_state.processing.gpr.background_mean_count))
|
||||
self._gpr_remove_sidelobe_objects_enabled.setChecked(
|
||||
bool(gui_state.processing.gpr.remove_sidelobe_objects_enabled)
|
||||
)
|
||||
self._gpr_imaging_plane_y_m.setValue(float(gui_state.processing.gpr.imaging_plane_y_m))
|
||||
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
|
||||
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
|
||||
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
|
||||
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
|
||||
self._gpr_visible_z_max_m.setValue(float(gui_state.processing.gpr.visible_z_max_m))
|
||||
|
||||
self._set_combo_current_text(self._legacy_gpr_config_mode, gui_state.processing.legacy_gpr.mode)
|
||||
self._legacy_gpr_input_positions_input.setText(str(gui_state.processing.legacy_gpr.input_positions))
|
||||
self._legacy_gpr_output_positions_input.setText(str(gui_state.processing.legacy_gpr.output_positions))
|
||||
self._legacy_gpr_min_depth_m.setValue(float(gui_state.processing.legacy_gpr.min_depth_m))
|
||||
self._legacy_gpr_max_depth_m.setValue(float(gui_state.processing.legacy_gpr.max_depth_m))
|
||||
self._legacy_gpr_comp_power.setValue(float(gui_state.processing.legacy_gpr.comp_power))
|
||||
self._legacy_gpr_snr_thresh.setValue(float(gui_state.processing.legacy_gpr.snr_thresh))
|
||||
self._legacy_gpr_snr_comp_max.setValue(float(gui_state.processing.legacy_gpr.snr_comp_max))
|
||||
self._legacy_gpr_start_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.start_freq_mhz))
|
||||
self._legacy_gpr_stop_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.stop_freq_mhz))
|
||||
self._legacy_gpr_speed_m_s.setValue(float(gui_state.processing.legacy_gpr.speed_m_s))
|
||||
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(
|
||||
bool(gui_state.processing.legacy_gpr.ignore_socket_speed_enabled)
|
||||
)
|
||||
self._legacy_gpr_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
|
||||
self._legacy_gpr_apply_freq_phase_correction.setChecked(
|
||||
bool(gui_state.processing.legacy_gpr.apply_freq_phase_correction)
|
||||
)
|
||||
self._set_combo_current_text(
|
||||
self._legacy_gpr_reference_mode,
|
||||
gui_state.processing.legacy_gpr.reference_mode,
|
||||
)
|
||||
self._legacy_gpr_background_subtract_enabled.setChecked(
|
||||
bool(gui_state.processing.legacy_gpr.background_subtract_enabled)
|
||||
)
|
||||
self._legacy_gpr_background_mean_count.setValue(int(gui_state.processing.legacy_gpr.background_mean_count))
|
||||
self._set_combo_current_text(self._legacy_gpr_render_mode, gui_state.processing.legacy_gpr.render_mode)
|
||||
self._legacy_gpr_min_visible_pair_count.setValue(int(gui_state.processing.legacy_gpr.min_visible_pair_count))
|
||||
self._legacy_gpr_visible_x_min_m.setValue(float(gui_state.processing.legacy_gpr.visible_x_min_m))
|
||||
self._legacy_gpr_visible_x_max_m.setValue(float(gui_state.processing.legacy_gpr.visible_x_max_m))
|
||||
self._legacy_gpr_visible_z_min_m.setValue(float(gui_state.processing.legacy_gpr.visible_z_min_m))
|
||||
self._legacy_gpr_visible_z_max_m.setValue(float(gui_state.processing.legacy_gpr.visible_z_max_m))
|
||||
|
||||
sar3d_state = gui_state.processing.sar3d
|
||||
self._sar3d_input_positions_input.setText(str(sar3d_state.input_positions))
|
||||
self._sar3d_output_positions_input.setText(str(sar3d_state.output_positions))
|
||||
self._sar3d_min_depth_m.setValue(float(sar3d_state.min_depth_m))
|
||||
self._sar3d_max_depth_m.setValue(float(sar3d_state.max_depth_m))
|
||||
self._sar3d_start_freq_mhz.setValue(float(sar3d_state.start_freq_mhz))
|
||||
self._sar3d_stop_freq_mhz.setValue(float(sar3d_state.stop_freq_mhz))
|
||||
self._sar3d_speed_m_s.setValue(float(sar3d_state.speed_m_s))
|
||||
self._sar3d_ignore_socket_speed_enabled.setChecked(bool(sar3d_state.ignore_socket_speed_enabled))
|
||||
self._sar3d_look_angle_deg.setValue(float(sar3d_state.look_angle_deg))
|
||||
self._sar3d_frame_radius.setValue(int(sar3d_state.frame_radius))
|
||||
self._sar3d_voxel_m.setValue(float(sar3d_state.voxel_m))
|
||||
self._sar3d_bp_ground_depth_m.setValue(float(sar3d_state.bp_ground_depth_m))
|
||||
self._sar3d_comp_range_power.setValue(float(sar3d_state.comp_range_power))
|
||||
self._sar3d_use_compensation.setChecked(bool(sar3d_state.use_compensation))
|
||||
self._sar3d_normalize_each_frame.setChecked(bool(sar3d_state.normalize_each_frame))
|
||||
self._sync_socket_speed_controls()
|
||||
|
||||
self._record_count.setValue(int(gui_state.data_actions.record_count))
|
||||
self._save_path_input.setText(str(gui_state.data_actions.save_path))
|
||||
self._save_name_input.setText(str(gui_state.data_actions.save_name))
|
||||
|
||||
self._gui_defaults = gui_state
|
||||
self._selected_preprocess_sets = selected_preprocess_sets
|
||||
self._selected_preprocess_radar_key = self._radar_key(config)
|
||||
self._preprocess_set_name = str(gui_state.preprocess_dialog.set_name)
|
||||
self._preprocess_radar_config_dir = str(gui_state.preprocess_dialog.radar_config_dir)
|
||||
self._preprocess_use_all_radar_configs = bool(gui_state.preprocess_dialog.use_all_radar_configs)
|
||||
self._preprocess_median_sweep_count = max(1, int(gui_state.preprocess_dialog.median_sweep_count))
|
||||
self._apply_history_limit_from_config(config)
|
||||
self._gpr_geometry_signature = None
|
||||
self._gpr_selected_geometry = None
|
||||
self._sync_pass_through_y_controls()
|
||||
self._set_processing_mode_page(gui_state.processing.selected_mode)
|
||||
self._refresh_preprocess_summary_labels()
|
||||
|
||||
if self._preprocess_dialog is not None:
|
||||
with ExitStack() as dialog_blockers:
|
||||
dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._set_name_input))
|
||||
dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._radar_config_dir_input))
|
||||
dialog_blockers.enter_context(QSignalBlocker(self._preprocess_dialog._use_all_radar_configs_checkbox))
|
||||
for combo in self._preprocess_dialog._set_combos.values():
|
||||
dialog_blockers.enter_context(QSignalBlocker(combo))
|
||||
self._preprocess_dialog.set_set_name(self._preprocess_set_name)
|
||||
self._preprocess_dialog.set_radar_config_dir(self._preprocess_radar_config_dir)
|
||||
self._preprocess_dialog.set_use_all_radar_configs(self._preprocess_use_all_radar_configs)
|
||||
self._preprocess_dialog.set_median_sweep_count(self._preprocess_median_sweep_count)
|
||||
self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
|
||||
|
||||
self._apply_initial_radar_limits()
|
||||
if self._preprocess_dialog is not None:
|
||||
self._refresh_sets()
|
||||
self._await_processing_revision = True
|
||||
self._on_processing_mode_changed(gui_state.processing.selected_mode)
|
||||
self._update_history_indicator()
|
||||
self._remember_active_profile_path(profile_path)
|
||||
@@ -1,237 +0,0 @@
|
||||
"""Radar device limit sync and clamping logic for the main window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
|
||||
|
||||
class AppWindowRadarLimitsMixin:
|
||||
"""Handle radar capability probing and dependent UI clamping."""
|
||||
|
||||
def _on_radar_sweep_limits_changed(self) -> None:
|
||||
"""Clamp processing frequency bounds after sweep start/stop edits."""
|
||||
self._reset_preprocess_selection_after_radar_key_change()
|
||||
if self._sync_processing_frequency_limits_with_radar():
|
||||
self._on_processing_live_settings_changed()
|
||||
|
||||
def _refresh_radar_limits_from_device(self) -> bool:
|
||||
"""Query native radar limits and apply them to GUI fields."""
|
||||
config = self._defaults_config
|
||||
if config.is_kamil_adc:
|
||||
return self._apply_radar_limits_to_ui(None)
|
||||
if config.is_multi_device:
|
||||
radar_service = LibreVnaService(serial=config.radar.serial or None)
|
||||
elif config.is_matrix_radar:
|
||||
radar_service = create_matrix_radar_service(config)
|
||||
else:
|
||||
radar_service = create_single_radar_service(config)
|
||||
|
||||
limits = radar_service.read_device_limits()
|
||||
return self._apply_radar_limits_to_ui(limits)
|
||||
|
||||
def _apply_radar_limits_to_ui(self, limits: dict[str, float | int] | None) -> bool:
|
||||
"""Apply optional radar limits and clamp dependent GUI fields."""
|
||||
previous_limits = dict(self._radar_limits) if self._radar_limits is not None else None
|
||||
if limits is None:
|
||||
self._radar_limits = None
|
||||
self._radar_start_label.setText("Start Hz")
|
||||
self._radar_stop_label.setText("Stop Hz")
|
||||
self._radar_points_label.setText("Points")
|
||||
self._radar_ifbw_label.setText("IF BW Hz")
|
||||
self._radar_power_label.setText("Stimulus Power dBm")
|
||||
self._radar_limits_hint.setText("Device limits are not available.")
|
||||
self._power_input.setToolTip("Stimulus power configured in the active profile.")
|
||||
return False
|
||||
|
||||
min_freq_hz = float(limits["min_frequency_hz"])
|
||||
max_freq_hz = float(limits["max_frequency_hz"])
|
||||
min_ifbw_hz = float(limits["min_ifbw_hz"])
|
||||
max_ifbw_hz = float(limits["max_ifbw_hz"])
|
||||
max_points = int(limits["max_points"])
|
||||
min_power_dbm = float(limits["min_power_dbm"])
|
||||
max_power_dbm = float(limits["max_power_dbm"])
|
||||
|
||||
self._radar_limits = limits
|
||||
|
||||
self._radar_start_label.setText(f"Start Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||
self._radar_stop_label.setText(f"Stop Hz ({min_freq_hz:g}..{max_freq_hz:g})")
|
||||
self._radar_points_label.setText(f"Points (1..{max_points:d})")
|
||||
self._radar_ifbw_label.setText(f"IF BW Hz ({min_ifbw_hz:g}..{max_ifbw_hz:g})")
|
||||
self._radar_power_label.setText(f"Stimulus Power dBm ({min_power_dbm:g}..{max_power_dbm:g})")
|
||||
self._radar_limits_hint.setText(
|
||||
f"Limits: Freq {min_freq_hz:g}..{max_freq_hz:g} Hz, Points 1..{max_points:d}, "
|
||||
f"IF BW {min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, Power {min_power_dbm:g}..{max_power_dbm:g} dBm."
|
||||
)
|
||||
|
||||
changed = False
|
||||
prev_start = self._start_hz_input.text().strip()
|
||||
prev_stop = self._stop_hz_input.text().strip()
|
||||
prev_points = self._points_input.text().strip()
|
||||
prev_ifbw = self._ifbw_input.text().strip()
|
||||
prev_power = self._power_input.text().strip()
|
||||
|
||||
start_hz = self._clamp_line_edit_float(self._start_hz_input, min_freq_hz, max_freq_hz)
|
||||
stop_hz = self._clamp_line_edit_float(self._stop_hz_input, min_freq_hz, max_freq_hz)
|
||||
if start_hz > stop_hz:
|
||||
stop_hz = start_hz
|
||||
self._stop_hz_input.setText(f"{stop_hz:g}")
|
||||
changed = True
|
||||
|
||||
self._clamp_line_edit_int(self._points_input, 1, max_points)
|
||||
self._clamp_line_edit_float(self._ifbw_input, min_ifbw_hz, max_ifbw_hz)
|
||||
self._clamp_line_edit_float(self._power_input, min_power_dbm, max_power_dbm)
|
||||
self._power_input.setToolTip(f"Device range: {min_power_dbm:g}..{max_power_dbm:g} dBm")
|
||||
|
||||
changed = (
|
||||
changed
|
||||
or prev_start != self._start_hz_input.text().strip()
|
||||
or prev_stop != self._stop_hz_input.text().strip()
|
||||
or prev_points != self._points_input.text().strip()
|
||||
or prev_ifbw != self._ifbw_input.text().strip()
|
||||
or prev_power != self._power_input.text().strip()
|
||||
)
|
||||
|
||||
applied_limits_changed = previous_limits != limits
|
||||
if applied_limits_changed:
|
||||
self._log(
|
||||
"Applied radar device limits: "
|
||||
f"freq={min_freq_hz:g}..{max_freq_hz:g} Hz, "
|
||||
f"points=1..{max_points}, "
|
||||
f"ifbw={min_ifbw_hz:g}..{max_ifbw_hz:g} Hz, "
|
||||
f"power={min_power_dbm:g}..{max_power_dbm:g} dBm"
|
||||
)
|
||||
|
||||
adjustments: list[str] = []
|
||||
current_start = self._start_hz_input.text().strip()
|
||||
current_stop = self._stop_hz_input.text().strip()
|
||||
current_points = self._points_input.text().strip()
|
||||
current_ifbw = self._ifbw_input.text().strip()
|
||||
current_power = self._power_input.text().strip()
|
||||
if prev_start != current_start:
|
||||
adjustments.append(f"Start Hz: {prev_start or '<empty>'} -> {current_start}")
|
||||
if prev_stop != current_stop:
|
||||
adjustments.append(f"Stop Hz: {prev_stop or '<empty>'} -> {current_stop}")
|
||||
if prev_points != current_points:
|
||||
adjustments.append(f"Points: {prev_points or '<empty>'} -> {current_points}")
|
||||
if prev_ifbw != current_ifbw:
|
||||
adjustments.append(f"IF BW Hz: {prev_ifbw or '<empty>'} -> {current_ifbw}")
|
||||
if prev_power != current_power:
|
||||
adjustments.append(f"Stimulus Power dBm: {prev_power or '<empty>'} -> {current_power}")
|
||||
if adjustments:
|
||||
self._log_warning(
|
||||
"Radar fields were adjusted to satisfy device limits.",
|
||||
details="\n".join(adjustments),
|
||||
)
|
||||
|
||||
self._sync_processing_frequency_limits_with_radar()
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _clamp_line_edit_float(widget, min_value: float, max_value: float) -> float:
|
||||
"""Clamp float line-edit value to inclusive range and rewrite widget text."""
|
||||
try:
|
||||
value = float(widget.text().strip())
|
||||
except ValueError:
|
||||
value = min_value
|
||||
value = min(max(value, min_value), max_value)
|
||||
widget.setText(f"{value:g}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _clamp_line_edit_int(widget, min_value: int, max_value: int) -> int:
|
||||
"""Clamp integer line-edit value to inclusive range and rewrite widget text."""
|
||||
try:
|
||||
value = int(float(widget.text().strip()))
|
||||
except ValueError:
|
||||
value = min_value
|
||||
value = min(max(value, min_value), max_value)
|
||||
widget.setText(str(value))
|
||||
return value
|
||||
|
||||
def _sync_processing_frequency_limits_with_radar(self) -> bool:
|
||||
"""Synchronize all processing frequency widgets with radar sweep bounds."""
|
||||
bscan_changed = self._sync_bscan_frequency_limits_with_radar()
|
||||
gpr_changed = self._sync_gpr_frequency_limits_with_radar()
|
||||
return bscan_changed or gpr_changed
|
||||
|
||||
def _sync_frequency_spinboxes_with_radar(self, widget_names: tuple[str, ...]) -> bool:
|
||||
"""Synchronize one or more MHz spin boxes with current radar sweep bounds."""
|
||||
required_widgets = ("_start_hz_input", "_stop_hz_input", *widget_names)
|
||||
if not all(hasattr(self, widget_name) for widget_name in required_widgets):
|
||||
return False
|
||||
|
||||
try:
|
||||
radar_start_hz = float(self._start_hz_input.text().strip())
|
||||
radar_stop_hz = float(self._stop_hz_input.text().strip())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
radar_min_mhz = min(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||
|
||||
changed = False
|
||||
widget_labels = {
|
||||
"_bscan_start_freq_mhz": "B-scan Start MHz",
|
||||
"_bscan_stop_freq_mhz": "B-scan Stop MHz",
|
||||
"_gpr_start_freq_mhz": "GPR Start MHz",
|
||||
"_gpr_stop_freq_mhz": "GPR Stop MHz",
|
||||
"_legacy_gpr_start_freq_mhz": "Legacy GPR Start MHz",
|
||||
"_legacy_gpr_stop_freq_mhz": "Legacy GPR Stop MHz",
|
||||
"_sar3d_start_freq_mhz": "3D SAR Start MHz",
|
||||
"_sar3d_stop_freq_mhz": "3D SAR Stop MHz",
|
||||
}
|
||||
widgets = [getattr(self, widget_name) for widget_name in widget_names]
|
||||
previous_values = {widget_name: getattr(self, widget_name).value() for widget_name in widget_names}
|
||||
for widget in widgets:
|
||||
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
|
||||
changed = True
|
||||
widget.blockSignals(True)
|
||||
widget.setRange(radar_min_mhz, radar_max_mhz)
|
||||
widget.blockSignals(False)
|
||||
|
||||
for widget in widgets:
|
||||
clamped_value = min(max(widget.value(), radar_min_mhz), radar_max_mhz)
|
||||
if clamped_value != widget.value():
|
||||
changed = True
|
||||
widget.blockSignals(True)
|
||||
widget.setValue(clamped_value)
|
||||
widget.blockSignals(False)
|
||||
clamped_fields = []
|
||||
for widget_name in widget_names:
|
||||
current_value = getattr(self, widget_name).value()
|
||||
previous_value = previous_values[widget_name]
|
||||
if current_value == previous_value:
|
||||
continue
|
||||
clamped_fields.append(
|
||||
f"{widget_labels.get(widget_name, widget_name)}: {previous_value:g} -> {current_value:g}"
|
||||
)
|
||||
if clamped_fields:
|
||||
self._log_warning(
|
||||
"Processing frequency limits were clamped to the active radar sweep.",
|
||||
details="\n".join(clamped_fields),
|
||||
)
|
||||
return changed
|
||||
|
||||
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
||||
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
||||
return self._sync_frequency_spinboxes_with_radar(
|
||||
(
|
||||
"_bscan_start_freq_mhz",
|
||||
"_bscan_stop_freq_mhz",
|
||||
)
|
||||
)
|
||||
|
||||
def _sync_gpr_frequency_limits_with_radar(self) -> bool:
|
||||
"""Synchronize GPR start/stop MHz widget ranges with radar sweep bounds."""
|
||||
return self._sync_frequency_spinboxes_with_radar(
|
||||
(
|
||||
"_gpr_start_freq_mhz",
|
||||
"_gpr_stop_freq_mhz",
|
||||
"_legacy_gpr_start_freq_mhz",
|
||||
"_legacy_gpr_stop_freq_mhz",
|
||||
"_sar3d_start_freq_mhz",
|
||||
"_sar3d_stop_freq_mhz",
|
||||
)
|
||||
)
|
||||
@@ -1,472 +0,0 @@
|
||||
"""State builders and config parsing helpers for the main window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from python_app.models.gui_profile_model import (
|
||||
GuiBscanStateModel,
|
||||
GuiDataActionsStateModel,
|
||||
GuiGprStateModel,
|
||||
GuiLegacyGprStateModel,
|
||||
GuiPassThroughStateModel,
|
||||
GuiPreprocessDialogStateModel,
|
||||
GuiProcessingStateModel,
|
||||
GuiProfileModel,
|
||||
GuiSar3dStateModel,
|
||||
GuiStateModel,
|
||||
GuiSwitchStateModel,
|
||||
)
|
||||
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
|
||||
from python_app.models.run_config_validation import validate_gpr_model
|
||||
from python_app.orchestration.config_writer import parse_combos_from_text
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_KEYS,
|
||||
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||
preprocess_asset_model,
|
||||
)
|
||||
from python_app.storage.npz_store import radar_key_from_config
|
||||
|
||||
|
||||
# GUI snapshot-save deques (`_raw_history`, `_pre_history`, `_result_history`)
|
||||
# are intentionally decoupled from SHM ring capacities: rings are sized for the
|
||||
# C++ processing pipeline, while save buffers only retain what the GUI poll
|
||||
# loop already read out. Growing this number lets the user save more recent
|
||||
# history without touching the processing-side ring sizes.
|
||||
GUI_SAVE_HISTORY_LIMIT: int = 1000
|
||||
|
||||
# Mirror of `kBscanReplayWindow` in
|
||||
# data_acq_and_processing/processing/data_processor/src/data_processor.cpp. When a
|
||||
# live B-scan setting changes, the C++ processor re-processes and re-publishes only
|
||||
# this many of the newest collections; anything older keeps the payload it was first
|
||||
# computed with. Keep the two constants in sync.
|
||||
CPP_BSCAN_REPLAY_WINDOW: int = 50
|
||||
|
||||
|
||||
class AppWindowConfigStateBuildersMixin:
|
||||
"""Build stable and GUI-only config models from current widget state."""
|
||||
|
||||
@staticmethod
|
||||
def _parse_csv_int_list(text: str) -> list[int]:
|
||||
"""Parse comma-separated integer selection list."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return []
|
||||
values: list[int] = []
|
||||
for part in cleaned.split(","):
|
||||
token = part.strip()
|
||||
if not token:
|
||||
continue
|
||||
values.append(int(token))
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _parse_multiline_string_list(text: str) -> list[str]:
|
||||
"""Parse one command argument per non-empty line."""
|
||||
return [line.strip() for line in text.splitlines() if line.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _parse_string_env_json(text: str) -> dict[str, str]:
|
||||
"""Parse strict string-to-string environment JSON."""
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return {}
|
||||
payload = json.loads(cleaned)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Environment JSON must be an object")
|
||||
env: dict[str, str] = {}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
raise ValueError("Environment JSON must contain only string keys and values")
|
||||
env[key] = value
|
||||
return env
|
||||
|
||||
@staticmethod
|
||||
def _format_combos_text_from_config(config: RunConfigModel) -> str:
|
||||
"""Render configured combos for UI text editor, keeping full matrix as empty."""
|
||||
if config.is_matrix_radar:
|
||||
return ",".join(
|
||||
f"{int(combo.input)}:{int(combo.output)}"
|
||||
for combo in config.build_runtime_combos()
|
||||
)
|
||||
combos = list(config.combos)
|
||||
full_combos = config.build_full_combos(config.input_switch.positions, config.output_switch.positions)
|
||||
if len(combos) == len(full_combos) and all(
|
||||
int(left.input) == int(right.input) and int(left.output) == int(right.output)
|
||||
for left, right in zip(combos, full_combos, strict=True)
|
||||
):
|
||||
return ""
|
||||
return ",".join(f"{int(combo.input)}:{int(combo.output)}" for combo in combos)
|
||||
|
||||
@staticmethod
|
||||
def _default_gpr_input_positions_from_config(config: RunConfigModel) -> str:
|
||||
"""Build default live GPR input-position selection from stable config."""
|
||||
geometry_values = {int(entry.input_pos) for entry in config.gpr.rx_geometry}
|
||||
combo_values = {int(combo.input) for combo in config.combos}
|
||||
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
|
||||
return ",".join(str(value) for value in values)
|
||||
|
||||
@staticmethod
|
||||
def _default_gpr_output_positions_from_config(config: RunConfigModel) -> str:
|
||||
"""Build default live GPR output-position selection from stable config."""
|
||||
geometry_values = {int(entry.output_pos) for entry in config.gpr.tx_geometry}
|
||||
combo_values = {int(combo.output) for combo in config.combos}
|
||||
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
|
||||
return ",".join(str(value) for value in values)
|
||||
|
||||
@staticmethod
|
||||
def _default_gpr_visible_x_bounds_from_config(config: RunConfigModel) -> tuple[float, float]:
|
||||
"""Build default visible X-range for GPR object-only rendering."""
|
||||
x_values = [float(entry.x_m) for entry in config.gpr.tx_geometry]
|
||||
x_values.extend(float(entry.x_m) for entry in config.gpr.rx_geometry)
|
||||
if not x_values:
|
||||
return (-2.0, 2.0)
|
||||
margin_m = 2.0
|
||||
return (min(x_values) - margin_m, max(x_values) + margin_m)
|
||||
|
||||
@staticmethod
|
||||
def _cpp_bscan_replay_window_for_config(config: RunConfigModel) -> int:
|
||||
"""Return how many newest collections the C++ processor re-processes on a
|
||||
live B-scan settings change.
|
||||
|
||||
Deliberately reproduces `replay_history_limit()` in `data_processor.cpp`
|
||||
formula-for-formula. The ring capacities matter because `ShmRing` overwrites
|
||||
the oldest unread slot on overflow, so a replay burst must fit in the results
|
||||
ring for the GUI to receive all of it.
|
||||
|
||||
This is the single place to change if the replay window ever becomes
|
||||
configurable on the C++ side.
|
||||
"""
|
||||
return max(
|
||||
1,
|
||||
min(
|
||||
int(config.rings.preprocessed.capacity),
|
||||
int(config.rings.results.capacity),
|
||||
CPP_BSCAN_REPLAY_WINDOW,
|
||||
),
|
||||
)
|
||||
|
||||
def _default_gui_state_for_config(self, config: RunConfigModel) -> GuiStateModel:
|
||||
"""Build fallback GUI-only defaults for a stable run config."""
|
||||
default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0)
|
||||
default_mode = "single" if len(config.combos) == 1 else "text"
|
||||
default_gpr_x_min_m, default_gpr_x_max_m = self._default_gpr_visible_x_bounds_from_config(config)
|
||||
return GuiStateModel(
|
||||
switches=GuiSwitchStateModel(
|
||||
combo_mode=default_mode,
|
||||
combos_text=self._format_combos_text_from_config(config),
|
||||
single_input=str(int(default_combo.input)),
|
||||
single_output=str(int(default_combo.output)),
|
||||
),
|
||||
processing=GuiProcessingStateModel(
|
||||
gpr=GuiGprStateModel(
|
||||
input_positions=self._default_gpr_input_positions_from_config(config),
|
||||
output_positions=self._default_gpr_output_positions_from_config(config),
|
||||
visible_x_min_m=default_gpr_x_min_m,
|
||||
visible_x_max_m=default_gpr_x_max_m,
|
||||
),
|
||||
legacy_gpr=GuiLegacyGprStateModel(
|
||||
input_positions=self._default_gpr_input_positions_from_config(config),
|
||||
output_positions=self._default_gpr_output_positions_from_config(config),
|
||||
visible_x_min_m=default_gpr_x_min_m,
|
||||
visible_x_max_m=default_gpr_x_max_m,
|
||||
),
|
||||
sar3d=GuiSar3dStateModel(
|
||||
input_positions=self._default_gpr_input_positions_from_config(config),
|
||||
output_positions=self._default_gpr_output_positions_from_config(config),
|
||||
),
|
||||
),
|
||||
data_actions=GuiDataActionsStateModel(
|
||||
save_path=str(self._project_root / "python_app/data/snapshots"),
|
||||
),
|
||||
)
|
||||
|
||||
def _current_preprocess_set_name(self) -> str:
|
||||
"""Return current preprocess dialog set name, even when dialog is still closed."""
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_set_name = self._preprocess_dialog.set_name()
|
||||
return self._preprocess_set_name
|
||||
|
||||
def _current_preprocess_radar_config_dir(self) -> str:
|
||||
"""Return current preprocess dialog radar-config directory path."""
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_radar_config_dir = self._preprocess_dialog.radar_config_dir()
|
||||
return self._preprocess_radar_config_dir
|
||||
|
||||
def _current_preprocess_use_all_radar_configs(self) -> bool:
|
||||
"""Return whether multi-radar preprocessing is enabled in the dialog."""
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_use_all_radar_configs = self._preprocess_dialog.use_all_radar_configs()
|
||||
return bool(self._preprocess_use_all_radar_configs)
|
||||
|
||||
def _current_preprocess_median_sweep_count(self) -> int:
|
||||
"""Return current per-combo median sweep count from the dialog."""
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_median_sweep_count = self._preprocess_dialog.median_sweep_count()
|
||||
return max(1, int(self._preprocess_median_sweep_count))
|
||||
|
||||
def _build_gui_state(self) -> GuiStateModel:
|
||||
"""Build GUI-only persistent state from current widget values."""
|
||||
return GuiStateModel(
|
||||
switches=GuiSwitchStateModel(
|
||||
combo_mode=(
|
||||
"text"
|
||||
if self._is_matrix_radar_model_selected()
|
||||
else "single"
|
||||
if self._single_combo_select_button.isChecked()
|
||||
else "text"
|
||||
),
|
||||
combos_text=(
|
||||
self._fixed_matrix_radar_combo_text()
|
||||
if self._is_matrix_radar_model_selected()
|
||||
else self._combos_text.text().strip()
|
||||
),
|
||||
single_input=self._single_combo_input.text().strip(),
|
||||
single_output=self._single_combo_output.text().strip(),
|
||||
),
|
||||
processing=GuiProcessingStateModel(
|
||||
selected_mode=self._processing_mode.currentText(),
|
||||
pass_through=GuiPassThroughStateModel(
|
||||
show_magnitude=bool(self._show_magnitude_checkbox.isChecked()),
|
||||
show_phase=bool(self._show_phase_checkbox.isChecked()),
|
||||
unwrap_phase=bool(self._unwrap_phase_checkbox.isChecked()),
|
||||
combo_filter=self._pass_through_combo_filter_input.text().strip(),
|
||||
fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||
y_min_db=float(self._pass_through_y_min_db.value()),
|
||||
y_max_db=float(self._pass_through_y_max_db.value()),
|
||||
),
|
||||
bscan=GuiBscanStateModel(
|
||||
axis=self._bscan_axis.currentText(),
|
||||
cut_m=float(self._bscan_cut_m.value()),
|
||||
max_depth_m=float(self._bscan_max_depth_m.value()),
|
||||
gain=float(self._bscan_gain.value()),
|
||||
start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||
stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||
subtract_mean_ascan=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
||||
history_window_scans=int(self._bscan_history_window.value()),
|
||||
),
|
||||
gpr=GuiGprStateModel(
|
||||
input_positions=self._gpr_input_positions_input.text().strip(),
|
||||
output_positions=self._gpr_output_positions_input.text().strip(),
|
||||
min_depth_m=float(self._gpr_min_depth_m.value()),
|
||||
max_depth_m=float(self._gpr_max_depth_m.value()),
|
||||
range_comp_power=float(self._gpr_range_comp_power.value()),
|
||||
object_min_frac=float(self._gpr_object_min_frac.value()),
|
||||
score_mode=self._gpr_score_mode.currentText(),
|
||||
motion_mode=self._gpr_motion_mode.currentText(),
|
||||
look_angle_deg=float(self._gpr_look_angle_deg.value()),
|
||||
direction_sign=float(self._gpr_direction_sign.value()),
|
||||
speed_m_s=float(self._gpr_speed_m_s.value()),
|
||||
ignore_socket_speed_enabled=bool(self._gpr_ignore_socket_speed_enabled.isChecked()),
|
||||
max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
|
||||
draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
|
||||
object_approach_min_frames=int(self._gpr_object_approach_min_frames.value()),
|
||||
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
||||
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
|
||||
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||
background_mean_count=int(self._gpr_background_mean_count.value()),
|
||||
remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
|
||||
imaging_plane_y_m=float(self._gpr_imaging_plane_y_m.value()),
|
||||
render_mode=self._gpr_render_mode.currentText(),
|
||||
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
|
||||
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
|
||||
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
|
||||
visible_z_max_m=float(self._gpr_visible_z_max_m.value()),
|
||||
),
|
||||
legacy_gpr=GuiLegacyGprStateModel(
|
||||
mode=self._legacy_gpr_config_mode.currentText(),
|
||||
input_positions=self._legacy_gpr_input_positions_input.text().strip(),
|
||||
output_positions=self._legacy_gpr_output_positions_input.text().strip(),
|
||||
min_depth_m=float(self._legacy_gpr_min_depth_m.value()),
|
||||
max_depth_m=float(self._legacy_gpr_max_depth_m.value()),
|
||||
comp_power=float(self._legacy_gpr_comp_power.value()),
|
||||
start_freq_mhz=float(self._legacy_gpr_start_freq_mhz.value()),
|
||||
stop_freq_mhz=float(self._legacy_gpr_stop_freq_mhz.value()),
|
||||
speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
|
||||
ignore_socket_speed_enabled=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
|
||||
look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
|
||||
apply_freq_phase_correction=bool(
|
||||
self._legacy_gpr_apply_freq_phase_correction.isChecked()
|
||||
),
|
||||
reference_mode=self._legacy_gpr_reference_mode.currentText(),
|
||||
snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
|
||||
snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()),
|
||||
background_subtract_enabled=bool(self._legacy_gpr_background_subtract_enabled.isChecked()),
|
||||
background_mean_count=int(self._legacy_gpr_background_mean_count.value()),
|
||||
render_mode=self._legacy_gpr_render_mode.currentText(),
|
||||
min_visible_pair_count=int(self._legacy_gpr_min_visible_pair_count.value()),
|
||||
visible_x_min_m=float(self._legacy_gpr_visible_x_min_m.value()),
|
||||
visible_x_max_m=float(self._legacy_gpr_visible_x_max_m.value()),
|
||||
visible_z_min_m=float(self._legacy_gpr_visible_z_min_m.value()),
|
||||
visible_z_max_m=float(self._legacy_gpr_visible_z_max_m.value()),
|
||||
),
|
||||
sar3d=GuiSar3dStateModel(
|
||||
input_positions=self._sar3d_input_positions_input.text().strip(),
|
||||
output_positions=self._sar3d_output_positions_input.text().strip(),
|
||||
min_depth_m=float(self._sar3d_min_depth_m.value()),
|
||||
max_depth_m=float(self._sar3d_max_depth_m.value()),
|
||||
start_freq_mhz=float(self._sar3d_start_freq_mhz.value()),
|
||||
stop_freq_mhz=float(self._sar3d_stop_freq_mhz.value()),
|
||||
speed_m_s=float(self._sar3d_speed_m_s.value()),
|
||||
ignore_socket_speed_enabled=bool(self._sar3d_ignore_socket_speed_enabled.isChecked()),
|
||||
look_angle_deg=float(self._sar3d_look_angle_deg.value()),
|
||||
frame_radius=int(self._sar3d_frame_radius.value()),
|
||||
voxel_m=float(self._sar3d_voxel_m.value()),
|
||||
bp_ground_depth_m=float(self._sar3d_bp_ground_depth_m.value()),
|
||||
comp_range_power=float(self._sar3d_comp_range_power.value()),
|
||||
use_compensation=bool(self._sar3d_use_compensation.isChecked()),
|
||||
normalize_each_frame=bool(self._sar3d_normalize_each_frame.isChecked()),
|
||||
),
|
||||
),
|
||||
data_actions=GuiDataActionsStateModel(
|
||||
save_path=self._save_path_input.text().strip(),
|
||||
save_name=self._save_name_input.text().strip(),
|
||||
record_count=int(self._record_count.value()),
|
||||
),
|
||||
preprocess_dialog=GuiPreprocessDialogStateModel(
|
||||
set_name=self._current_preprocess_set_name(),
|
||||
radar_config_dir=self._current_preprocess_radar_config_dir(),
|
||||
use_all_radar_configs=self._current_preprocess_use_all_radar_configs(),
|
||||
median_sweep_count=self._current_preprocess_median_sweep_count(),
|
||||
),
|
||||
)
|
||||
|
||||
def _build_gui_profile(self) -> GuiProfileModel:
|
||||
"""Build full GUI config profile from current window state."""
|
||||
return GuiProfileModel(
|
||||
run_config=self._build_config(),
|
||||
gui=self._build_gui_state(),
|
||||
)
|
||||
|
||||
def _build_config(self) -> RunConfigModel:
|
||||
"""Build `RunConfigModel` from current GUI widget values."""
|
||||
config = self._defaults_config.clone()
|
||||
|
||||
config.radar.sweep.start_hz = float(self._start_hz_input.text().strip())
|
||||
config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip())
|
||||
config.radar.sweep.points = int(self._points_input.text().strip())
|
||||
config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
|
||||
config.radar.sweep.power_dbm = float(self._power_input.text().strip())
|
||||
if config.is_kamil_adc:
|
||||
self._apply_adc_settings_to_config(config)
|
||||
|
||||
config.runtime.settling_ms = int(self._settling_ms.text().strip())
|
||||
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
|
||||
config.logging.level = self._log_level_combo.currentText().strip().lower()
|
||||
|
||||
if config.is_matrix_radar:
|
||||
if config.is_multi_device and len(config.radar.multi_device.slave_serials) != 2:
|
||||
raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
|
||||
config.apply_device_model_constraints()
|
||||
else:
|
||||
if self._single_combo_select_button.isChecked():
|
||||
config.combos = [
|
||||
ComboModel(
|
||||
input=int(self._single_combo_input.text().strip()),
|
||||
output=int(self._single_combo_output.text().strip()),
|
||||
)
|
||||
]
|
||||
else:
|
||||
combo_text = self._combos_text.text()
|
||||
config.combos = parse_combos_from_text(combo_text)
|
||||
config.ensure_combos()
|
||||
if self._processing_mode.currentText() not in {"gpr", "legacy_gpr", "sar3d"} and self._switches_are_effectively_static(config):
|
||||
config.combos = [ComboModel(input=0, output=0)]
|
||||
|
||||
for key in PREPROCESS_ASSET_KEYS:
|
||||
preprocess_asset_model(config, key).bundle_path = ""
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
|
||||
preprocess_asset_model(config, key).set_name = self._selected_preprocess_sets.get(key, "")
|
||||
config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value())
|
||||
config.gpr.tx_geometry = [GprTxGeometryModel(**entry) for entry in self._gpr_tx_geometry_input.records()]
|
||||
config.gpr.rx_geometry = [GprRxGeometryModel(**entry) for entry in self._gpr_rx_geometry_input.records()]
|
||||
validate_gpr_model(
|
||||
config.gpr,
|
||||
input_switch_positions=config.input_switch.positions,
|
||||
output_switch_positions=config.output_switch.positions,
|
||||
sweep=config.radar.sweep,
|
||||
)
|
||||
return config
|
||||
|
||||
def _radar_key(self, config: RunConfigModel) -> str:
|
||||
"""Build radar key used by preprocess-set storage lookup."""
|
||||
return radar_key_from_config(
|
||||
model_name=config.radar.model,
|
||||
serial=config.radar.serial,
|
||||
sweep_start_hz=config.radar.sweep.start_hz,
|
||||
sweep_stop_hz=config.radar.sweep.stop_hz,
|
||||
sweep_points=config.radar.sweep.points,
|
||||
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
|
||||
power_dbm=config.radar.sweep.power_dbm,
|
||||
extra_serials=config.radar_key_extra_parts() or None,
|
||||
)
|
||||
|
||||
def _radar_key_from_ui(self) -> str:
|
||||
"""Build current radar key directly from radar widgets only."""
|
||||
model_name = self._defaults_config.radar.model or RunConfigModel.LIBREVNA_MODEL
|
||||
extra_parts = self._defaults_config.radar_key_extra_parts()
|
||||
if self._defaults_config.is_kamil_adc:
|
||||
config = self._defaults_config.clone()
|
||||
config.radar.sweep.start_hz = float(self._start_hz_input.text().strip())
|
||||
config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip())
|
||||
config.radar.sweep.points = int(self._points_input.text().strip())
|
||||
config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
|
||||
config.radar.sweep.power_dbm = float(self._power_input.text().strip())
|
||||
self._apply_adc_settings_to_config(config)
|
||||
extra_parts = config.radar_key_extra_parts()
|
||||
return radar_key_from_config(
|
||||
model_name=model_name,
|
||||
serial=self._defaults_config.radar.serial,
|
||||
sweep_start_hz=float(self._start_hz_input.text().strip()),
|
||||
sweep_stop_hz=float(self._stop_hz_input.text().strip()),
|
||||
sweep_points=int(self._points_input.text().strip()),
|
||||
ifbw_hz=float(self._ifbw_input.text().strip()),
|
||||
power_dbm=float(self._power_input.text().strip()),
|
||||
extra_serials=extra_parts or None,
|
||||
)
|
||||
|
||||
def _apply_adc_settings_to_config(self, config: RunConfigModel) -> None:
|
||||
"""Copy visible ADC collector and optical-board settings into config."""
|
||||
adc = config.radar.kamil_adc
|
||||
adc.project_dir = self._adc_project_dir_input.text().strip()
|
||||
adc.executable_path = self._adc_executable_path_input.text().strip()
|
||||
adc.tty_path = self._adc_tty_path_input.text().strip()
|
||||
adc.args = self._parse_multiline_string_list(self._adc_args_input.toPlainText())
|
||||
adc.env = self._parse_string_env_json(self._adc_env_input.toPlainText())
|
||||
adc.startup_timeout_s = float(self._adc_startup_timeout_s_input.text().strip())
|
||||
adc.sweep_timeout_s = float(self._adc_sweep_timeout_s_input.text().strip())
|
||||
adc.stop_timeout_s = float(self._adc_stop_timeout_s_input.text().strip())
|
||||
|
||||
optical = config.radar.laser_control
|
||||
optical.enabled = bool(self._optical_enabled_checkbox.isChecked())
|
||||
optical.port = self._optical_port_input.text().strip()
|
||||
optical.mode = self._optical_mode_combo.currentText()
|
||||
optical.pi_coeff1_p = int(self._optical_pi_coeff1_p_input.text().strip())
|
||||
optical.pi_coeff1_i = int(self._optical_pi_coeff1_i_input.text().strip())
|
||||
optical.pi_coeff2_p = int(self._optical_pi_coeff2_p_input.text().strip())
|
||||
optical.pi_coeff2_i = int(self._optical_pi_coeff2_i_input.text().strip())
|
||||
|
||||
optical.manual.temp1 = float(self._optical_manual_temp1_input.text().strip())
|
||||
optical.manual.temp2 = float(self._optical_manual_temp2_input.text().strip())
|
||||
optical.manual.current1 = float(self._optical_manual_current1_input.text().strip())
|
||||
optical.manual.current2 = float(self._optical_manual_current2_input.text().strip())
|
||||
|
||||
optical.variation.variation_type = self._optical_variation_type_combo.currentText()
|
||||
optical.variation.static_temp1 = float(self._optical_static_temp1_input.text().strip())
|
||||
optical.variation.static_temp2 = float(self._optical_static_temp2_input.text().strip())
|
||||
optical.variation.static_current1 = float(self._optical_static_current1_input.text().strip())
|
||||
optical.variation.static_current2 = float(self._optical_static_current2_input.text().strip())
|
||||
optical.variation.min_value = float(self._optical_min_value_input.text().strip())
|
||||
optical.variation.max_value = float(self._optical_max_value_input.text().strip())
|
||||
optical.variation.step = float(self._optical_step_input.text().strip())
|
||||
optical.variation.time_step = int(self._optical_time_step_input.text().strip())
|
||||
optical.variation.delay_time = int(self._optical_delay_time_input.text().strip())
|
||||
|
||||
@staticmethod
|
||||
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
|
||||
"""Return `True` when non-GPR switch setup effectively yields one fixed combo."""
|
||||
if config.is_multi_device:
|
||||
return False
|
||||
has_single_position = config.input_switch.positions <= 1 and config.output_switch.positions <= 1
|
||||
both_mock = config.input_switch.driver_mode == "mock" and config.output_switch.driver_mode == "mock"
|
||||
return has_single_position or both_mock
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Facade mixin composing focused config/profile/runtime controller mixins."""
|
||||
|
||||
from python_app.gui.controllers.app_window_config import (
|
||||
AppWindowConfigProfileIOMixin,
|
||||
AppWindowConfigStateBuildersMixin,
|
||||
AppWindowLiveProcessingMixin,
|
||||
AppWindowRadarLimitsMixin,
|
||||
)
|
||||
|
||||
|
||||
class AppWindowConfigMixin(
|
||||
AppWindowConfigStateBuildersMixin,
|
||||
AppWindowConfigProfileIOMixin,
|
||||
AppWindowLiveProcessingMixin,
|
||||
AppWindowRadarLimitsMixin,
|
||||
):
|
||||
"""Backward-compatible facade for AppWindow config-related behavior."""
|
||||
|
||||
pass
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Mixin wiring a physical GPIO control button to a runtime action.
|
||||
|
||||
Both GUI and headless launches construct :class:`AppWindow`, so attaching the
|
||||
watcher here makes the button behave identically in both modes. The button
|
||||
reuses the existing "Capture Tmp Reference" flow (stop the pipeline, capture a
|
||||
fresh tmp reference, then restart the pipeline if it had been running), so no
|
||||
acquisition logic is duplicated for the hardware trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.control_button import ControlButtonWatcher
|
||||
from python_app.models.run_config_schema import ControlButtonModel
|
||||
|
||||
|
||||
class AppWindowControlButtonMixin:
|
||||
"""Start and stop a background GPIO button watcher bound to a runtime action."""
|
||||
|
||||
def _init_control_button_state(self) -> None:
|
||||
"""Initialize the watcher handle before the watcher is started."""
|
||||
self._control_button_watcher: ControlButtonWatcher | None = None
|
||||
# Re-entrancy guard: ignore presses that arrive while a capture is in progress.
|
||||
self._control_button_busy = False
|
||||
|
||||
def _start_control_button_watcher(self) -> None:
|
||||
"""Open the configured GPIO button line and begin watching for presses.
|
||||
|
||||
Any failure is logged and swallowed: the same build runs on developer
|
||||
machines and non-Pi hosts where the GPIO chip is absent, and a missing
|
||||
button must never abort startup.
|
||||
"""
|
||||
config = getattr(self._defaults_config, "control_button", None)
|
||||
if config is None or not config.enabled:
|
||||
return
|
||||
if config.pin < 0 or not config.gpio_chip:
|
||||
self._log_warning(
|
||||
"Control button enabled but gpio_chip/pin are unset; watcher not started."
|
||||
)
|
||||
return
|
||||
if config.action != ControlButtonModel.ACTION_CAPTURE_TMP_REFERENCE:
|
||||
self._log_warning(
|
||||
f"Control button action '{config.action}' is not supported; watcher not started."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
watcher = ControlButtonWatcher(
|
||||
chip=config.gpio_chip,
|
||||
pin=config.pin,
|
||||
active_low=config.active_low,
|
||||
bias=config.bias,
|
||||
debounce_ms=config.debounce_ms,
|
||||
parent=self,
|
||||
)
|
||||
watcher.pressed.connect(self._on_control_button_pressed)
|
||||
watcher.failed.connect(self._on_control_button_failed)
|
||||
watcher.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to start GPIO control button watcher", exc, level="WARN")
|
||||
return
|
||||
|
||||
self._control_button_watcher = watcher
|
||||
self._log(
|
||||
"GPIO control button watcher started: "
|
||||
f"chip={config.gpio_chip}, pin={config.pin}, active_low={config.active_low}, "
|
||||
f"debounce_ms={config.debounce_ms}, action={config.action}"
|
||||
)
|
||||
|
||||
def _on_control_button_pressed(self) -> None:
|
||||
"""Run the configured action for a physical press on the Qt main thread.
|
||||
|
||||
Delivered as a queued signal from the watcher thread, so this executes
|
||||
on the GUI thread exactly like a click on "Capture Tmp Reference".
|
||||
"""
|
||||
# The shared re-entrancy guard lives in _capture_tmp_reference, so this GPIO
|
||||
# trigger and the GUI "Capture Tmp Reference" button are protected by one
|
||||
# mechanism (a press during an in-progress capture is ignored there).
|
||||
self._log("GPIO control button pressed: capturing tmp reference.")
|
||||
self._capture_tmp_reference()
|
||||
|
||||
def _on_control_button_failed(self, message: str) -> None:
|
||||
"""Log an unrecoverable watcher error reported from the background thread."""
|
||||
self._log_warning("GPIO control button watcher stopped", details=message)
|
||||
|
||||
def _stop_control_button_watcher(self) -> None:
|
||||
"""Stop the watcher and release its GPIO line during shutdown."""
|
||||
watcher = getattr(self, "_control_button_watcher", None)
|
||||
if watcher is None:
|
||||
return
|
||||
# Disconnect first so a press queued before teardown cannot run a slot afterwards.
|
||||
self._disconnect_control_button_signals(watcher)
|
||||
try:
|
||||
watcher.stop()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning("Error stopping GPIO control button watcher", details=str(exc))
|
||||
finally:
|
||||
# Disconnect again in case stop() re-emitted, then drop and schedule deletion.
|
||||
self._disconnect_control_button_signals(watcher)
|
||||
self._control_button_watcher = None
|
||||
watcher.deleteLater()
|
||||
|
||||
@staticmethod
|
||||
def _disconnect_control_button_signals(watcher: ControlButtonWatcher) -> None:
|
||||
"""Detach the watcher's signals from their slots, tolerating already-disconnected."""
|
||||
for signal in (watcher.pressed, watcher.failed):
|
||||
try:
|
||||
signal.disconnect()
|
||||
except (TypeError, RuntimeError):
|
||||
# No connections left (or the C++ object is already gone): nothing to do.
|
||||
pass
|
||||
@@ -1,781 +0,0 @@
|
||||
"""Pipeline runtime lifecycle mixin for the main GUI window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import QCoreApplication, QEventLoop
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import sar3d_status
|
||||
from python_app.gui.runtime.history import (
|
||||
build_processor_run_signature,
|
||||
build_run_history_signature,
|
||||
record_result_history,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.models.run_config_validation import validate_acquisition_config
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_SPECS,
|
||||
REQUIRED_PREPROCESS_ASSET_KEYS,
|
||||
preprocess_asset_model,
|
||||
runtime_preprocess_asset_keys,
|
||||
)
|
||||
from python_app.orchestration.restart_policy import RestartPolicy
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
|
||||
|
||||
class AppWindowPipelineMixin:
|
||||
"""Controls start/stop, readers, and periodic polling of pipeline rings."""
|
||||
|
||||
# A repeated identical reader error is deduped from the log, but must still be
|
||||
# re-logged every this-many polls so a persistent failure stays visible.
|
||||
_READER_ERROR_RELOG_EVERY = 200
|
||||
# After this many consecutive identical reader errors, attempt one reader
|
||||
# reconnect; if it keeps failing past the next threshold, stop the pipeline so
|
||||
# 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."""
|
||||
return self._supervisor.is_processor_running() and self._processor_run_signature != run_signature
|
||||
|
||||
def _start_single_capture(self) -> None:
|
||||
"""Start acquisition in single-capture mode."""
|
||||
self._start_run(single_capture=True)
|
||||
|
||||
def _start_run(self, *, single_capture: bool = False, config_override: RunConfigModel | None = None) -> None:
|
||||
"""Start pipeline processes and ring readers."""
|
||||
if getattr(self, "_closing", False) or getattr(self, "_close_after_stop", False):
|
||||
return
|
||||
if getattr(self, "_stopping_run", False):
|
||||
self._show_error("Wait for pipeline shutdown before starting again")
|
||||
return
|
||||
if config_override is None and getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Cannot start pipeline during active capture sequence",
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
if self._supervisor.is_running():
|
||||
# A single-shot capture from a running continuous pipeline must
|
||||
# restart acquisition with `runtime.continuous=false`; refusing
|
||||
# here would leave the previous run streaming and the user could
|
||||
# never reach the single-capture termination state.
|
||||
if not single_capture:
|
||||
self._show_error(
|
||||
"Pipeline is already running",
|
||||
details=self._process_state_details(),
|
||||
)
|
||||
return
|
||||
self._log("Stopping continuous pipeline before single capture")
|
||||
self._stop_run()
|
||||
if getattr(self, "_closing", False):
|
||||
return
|
||||
|
||||
try:
|
||||
processor_was_running = self._supervisor.is_processor_running()
|
||||
config = config_override.clone() if config_override is not None else self._build_config()
|
||||
validate_acquisition_config(config)
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if self._processor_requires_restart(processor_signature):
|
||||
self._log("Restarting data_processor because stable run settings changed")
|
||||
self._stop_all_processes()
|
||||
if getattr(self, "_closing", False):
|
||||
return
|
||||
processor_was_running = False
|
||||
if not processor_was_running:
|
||||
self._reset_runtime_history()
|
||||
|
||||
if config_override is None:
|
||||
self._validate_processing_mode_constraints(config)
|
||||
radar_key = self._radar_key(config)
|
||||
|
||||
missing_assets = [
|
||||
PREPROCESS_ASSET_SPECS[key].display_name
|
||||
for key in REQUIRED_PREPROCESS_ASSET_KEYS
|
||||
if not preprocess_asset_model(config, key).set_name
|
||||
]
|
||||
if missing_assets:
|
||||
raise RuntimeError(
|
||||
"Select all required preprocess sets in Preprocessing Panel before Start: "
|
||||
+ ", ".join(missing_assets)
|
||||
)
|
||||
|
||||
combo_keys = [ComboKey(input=combo.input, output=combo.output) for combo in config.combos]
|
||||
active_preprocess_keys = runtime_preprocess_asset_keys(config)
|
||||
preprocess_summary = "; ".join(
|
||||
f"{PREPROCESS_ASSET_SPECS[key].display_name}={preprocess_asset_model(config, key).set_name}"
|
||||
for key in active_preprocess_keys
|
||||
)
|
||||
self._log(f"Active preprocess assets for run: {preprocess_summary}")
|
||||
|
||||
for key in active_preprocess_keys:
|
||||
spec = PREPROCESS_ASSET_SPECS[key]
|
||||
asset = preprocess_asset_model(config, key)
|
||||
if not self._store.has_combo_coverage(spec.set_kind, radar_key, asset.set_name, combo_keys):
|
||||
raise RuntimeError(f"Selected {spec.display_name} set does not cover requested run combos")
|
||||
|
||||
self._config_writer.prepare_preprocess_bundles(self._store, radar_key, config)
|
||||
config.runtime.continuous = not single_capture
|
||||
|
||||
if not single_capture and not config.is_kamil_adc:
|
||||
self._prepare_radar_for_native_acquisition(config)
|
||||
|
||||
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
|
||||
combo_preview = ", ".join(f"in{combo.input}/out{combo.output}" for combo in config.combos[:6])
|
||||
if len(config.combos) > 6:
|
||||
combo_preview += ", ..."
|
||||
self._log(
|
||||
f"Starting pipeline: mode={'single_capture' if single_capture else 'continuous'}, "
|
||||
f"config={config_path}, combos={len(config.combos)}"
|
||||
f"{', ' + combo_preview if combo_preview else ''}, radar_key={radar_key}"
|
||||
)
|
||||
|
||||
if self._history_run_signature is not None and self._history_run_signature != run_signature:
|
||||
if processor_was_running:
|
||||
self._reset_runtime_history()
|
||||
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
|
||||
self._log("History reset because run settings changed")
|
||||
self._history_run_signature = run_signature
|
||||
|
||||
# Open the single-capture acquisition window BEFORE the producer can emit:
|
||||
# any collection the one-shot orchestrator publishes is then strictly newer
|
||||
# than this timestamp and is recognized as the target. Reading the clock
|
||||
# AFTER start() raced a fast (simulator) orchestrator that had already
|
||||
# published — its collection.monotonic_ns < start_ns, so the capture never
|
||||
# completed and the GUI hung.
|
||||
single_capture_start_ns = time.monotonic_ns() if single_capture else None
|
||||
|
||||
# `data_preprocessor` is about to become the owner of the preprocessed ring
|
||||
# again, so the GUI must stop holding a writer into it. A queued replay would
|
||||
# otherwise interleave with the real producer.
|
||||
self._bscan_reprocess_timer.stop()
|
||||
self._close_replay_ring_writer()
|
||||
self._discard_bscan_replay_results()
|
||||
|
||||
self._supervisor.start(config_path, allow_clean_orchestrator_exit=single_capture)
|
||||
self._close_readers()
|
||||
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 = processor_signature
|
||||
self._single_capture_active = single_capture
|
||||
self._single_capture_start_ns = single_capture_start_ns
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
|
||||
if not single_capture:
|
||||
# Continuous start: discard any stale tail so rendering begins fresh.
|
||||
# Single capture must NOT drop here — the drop races with (and can
|
||||
# discard) the one-shot result; it instead filters stale data by the
|
||||
# start timestamp recorded above.
|
||||
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
|
||||
|
||||
pid_map = self._supervisor.pids()
|
||||
pid_text = ", ".join(f"{name}={pid}" for name, pid in sorted(pid_map.items())) or "none"
|
||||
if single_capture:
|
||||
self._status_label.setText("Status: single capture running")
|
||||
self._log(f"Single capture started; managed processes: {pid_text}")
|
||||
else:
|
||||
self._status_label.setText("Status: running")
|
||||
self._log(f"Pipeline started; managed processes: {pid_text}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns = None
|
||||
self._stop_all_processes()
|
||||
self._show_exception("Failed to start pipeline", exc)
|
||||
|
||||
def _apply_radar_settings(self) -> None:
|
||||
"""Apply current radar settings by preconfiguring native device."""
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Finish or abort capture sequence before applying radar settings",
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
|
||||
was_running = self._supervisor.is_running()
|
||||
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
processor_only_running = self._supervisor.is_processor_running() and not was_running
|
||||
if was_running:
|
||||
self._stop_run()
|
||||
if not self._pipeline_resume_allowed(resume_seq):
|
||||
return
|
||||
|
||||
try:
|
||||
config = self._build_config()
|
||||
validate_acquisition_config(config)
|
||||
if config.radar.driver_mode == "native" and not config.radar.replay.dataset_dir:
|
||||
self._refresh_radar_limits_from_device()
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if processor_only_running and self._processor_requires_restart(processor_signature):
|
||||
self._stop_all_processes()
|
||||
self._reset_runtime_history()
|
||||
self._history_run_signature = None
|
||||
self._log(
|
||||
"Stable run settings changed; stopped data_processor because cached history no longer "
|
||||
"matches the current config"
|
||||
)
|
||||
self._prepare_radar_for_native_acquisition(config)
|
||||
points_text = (
|
||||
"points=from Kamil ADC stream"
|
||||
if config.is_kamil_adc
|
||||
else f"points={config.radar.sweep.points}"
|
||||
)
|
||||
self._log(
|
||||
"Radar settings applied: "
|
||||
f"start={config.radar.sweep.start_hz:g} Hz, "
|
||||
f"stop={config.radar.sweep.stop_hz:g} Hz, "
|
||||
f"{points_text}, "
|
||||
f"ifbw={config.radar.sweep.if_bandwidth_hz:g} Hz, "
|
||||
f"power={config.radar.sweep.power_dbm:g} dBm"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to apply radar settings", exc)
|
||||
finally:
|
||||
if was_running and self._pipeline_resume_allowed(resume_seq):
|
||||
self._start_run()
|
||||
|
||||
def _prepare_radar_for_native_acquisition(self, config: RunConfigModel) -> None:
|
||||
"""Preconfigure native single-radar hardware using current sweep settings."""
|
||||
if config.radar.replay.dataset_dir:
|
||||
self._log("Radar pre-configuration skipped (dataset replay)")
|
||||
return
|
||||
if config.radar.driver_mode != "native":
|
||||
self._log("Radar pre-configuration skipped (mock mode)")
|
||||
return
|
||||
if config.is_multi_device:
|
||||
self._log("Multi-device raw producer will configure all LibreVNA devices")
|
||||
return
|
||||
if config.is_matrix_radar:
|
||||
self._log("Matrix raw producer will configure the matrix radar")
|
||||
return
|
||||
if config.is_kamil_adc:
|
||||
if apply_kamil_adc_laser_control(config):
|
||||
self._log("Kamil ADC laser_control applied via Apply Radar")
|
||||
else:
|
||||
self._log("Kamil ADC laser_control skipped because it is disabled")
|
||||
return
|
||||
|
||||
radar_service = create_single_radar_service(config)
|
||||
try:
|
||||
radar_service.open()
|
||||
radar_service.configure(config.radar.sweep)
|
||||
finally:
|
||||
radar_service.close()
|
||||
|
||||
self._log(f"Radar pre-configured via Python driver: model={config.radar.model}")
|
||||
|
||||
def _stop_run(self) -> None:
|
||||
"""Stop acquisition-side processes and close readers as needed."""
|
||||
self._stop_request_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._stop_pipeline(keep_processor=True)
|
||||
|
||||
def _stop_all_processes(self) -> None:
|
||||
"""Stop all managed pipeline processes and close all readers."""
|
||||
self._stop_pipeline(keep_processor=False)
|
||||
|
||||
def _stop_pipeline(self, *, keep_processor: bool) -> None:
|
||||
"""Share shutdown/drain/finalization, protecting event-loop re-entry."""
|
||||
self._pipeline_should_run = False
|
||||
if getattr(self, "_stopping_run", False):
|
||||
self._stop_all_requested |= not keep_processor
|
||||
return
|
||||
self._stopping_run = True
|
||||
self._stop_all_requested = not keep_processor
|
||||
try:
|
||||
drain_error = None
|
||||
try:
|
||||
was_running = self._supervisor.is_running()
|
||||
if keep_processor and was_running:
|
||||
self._supervisor.stop_orchestrator()
|
||||
self._drain_rings_until_quiet(timeout_s=0.35, poll_s=0.02)
|
||||
self._supervisor.stop_preprocessor()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
elif keep_processor:
|
||||
self._supervisor.stop()
|
||||
self._drain_rings_once_for_history()
|
||||
if self._stop_all_requested:
|
||||
self._supervisor.stop_all()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
except Exception as exc: # noqa: BLE001 - corrupt data must not prevent Stop
|
||||
drain_error = exc
|
||||
self._stop_request_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
if self._stop_all_requested:
|
||||
self._supervisor.stop_all()
|
||||
else:
|
||||
self._supervisor.stop()
|
||||
if self._stop_all_requested:
|
||||
self._processor_run_signature = None
|
||||
self._finalize_recording_on_stop()
|
||||
keep_results = not self._stop_all_requested and self._supervisor.is_processor_running()
|
||||
result_ring_name = getattr(self._result_reader, "_ring_name", None) if drain_error else None
|
||||
self._close_readers(keep_results=keep_results and drain_error is None)
|
||||
self._single_capture_active = False
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
self._single_capture_target_collection_id = None
|
||||
self._update_history_indicator()
|
||||
self._status_label.setText("Status: idle")
|
||||
self._log("Acquisition stopped (data_processor kept running)" if keep_results else "Pipeline stopped")
|
||||
if drain_error is not None:
|
||||
self._status_label.setText("Status: error")
|
||||
self._show_exception("Pipeline stopped, but final ring data could not be read", drain_error)
|
||||
if keep_results and result_ring_name:
|
||||
self._result_reader = ShmRingReader(result_ring_name)
|
||||
except Exception as exc: # noqa: BLE001 - this is also a direct Qt button slot
|
||||
self._stop_request_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
self._status_label.setText("Status: error")
|
||||
self._show_exception("Failed to stop pipeline", exc)
|
||||
finally:
|
||||
self._stopping_run = False
|
||||
if getattr(self, "_close_after_stop", False):
|
||||
self._close_after_stop = False
|
||||
self.close()
|
||||
|
||||
def _pipeline_resume_allowed(self, stop_seq: int) -> bool:
|
||||
"""A Stop or Close received during a temporary pause cancels its restart."""
|
||||
return not getattr(self, "_closing", False) and not getattr(self, "_close_after_stop", False) and (
|
||||
getattr(self, "_stop_request_seq", 0) == stop_seq
|
||||
)
|
||||
|
||||
def _close_readers(self, *, keep_results: bool = False) -> None:
|
||||
"""Close active ring readers; keep the results reader when ``keep_results``."""
|
||||
closed = []
|
||||
if self._raw_reader is not None:
|
||||
self._raw_reader.close()
|
||||
self._raw_reader = None
|
||||
closed.append("raw")
|
||||
if self._pre_reader is not None:
|
||||
self._pre_reader.close()
|
||||
self._pre_reader = None
|
||||
closed.append("preprocessed")
|
||||
if not keep_results and self._result_reader is not None:
|
||||
self._result_reader.close()
|
||||
self._result_reader = None
|
||||
closed.append("results")
|
||||
if closed:
|
||||
self._log_debug(f"Closed ring readers: {', '.join(closed)}.")
|
||||
|
||||
def _poll_rings(self) -> None:
|
||||
"""Poll readers, ingest history, and trigger rendering."""
|
||||
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
|
||||
return
|
||||
self._web_update_snapshot() # guarded internally; never raises
|
||||
self._handle_process_exit_reports()
|
||||
|
||||
try:
|
||||
if self._raw_reader is not None:
|
||||
self._read_all_raw()
|
||||
self._read_all_preprocessed()
|
||||
result_latest = self._read_all_results() if self._result_reader is not None else None
|
||||
self._poll_recording_writer() # finalize a disk recording once its writer drains
|
||||
self._update_history_indicator()
|
||||
self._last_reader_error_signature = None
|
||||
self._reader_error_repeat_count = 0
|
||||
|
||||
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_results(result_latest)
|
||||
self._pipeline_metrics.record(
|
||||
"rendering", time.monotonic_ns() - render_started_ns
|
||||
)
|
||||
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 getattr(self, "_pipeline_should_run", False) and (
|
||||
unexpected
|
||||
or not {"data_processor", "data_preprocessor", "sweep_orchestrator"}.issubset(self._supervisor.pids())
|
||||
):
|
||||
# Exit reports are consumed once. A missing child must keep triggering
|
||||
# recovery after a back-off delay or a failed start with no reports left.
|
||||
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 subsequent polls
|
||||
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.
|
||||
|
||||
Distinct errors log once; an identical recurring error is deduped from the
|
||||
log but still drives the status label to error, is periodically re-logged,
|
||||
and after escalating thresholds triggers a reader reconnect and finally a
|
||||
pipeline stop so a wedged reader cannot fail silently forever.
|
||||
"""
|
||||
signature = (type(exc).__name__, str(exc))
|
||||
# Always reflect a reader failure in the status label, even when deduped.
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
if self._last_reader_error_signature == signature:
|
||||
self._reader_error_repeat_count = getattr(self, "_reader_error_repeat_count", 0) + 1
|
||||
else:
|
||||
self._last_reader_error_signature = signature
|
||||
self._reader_error_repeat_count = 0
|
||||
self._log_exception("Reader poll failed", exc, level="ERROR")
|
||||
|
||||
repeats = self._reader_error_repeat_count
|
||||
# Periodically re-log a persistent identical failure so it stays visible.
|
||||
if repeats and repeats % self._READER_ERROR_RELOG_EVERY == 0:
|
||||
self._log_exception(
|
||||
f"Reader poll still failing (repeat #{repeats})", exc, level="ERROR"
|
||||
)
|
||||
|
||||
if repeats >= self._READER_ERROR_STOP_AT:
|
||||
# The reader stayed wedged through a reconnect attempt; stop the
|
||||
# pipeline so the failure is unmistakable instead of an endless retry.
|
||||
self._log_error(
|
||||
f"Stopping pipeline after {repeats} consecutive reader poll failures"
|
||||
)
|
||||
self._reader_error_repeat_count = 0
|
||||
self._last_reader_error_signature = None
|
||||
self._stop_all_processes()
|
||||
elif repeats == self._READER_ERROR_RECONNECT_AT:
|
||||
self._reconnect_readers_after_error()
|
||||
|
||||
def _reconnect_readers_after_error(self) -> None:
|
||||
"""Re-open active ring readers in place to recover from a wedged reader."""
|
||||
self._log_warning("Attempting ring reader reconnect after repeated poll failures")
|
||||
try:
|
||||
for attr in ("_raw_reader", "_pre_reader", "_result_reader"):
|
||||
reader = getattr(self, attr)
|
||||
if reader is None:
|
||||
continue
|
||||
ring_name = reader._ring_name # noqa: SLF001 - reuse the reader's own ring name
|
||||
reader.close()
|
||||
setattr(self, attr, ShmRingReader(ring_name))
|
||||
# A successful reconnect clears the error state so the next failure
|
||||
# logs fresh rather than being swallowed by the stale signature.
|
||||
self._last_reader_error_signature = None
|
||||
self._reader_error_repeat_count = 0
|
||||
self._status_label.setText("Status: running")
|
||||
self._log("Ring readers reconnected after repeated poll failures")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Leave the error signature intact so escalation to a stop still fires.
|
||||
self._log_exception("Ring reader reconnect failed", exc, level="ERROR")
|
||||
|
||||
def _finish_single_capture_if_ready(self) -> bool:
|
||||
"""Finalize single capture when the exact target result becomes available."""
|
||||
if not self._single_capture_active:
|
||||
return False
|
||||
if self._single_capture_start_ns is None:
|
||||
return False
|
||||
if not self._single_capture_seen_raw:
|
||||
return False
|
||||
target_result = self._find_single_capture_target_result()
|
||||
if target_result is None:
|
||||
return False
|
||||
|
||||
self._draw_results(target_result)
|
||||
status = sar3d_status(target_result) if self._processing_mode.currentText() == "sar3d" else None
|
||||
self._stop_run()
|
||||
if status is not None and status[0] in (1, 2, 3):
|
||||
state = {1: "no signal in selected range", 2: "error", 3: "warning"}[status[0]]
|
||||
self._status_label.setText(f"Status: {state}")
|
||||
log = self._log_error if status[0] == 2 else self._log_warning
|
||||
log(f"Single capture: SAR3D {state}: {status[1]}")
|
||||
else:
|
||||
self._log("Single capture completed")
|
||||
return True
|
||||
|
||||
def _find_single_capture_target_result(self) -> ResultCollection | None:
|
||||
"""Return the exact result collection corresponding to the captured target raw sweep."""
|
||||
if self._single_capture_target_collection_id is None:
|
||||
return None
|
||||
if self._single_capture_start_ns is None:
|
||||
return None
|
||||
|
||||
for collection in reversed(self._result_history):
|
||||
if collection.collection_id != self._single_capture_target_collection_id:
|
||||
continue
|
||||
if collection.monotonic_ns < self._single_capture_start_ns:
|
||||
continue
|
||||
if not self._result_collection_has_trace(collection):
|
||||
continue
|
||||
return collection
|
||||
|
||||
return None
|
||||
|
||||
def _read_all_raw(self, *, max_collections: int | None = None) -> SweepCollection | None:
|
||||
"""Read available raw collections from raw ring."""
|
||||
if self._raw_reader is None:
|
||||
raise RuntimeError("Raw ring reader is not initialised")
|
||||
latest: SweepCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._raw_history.append(collection)
|
||||
self._record_collection("raw", collection)
|
||||
latest = collection
|
||||
# `capture_*_ns` are populated by the C++ sweep_orchestrator with
|
||||
# wallclocks captured around the actual device read. Pre-orchestrator
|
||||
# producers leave them zero, in which case PipelineMetrics drops it.
|
||||
acquisition_ns = int(collection.capture_end_ns) - int(collection.capture_start_ns)
|
||||
self._pipeline_metrics.record("acquisition", acquisition_ns)
|
||||
if self._single_capture_active and self._single_capture_start_ns is not None:
|
||||
if collection.monotonic_ns >= self._single_capture_start_ns:
|
||||
self._single_capture_seen_raw = True
|
||||
if self._single_capture_target_collection_id is None:
|
||||
self._single_capture_target_collection_id = collection.collection_id
|
||||
return latest
|
||||
|
||||
def _read_all_preprocessed(self, *, max_collections: int | None = None) -> SweepCollection | None:
|
||||
"""Read available preprocessed collections from preprocessed ring."""
|
||||
if self._pre_reader is None:
|
||||
return
|
||||
|
||||
latest: SweepCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._pre_reader.pop_preprocessed_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._pre_history.append(collection)
|
||||
self._record_collection("preprocessed", collection)
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def _read_all_results(self, *, max_collections: int | None = None) -> ResultCollection | None:
|
||||
"""Read available result collections from results ring."""
|
||||
if self._result_reader is None:
|
||||
raise RuntimeError("Result ring reader is not initialised")
|
||||
latest: ResultCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
|
||||
# Only a new sweep is a new measurement. Any other result is the processor
|
||||
# reprocessing retained sweeps: recording it would duplicate a measurement
|
||||
# or put one out of order.
|
||||
if record_result_history(self._result_history, collection):
|
||||
self._record_collection("results", collection)
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def _pump_events_during_drain(self, pause_s: float) -> None:
|
||||
"""Yield to the Qt event loop for `pause_s` instead of blocking on time.sleep.
|
||||
|
||||
The bounded drain loops run on the GUI thread; a raw time.sleep here freezes
|
||||
the event loop, stalling the keepalive/headless-watchdog timers and starving
|
||||
queued signals. Pumping events keeps the daemon responsive while we wait.
|
||||
"""
|
||||
app = QCoreApplication.instance()
|
||||
if app is None:
|
||||
# No event loop (e.g. unit context); fall back to a plain short sleep.
|
||||
time.sleep(pause_s)
|
||||
return
|
||||
deadline = time.monotonic() + pause_s
|
||||
while True:
|
||||
remaining_ms = int((deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
break
|
||||
app.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, remaining_ms)
|
||||
# processEvents returns immediately when the queue empties; sleep the
|
||||
# residual in tiny slices so we neither busy-spin nor block too long.
|
||||
time.sleep(min(0.002, max(0.0, deadline - time.monotonic())))
|
||||
|
||||
def _drain_rings_once_for_history(self) -> bool:
|
||||
"""Perform one non-blocking read pass to extend histories."""
|
||||
raw = self._read_all_raw() if self._raw_reader is not None else None
|
||||
preprocessed = self._read_all_preprocessed()
|
||||
result = self._read_all_results() if self._result_reader is not None else None
|
||||
return raw is not None or preprocessed is not None or result is not None
|
||||
|
||||
def _drain_rings_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
|
||||
"""Drain rings until readers are quiet or timeout expires, even at full history capacity."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
stable_rounds = 0
|
||||
while time.monotonic() < deadline and stable_rounds < 2:
|
||||
stable_rounds = 0 if self._drain_rings_once_for_history() else stable_rounds + 1
|
||||
# Event-loop-friendly wait so timers/signals keep firing during drain.
|
||||
self._pump_events_during_drain(poll_s)
|
||||
|
||||
def _drain_results_until_quiet(self, *, timeout_s: float, poll_s: float) -> ResultCollection | None:
|
||||
"""Drain results until at least one result arrives and the ring becomes quiet."""
|
||||
if self._result_reader is None:
|
||||
return None
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
stable_rounds = 0
|
||||
latest_seen: ResultCollection | None = None
|
||||
|
||||
while time.monotonic() < deadline and (latest_seen is None or stable_rounds < 2):
|
||||
latest = self._read_all_results()
|
||||
if latest is None:
|
||||
if latest_seen is not None:
|
||||
stable_rounds += 1
|
||||
else:
|
||||
latest_seen = latest
|
||||
stable_rounds = 0
|
||||
# Event-loop-friendly wait so timers/signals keep firing during drain.
|
||||
self._pump_events_during_drain(poll_s)
|
||||
return latest_seen
|
||||
|
||||
def _update_history_indicator(self) -> None:
|
||||
"""Update UI label with current history buffer sizes."""
|
||||
self._history_label.setText(
|
||||
f"History: raw={len(self._raw_history)}, "
|
||||
f"preprocessed={len(self._pre_history)}, "
|
||||
f"results={len(self._result_history)}"
|
||||
)
|
||||
|
||||
def _reset_runtime_history(self) -> None:
|
||||
"""Reset runtime history and B-scan caches."""
|
||||
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
||||
self._discard_bscan_replay_results()
|
||||
self._clear_history_mode_caches()
|
||||
self._update_history_indicator()
|
||||
|
||||
def _replace_runtime_history(
|
||||
self,
|
||||
*,
|
||||
retained_raw: list[SweepCollection],
|
||||
retained_pre: list[SweepCollection],
|
||||
retained_result: list[ResultCollection],
|
||||
) -> None:
|
||||
"""Replace history deques with provided retained tails."""
|
||||
raw_tail = retained_raw[-self._raw_history.maxlen :] if self._raw_history.maxlen is not None else retained_raw
|
||||
pre_tail = retained_pre[-self._pre_history.maxlen :] if self._pre_history.maxlen is not None else retained_pre
|
||||
result_tail = (
|
||||
retained_result[-self._result_history.maxlen :]
|
||||
if self._result_history.maxlen is not None
|
||||
else retained_result
|
||||
)
|
||||
|
||||
self._raw_history.clear()
|
||||
self._pre_history.clear()
|
||||
self._result_history.clear()
|
||||
self._raw_history.extend(raw_tail)
|
||||
self._pre_history.extend(pre_tail)
|
||||
self._result_history.extend(result_tail)
|
||||
|
||||
def _build_run_history_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when display history should be reset."""
|
||||
return build_run_history_signature(config)
|
||||
|
||||
def _build_processor_run_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when the data_processor must be restarted."""
|
||||
return build_processor_run_signature(config)
|
||||
|
||||
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
|
||||
"""Validate processing-mode constraints for run start."""
|
||||
validate_processing_mode_constraints(
|
||||
self._processing_mode.currentText(),
|
||||
config,
|
||||
self._live_processing_config(),
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Plot-rendering mixins split by rendering mode."""
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import AppWindowBscanPlotMixin
|
||||
from python_app.gui.controllers.app_window_plot.bscan_replay_mixin import AppWindowBscanReplayMixin
|
||||
from python_app.gui.controllers.app_window_plot.gpr_plot_mixin import AppWindowGprPlotMixin
|
||||
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import AppWindowSar3dPlotMixin
|
||||
from python_app.gui.controllers.app_window_plot.trace_plot_mixin import AppWindowTracePlotMixin
|
||||
|
||||
__all__ = [
|
||||
"AppWindowBscanPlotMixin",
|
||||
"AppWindowBscanReplayMixin",
|
||||
"AppWindowGprPlotMixin",
|
||||
"AppWindowSar3dPlotMixin",
|
||||
"AppWindowTracePlotMixin",
|
||||
]
|
||||
@@ -1,470 +0,0 @@
|
||||
"""B-scan rendering and cache helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
from PyQt6.QtCore import QRectF
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
|
||||
|
||||
def _result_tail(
|
||||
*,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
) -> list[ResultCollection]:
|
||||
"""Return the de-duplicated newest `history_limit` entries for B-scan usage.
|
||||
|
||||
Selection is purely positional. An earlier version also dropped entries below a
|
||||
`collection_id` floor, which cannot work here: ids are neither dense (the results
|
||||
ring overwrites unread slots) nor monotonic across a run boundary (the C++ side
|
||||
numbers from 1 again). Given the floor was derived from the first entry of this
|
||||
very slice, the comparison provably removed nothing when ids ascend, and removed
|
||||
exactly the newest frames when they do not.
|
||||
"""
|
||||
unique_reversed_tail: list[ResultCollection] = []
|
||||
seen_keys: set[tuple[int, int]] = set()
|
||||
for collection in reversed(result_history[-history_limit:]):
|
||||
key = (int(collection.collection_id), int(collection.monotonic_ns))
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
unique_reversed_tail.append(collection)
|
||||
|
||||
unique_reversed_tail.reverse()
|
||||
return unique_reversed_tail
|
||||
|
||||
|
||||
def build_bscan_signature(
|
||||
live_config: ProcessingLiveConfig,
|
||||
*,
|
||||
subtract_mean_ascan_enabled: bool,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature used to detect B-scan cache invalidation."""
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
)
|
||||
return (
|
||||
int(history_limit),
|
||||
str(live_config.bscan_axis),
|
||||
str(live_config.bscan_channel),
|
||||
float(live_config.bscan_cut_m),
|
||||
float(live_config.bscan_max_depth_m),
|
||||
float(live_config.bscan_gain),
|
||||
float(live_config.bscan_start_freq_mhz),
|
||||
float(live_config.bscan_stop_freq_mhz),
|
||||
bool(subtract_mean_ascan_enabled),
|
||||
tuple((int(collection.collection_id), int(collection.monotonic_ns), len(collection.blocks)) for collection in result_tail),
|
||||
)
|
||||
|
||||
|
||||
def apply_mean_ascan_subtraction(
|
||||
history: deque[np.ndarray],
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> np.ndarray:
|
||||
"""Subtract mean A-scan across sweeps when enabled."""
|
||||
sweeps = np.vstack(history).astype(np.float32, copy=False)
|
||||
if not enabled:
|
||||
return sweeps
|
||||
mean_trace = np.mean(sweeps, axis=0, dtype=np.float32)
|
||||
return sweeps - mean_trace
|
||||
|
||||
|
||||
def rebuild_bscan_history_from_results(
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
stats: dict[str, object] | None = None,
|
||||
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
||||
"""Rebuild B-scan history and depth axes from processed result payloads.
|
||||
|
||||
Pass `stats` to receive a breakdown of why the rebuilt image may hold fewer
|
||||
columns than `history_limit`. The three causes are independent and only
|
||||
distinguishable here: too little history, frames carrying no `bscan` payload, and
|
||||
depth-axis changes that reset the accumulated deque (see below).
|
||||
"""
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
)
|
||||
axis_resets = 0
|
||||
without_bscan = 0
|
||||
|
||||
for collection in result_tail:
|
||||
carried_bscan = False
|
||||
for block in collection.blocks:
|
||||
key = (block.combo.input, block.combo.output)
|
||||
for payload in block.payloads:
|
||||
if payload.kind != 1 or payload.processing_name != "bscan":
|
||||
continue
|
||||
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
|
||||
continue
|
||||
if payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
|
||||
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
|
||||
if depth_axis.size == 0 or amplitudes.size == 0:
|
||||
continue
|
||||
|
||||
carried_bscan = True
|
||||
history = history_by_combo.get(key)
|
||||
stored_axis = depth_axis_by_combo.get(key)
|
||||
if (
|
||||
history is None
|
||||
or stored_axis is None
|
||||
or stored_axis.shape != depth_axis.shape
|
||||
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
||||
):
|
||||
# A changed depth axis makes previously accumulated columns
|
||||
# un-stackable, so the deque restarts and everything gathered so far
|
||||
# for this combo is dropped. Frames computed with different
|
||||
# bscan_max_depth_m / frequency bounds land here — which is exactly
|
||||
# what a partially replayed history looks like.
|
||||
if history is not None:
|
||||
axis_resets += 1
|
||||
history = deque(maxlen=history_limit)
|
||||
history_by_combo[key] = history
|
||||
depth_axis_by_combo[key] = depth_axis.copy()
|
||||
|
||||
history.append(amplitudes.copy())
|
||||
if not carried_bscan:
|
||||
without_bscan += 1
|
||||
|
||||
if stats is not None:
|
||||
stats["tail"] = len(result_tail)
|
||||
stats["without_bscan"] = without_bscan
|
||||
stats["axis_resets"] = axis_resets
|
||||
|
||||
return history_by_combo, depth_axis_by_combo
|
||||
|
||||
|
||||
def pick_bscan_display_key(
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
||||
preferred_key: tuple[int, int] | None = None,
|
||||
) -> tuple[int, int] | None:
|
||||
"""Choose combo key to display when multiple histories are present."""
|
||||
if not history_by_combo:
|
||||
return None
|
||||
if preferred_key is not None and preferred_key in history_by_combo:
|
||||
return preferred_key
|
||||
return next(iter(history_by_combo.keys()))
|
||||
|
||||
|
||||
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
|
||||
"""Build B-scan colormap table for selected axis mode."""
|
||||
if axis_mode == "abs":
|
||||
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
|
||||
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
|
||||
|
||||
|
||||
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||
"""Interpolate hex color stops into 8-bit RGB LUT array."""
|
||||
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
|
||||
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
|
||||
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
|
||||
|
||||
lut = np.empty((size, 3), dtype=np.uint8)
|
||||
for channel in range(3):
|
||||
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
|
||||
return lut
|
||||
|
||||
|
||||
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||
"""Compute image levels for B-scan data based on axis mode."""
|
||||
min_value = float(np.min(sweeps))
|
||||
max_value = float(np.max(sweeps))
|
||||
if axis_mode == "abs":
|
||||
if max_value <= min_value:
|
||||
return min_value, min_value + 1e-6
|
||||
return min_value, max_value
|
||||
|
||||
max_abs = max(abs(min_value), abs(max_value), 1e-6)
|
||||
return -max_abs, max_abs
|
||||
|
||||
|
||||
class AppWindowBscanPlotMixin:
|
||||
"""Renders B-scan heatmaps and maintains B-scan history caches."""
|
||||
|
||||
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
|
||||
"""Draw B-scan image rebuilt from processed result history."""
|
||||
self._trace_phase_plot.clear()
|
||||
self._sync_bscan_history_from_results()
|
||||
return self._draw_bscan_heatmap_from_history()
|
||||
|
||||
def _on_bscan_display_combo_changed(self) -> None:
|
||||
"""Show the selected combo's B-scan; the histories of all combos are already cached."""
|
||||
if self._processing_mode.currentText() != "bscan":
|
||||
return
|
||||
try:
|
||||
self._sync_bscan_history_from_results()
|
||||
self._draw_bscan_heatmap_from_history()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to redraw the B-scan", exc)
|
||||
|
||||
def _configure_bscan_plot_axes(self) -> None:
|
||||
"""Apply persistent B-scan plot axis labels and base view settings."""
|
||||
plot = self._bscan_plot
|
||||
plot_item = plot.getPlotItem()
|
||||
plot_item.showAxis("left", show=True)
|
||||
plot_item.showAxis("bottom", show=True)
|
||||
plot.setLabel("bottom", "Sweep #")
|
||||
plot.setLabel("left", "Range", units="m")
|
||||
view_box = plot.getViewBox()
|
||||
view_box.invertY(False)
|
||||
view_box.enableAutoRange(x=False, y=False)
|
||||
|
||||
def _draw_bscan_heatmap_from_history(self) -> bool:
|
||||
"""Render B-scan heatmap from currently cached history arrays."""
|
||||
display_key = self._pick_bscan_display_key()
|
||||
if display_key is None:
|
||||
return False
|
||||
|
||||
history = self._bscan_history_by_combo.get(display_key)
|
||||
depth_axis = self._bscan_depth_axis_by_combo.get(display_key)
|
||||
if not history or depth_axis is None:
|
||||
return False
|
||||
|
||||
sweeps = apply_mean_ascan_subtraction(
|
||||
history,
|
||||
enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
||||
)
|
||||
if sweeps.size == 0:
|
||||
return False
|
||||
|
||||
depth_min = float(np.min(depth_axis))
|
||||
depth_max = float(np.max(depth_axis))
|
||||
depth_span = max(depth_max - depth_min, 1e-6)
|
||||
sweep_count = sweeps.shape[0]
|
||||
self._warn_if_bscan_window_underfilled()
|
||||
sweep_width = float(max(sweep_count, 1))
|
||||
x_min = 0.5
|
||||
x_max = x_min + sweep_width
|
||||
|
||||
image_item = pg.ImageItem(axisOrder="row-major")
|
||||
image_item.setImage(sweeps.T, autoLevels=False)
|
||||
image_item.setRect(QRectF(x_min, depth_min, sweep_width, depth_span))
|
||||
|
||||
axis_mode = self._bscan_axis.currentText()
|
||||
image_item.setLookupTable(bscan_lookup_table(axis_mode))
|
||||
image_item.setLevels(bscan_levels(sweeps, axis_mode))
|
||||
|
||||
self._bscan_plot.clear()
|
||||
self._configure_bscan_plot_axes()
|
||||
self._bscan_plot.addItem(image_item)
|
||||
self._bscan_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
self._bscan_plot.setYRange(depth_min, depth_max, padding=0.02)
|
||||
bscan_channel = "S21"
|
||||
self._bscan_plot.setTitle(
|
||||
f"B-scan {bscan_channel} in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}"
|
||||
)
|
||||
return True
|
||||
|
||||
def _warn_if_bscan_window_underfilled(self) -> None:
|
||||
"""Warn once when there is less retained raw material than the operator asked for.
|
||||
|
||||
Settings edits are recomputed across the whole image by re-feeding
|
||||
`_pre_history` to the processor (see :class:`AppWindowBscanReplayMixin`), so a
|
||||
wide window is no longer a coherency problem. What it can still be is an empty
|
||||
promise: asking for more sweeps than were ever captured simply shows fewer.
|
||||
"""
|
||||
window = self._bscan_display_window_scans()
|
||||
retained = len(self._pre_history)
|
||||
if retained >= window:
|
||||
return
|
||||
|
||||
self._log_warning(
|
||||
f"B-scan is set to show {window} sweeps but only {retained} are retained; "
|
||||
"the image shows what history there is.",
|
||||
details=(
|
||||
"The GUI keeps a bounded history of preprocessed sweeps, so a window "
|
||||
"wider than the run itself cannot be filled.\n"
|
||||
f"Capture more sweeps, or set 'Scans to show (stopped)' to {retained} or less."
|
||||
),
|
||||
# Keyed on the operator-controlled window rather than the live retained count,
|
||||
# so that repeated "Remove Last" does not re-warn on every click.
|
||||
once_key=f"bscan_window_underfilled_{window}",
|
||||
)
|
||||
|
||||
def _bscan_display_window_scans(self) -> int:
|
||||
"""Return how many past sweeps the B-scan should render right now.
|
||||
|
||||
While acquisition runs the window stays at the C++ replay window: results
|
||||
arrive continuously, the whole history is rebuilt on every new one, and a
|
||||
1000-wide rebuild on the live path would cost ~20x per frame.
|
||||
|
||||
Once stopped, the operator reviews a frozen history, so the user-configured
|
||||
window applies and may reach back over the whole GUI result deque.
|
||||
"""
|
||||
if self._supervisor.is_running():
|
||||
return int(self._bscan_cpp_replay_window)
|
||||
return max(1, int(self._bscan_history_window.value()))
|
||||
|
||||
def _sync_bscan_history_from_results(self) -> None:
|
||||
"""Rebuild B-scan history cache when live params or inputs changed."""
|
||||
signature = self._bscan_signature()
|
||||
if signature == self._bscan_render_signature:
|
||||
return
|
||||
self._rebuild_bscan_history_from_results()
|
||||
self._bscan_render_signature = signature
|
||||
|
||||
def _bscan_signature(self) -> tuple[object, ...]:
|
||||
"""Build state signature for B-scan history cache invalidation."""
|
||||
live_config = self._live_processing_config()
|
||||
# Must read the same source the rebuild will, or the cache decides nothing
|
||||
# changed while the image would in fact be built from different collections.
|
||||
result_history, _from_replay = self._bscan_source_collections()
|
||||
return build_bscan_signature(
|
||||
live_config=live_config,
|
||||
subtract_mean_ascan_enabled=bool(self._bscan_subtract_mean_ascan.isChecked()),
|
||||
result_history=result_history,
|
||||
history_limit=self._bscan_display_window_scans(),
|
||||
)
|
||||
|
||||
def _bscan_source_collections(self) -> tuple[list[ResultCollection], bool]:
|
||||
"""Return the collections the image is built from, and whether they are replayed.
|
||||
|
||||
A completed replay is the better source: it is exactly `window` long and every
|
||||
entry went through the processor with the same settings. The runtime history is
|
||||
not: it can also hold results of sweeps the GUI never received preprocessed,
|
||||
which were not re-fed and keep their old settings.
|
||||
"""
|
||||
replayed = getattr(self, "_bscan_replay_results", None)
|
||||
if replayed:
|
||||
return list(replayed), True
|
||||
return list(self._result_history), False
|
||||
|
||||
def _rebuild_bscan_history_from_results(self) -> None:
|
||||
"""Recompute B-scan history cache from results history buffer."""
|
||||
result_history, from_replay = self._bscan_source_collections()
|
||||
window = self._bscan_display_window_scans()
|
||||
stats: dict[str, object] = {}
|
||||
history_by_combo, depth_axis_by_combo = rebuild_bscan_history_from_results(
|
||||
result_history=result_history,
|
||||
history_limit=window,
|
||||
stats=stats,
|
||||
)
|
||||
self._bscan_history_by_combo = history_by_combo
|
||||
self._bscan_depth_axis_by_combo = depth_axis_by_combo
|
||||
self._log_bscan_window_shortfall(
|
||||
window=window,
|
||||
result_history_len=len(result_history),
|
||||
from_replay=from_replay,
|
||||
history_by_combo=history_by_combo,
|
||||
stats=stats,
|
||||
)
|
||||
|
||||
def _log_bscan_window_shortfall(
|
||||
self,
|
||||
*,
|
||||
window: int,
|
||||
result_history_len: int,
|
||||
from_replay: bool,
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]],
|
||||
stats: dict[str, object],
|
||||
) -> None:
|
||||
"""Explain at DEBUG why the image holds fewer columns than were requested.
|
||||
|
||||
Three independent causes produce the same symptom, so each is reported as its
|
||||
own number rather than a single verdict:
|
||||
* `tail` — the run simply produced fewer sweeps than were asked for;
|
||||
* `without_bscan` — frames processed in another mode carry no bscan payload;
|
||||
* `axis_resets` — a changed depth axis restarted the deque, dropping every
|
||||
column gathered before it (the usual cause after a partial replay);
|
||||
* per-combo counts — the image renders one combo at a time.
|
||||
"""
|
||||
rendered = max((len(history) for history in history_by_combo.values()), default=0)
|
||||
if rendered >= window:
|
||||
return
|
||||
|
||||
per_combo = ", ".join(
|
||||
f"in{input_pos}/out{output_pos}={len(history)}"
|
||||
for (input_pos, output_pos), history in sorted(history_by_combo.items())
|
||||
)
|
||||
self._log_debug(
|
||||
f"B-scan window not filled: rendered={rendered} of requested={window}. "
|
||||
f"source={'replay' if from_replay else 'result history'} len={result_history_len}, "
|
||||
f"newest-{window} tail={stats.get('tail')}, "
|
||||
f"of those without a bscan payload={stats.get('without_bscan')}, "
|
||||
f"depth-axis resets={stats.get('axis_resets')}. "
|
||||
f"Per combo: {per_combo or 'none'}."
|
||||
)
|
||||
|
||||
def _pick_bscan_display_key(self) -> tuple[int, int] | None:
|
||||
"""Choose combo history key to render."""
|
||||
requested_key = self._requested_bscan_display_key()
|
||||
display_key = pick_bscan_display_key(
|
||||
self._bscan_history_by_combo,
|
||||
preferred_key=requested_key,
|
||||
)
|
||||
available_keys = sorted(self._bscan_history_by_combo.keys())
|
||||
if requested_key is not None and requested_key != display_key and available_keys:
|
||||
combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys)
|
||||
details = "\n".join(
|
||||
f"- in{input_pos}/out{output_pos}"
|
||||
for input_pos, output_pos in available_keys
|
||||
)
|
||||
self._log_warning(
|
||||
f"B-scan display combo in{requested_key[0]}/out{requested_key[1]} is not available; "
|
||||
f"rendering in{display_key[0]}/out{display_key[1]} instead.",
|
||||
details=details,
|
||||
once_key=(
|
||||
f"bscan_requested_combo_missing_{requested_key[0]}_{requested_key[1]}_"
|
||||
f"{combo_signature}"
|
||||
),
|
||||
)
|
||||
return display_key
|
||||
if display_key is not None and len(available_keys) > 1:
|
||||
if requested_key == display_key:
|
||||
return display_key
|
||||
combo_signature = ",".join(f"{input_pos}:{output_pos}" for input_pos, output_pos in available_keys)
|
||||
details = "\n".join(
|
||||
f"- in{input_pos}/out{output_pos}"
|
||||
for input_pos, output_pos in available_keys
|
||||
)
|
||||
self._log(
|
||||
f"B-scan auto-selected combo in{display_key[0]}/out{display_key[1]} because multiple combos are available.",
|
||||
once_key=f"bscan_auto_display_{combo_signature}",
|
||||
)
|
||||
self._log_warning(
|
||||
"B-scan has multiple combo histories but the UI currently renders only one at a time.",
|
||||
details=details,
|
||||
once_key=f"bscan_multi_combo_warning_{combo_signature}",
|
||||
)
|
||||
return display_key
|
||||
|
||||
def _requested_bscan_display_key(self) -> tuple[int, int] | None:
|
||||
"""Return the matrix-radar B-scan display combo requested by the GUI."""
|
||||
if not (
|
||||
hasattr(self, "_is_matrix_radar_model_selected")
|
||||
and self._is_matrix_radar_model_selected()
|
||||
and hasattr(self, "_single_combo_input")
|
||||
and hasattr(self, "_single_combo_output")
|
||||
):
|
||||
return None
|
||||
|
||||
input_text = self._single_combo_input.text().strip()
|
||||
output_text = self._single_combo_output.text().strip()
|
||||
if not input_text or not output_text:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(input_text), int(output_text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _clear_bscan_plot_history(self) -> None:
|
||||
"""Drop cached B-scan history and invalidate cache signature."""
|
||||
self._bscan_history_by_combo.clear()
|
||||
self._bscan_depth_axis_by_combo.clear()
|
||||
self._bscan_render_signature = None
|
||||
@@ -1,243 +0,0 @@
|
||||
"""Re-feed retained sweeps to the processor so it recomputes the whole B-scan.
|
||||
|
||||
The processor keeps only ~50 preprocessed sweeps of its own (`kBscanReplayWindow` in
|
||||
`data_processor.cpp`), so changing a live B-scan setting used to refresh just the newest
|
||||
50 columns while everything older kept the parameters it was captured with — one image
|
||||
stitched from two parameter sets.
|
||||
|
||||
The GUI already holds up to 1000 preprocessed sweeps in `_pre_history`, byte-identical
|
||||
to what the processor consumes: `data_preprocessor` pushes the very same serialized
|
||||
buffer into both the working ring and the tap the GUI reads. So instead of making the
|
||||
processor hoard sweeps, we hand its own raw material back to it and let the ordinary
|
||||
`pop -> process -> publish` path recompute every column with the current settings.
|
||||
The re-fed sweeps keep their timestamps, by which the processor tells them from new
|
||||
ones and keeps them out of its own history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from python_app.gui.runtime.history import collection_key, record_result_history
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.orchestration.shm import ShmRingWriter
|
||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection
|
||||
|
||||
# The results ring overwrites unread slots, so a burst must never exceed what the GUI
|
||||
# drains between chunks. Comfortably below the (typically 50-slot) ring capacity.
|
||||
_REPLAY_CHUNK_SIZE = 12
|
||||
|
||||
# Per-chunk budget. Generous: the processor may be busy, and overshooting only costs
|
||||
# a redraw that reflects fewer columns.
|
||||
_REPLAY_CHUNK_TIMEOUT_S = 2.0
|
||||
|
||||
# Restarting the timer on every edit collapses a spin-box drag into one replay.
|
||||
_BSCAN_REPROCESS_DEBOUNCE_MS = 300
|
||||
|
||||
|
||||
class AppWindowBscanReplayMixin:
|
||||
"""Recomputes the full visible B-scan by re-feeding sweeps to the processor."""
|
||||
|
||||
def _reprocess_history_through_processor(self) -> bool:
|
||||
"""Re-feed the visible tail of `_pre_history` so every column is recomputed.
|
||||
|
||||
Returns True when a replay actually ran. Refuses (returning False) whenever the
|
||||
preconditions for safely writing into the processor's input ring do not hold.
|
||||
"""
|
||||
# `_pump_events_during_drain` runs the event loop, so a settings edit made while
|
||||
# a long replay is in flight can re-arm the debounce and fire this method inside
|
||||
# itself — two nested bursts sharing one ring and one result count.
|
||||
if getattr(self, "_bscan_replay_active", False):
|
||||
return False
|
||||
if not self._can_reprocess_history():
|
||||
return False
|
||||
|
||||
window = self._bscan_display_window_scans()
|
||||
tail = list(self._pre_history)[-window:]
|
||||
keys = [collection_key(collection) for collection in tail]
|
||||
# `_pre_history` and `_result_history` are fed by two different rings, each
|
||||
# dropping independently when the producer outruns the GUI. A low overlap means
|
||||
# many replayed results are of sweeps the result history never held.
|
||||
result_keys = {collection_key(collection) for collection in self._result_history}
|
||||
overlap = sum(1 for key in keys if key in result_keys)
|
||||
self._log_debug(
|
||||
f"B-scan replay starting: window={window}, preprocessed history="
|
||||
f"{len(self._pre_history)}, result history={len(self._result_history)}, "
|
||||
f"to re-send={len(tail)}, of those already in result history={overlap}."
|
||||
)
|
||||
if not tail:
|
||||
return False
|
||||
|
||||
writer = self._ensure_replay_ring_writer()
|
||||
if writer is None:
|
||||
return False
|
||||
|
||||
# Our own event pumping would otherwise let the poll timer fire and consume the
|
||||
# replayed results through `_read_all_results`, which records them to disk and
|
||||
# feeds the pipeline metrics. Restart in `finally`: losing the ring poll on an
|
||||
# exception would leave the GUI permanently blind.
|
||||
self._timer.stop()
|
||||
self._bscan_replay_active = True
|
||||
sent = 0
|
||||
replayed_keys = set(keys)
|
||||
replayed: dict[tuple[int, int], ResultCollection] = {}
|
||||
try:
|
||||
for start in range(0, len(tail), _REPLAY_CHUNK_SIZE):
|
||||
chunk = tail[start : start + _REPLAY_CHUNK_SIZE]
|
||||
pushed = 0
|
||||
for collection in chunk:
|
||||
payload = serialize_trace_collection(collection, PREPROC_MAGIC)
|
||||
if not writer.push(payload):
|
||||
# Only fails when the payload exceeds the slot size, which is a
|
||||
# config problem rather than a transient one: stop the burst.
|
||||
self._log_warning(
|
||||
"B-scan replay stopped: a preprocessed sweep does not fit the ring slot.",
|
||||
details=(
|
||||
f"payload={len(payload)} bytes, "
|
||||
f"slot={writer.slot_size_bytes} bytes"
|
||||
),
|
||||
once_key="bscan_replay_payload_too_large",
|
||||
)
|
||||
break
|
||||
pushed += 1
|
||||
sent += pushed
|
||||
if not self._collect_replayed_results(
|
||||
keys[start : start + pushed], replayed_keys, replayed, timeout_s=_REPLAY_CHUNK_TIMEOUT_S
|
||||
):
|
||||
# The processor did not answer in time; keep going, but say which
|
||||
# chunk so a systematic stall is visible.
|
||||
self._log_debug(f"B-scan replay chunk at offset {start} came back incomplete.")
|
||||
if pushed != len(chunk):
|
||||
break
|
||||
finally:
|
||||
self._bscan_replay_active = False
|
||||
self._timer.start()
|
||||
|
||||
# Render exactly the re-fed sweeps, in acquisition order, rather than the newest
|
||||
# `window` of `_result_history`: the two histories are fed by rings that drop
|
||||
# independently, so the latter can also hold results of sweeps the GUI never
|
||||
# received preprocessed, which were not re-fed and keep their old settings.
|
||||
self._bscan_replay_results = [replayed[key] for key in keys if key in replayed]
|
||||
received = sum(1 for key in keys[:sent] if key in replayed)
|
||||
|
||||
# Replayed collections keep their original ids and timestamps, so the render
|
||||
# signature is unchanged even though the payload values are not. Drop it or the
|
||||
# cache would decide nothing needs rebuilding.
|
||||
self._bscan_render_signature = None
|
||||
self._sync_bscan_history_from_results()
|
||||
self._draw_bscan_heatmap_from_history()
|
||||
|
||||
if received < sent:
|
||||
self._log_warning(
|
||||
f"B-scan replay recovered {received} of {sent} re-sent sweeps; "
|
||||
"some columns may still show their captured settings.",
|
||||
once_key=f"bscan_replay_incomplete_{sent}_{received}",
|
||||
)
|
||||
self._log_debug(f"B-scan replay finished: sent={sent}, recovered={received}.")
|
||||
return True
|
||||
|
||||
def _can_reprocess_history(self) -> bool:
|
||||
"""Return whether re-feeding the processor's input ring is safe right now."""
|
||||
if self._processing_mode.currentText() != "bscan":
|
||||
return False
|
||||
# `data_preprocessor` owns the preprocessed ring while acquisition runs; writing
|
||||
# into it concurrently would corrupt the sequence counters.
|
||||
if self._supervisor.is_running():
|
||||
return False
|
||||
if not self._supervisor.is_processor_running():
|
||||
return False
|
||||
return self._result_reader is not None
|
||||
|
||||
def _collect_replayed_results(
|
||||
self,
|
||||
waiting_for: list[tuple[int, int]],
|
||||
replayed_keys: set[tuple[int, int]],
|
||||
into: dict[tuple[int, int], ResultCollection],
|
||||
*,
|
||||
timeout_s: float,
|
||||
) -> bool:
|
||||
"""Pop results until those of the sweeps `waiting_for` are back; return whether they are.
|
||||
|
||||
Every result goes into the runtime history, deliberately not through
|
||||
`_read_all_results`: that path also feeds the pipeline metrics and the disk
|
||||
recorder, and a replay is neither new acquisition nor something that should be
|
||||
written to disk a second time. Results of re-fed sweeps (`replayed_keys`) also
|
||||
go into `into` by sweep, the latest winning, so whatever else the ring delivers
|
||||
meanwhile, and in whichever order, cannot get into the image or reorder it.
|
||||
"""
|
||||
if self._result_reader is None:
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while any(key not in into for key in waiting_for) and time.monotonic() < deadline:
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
self._pump_events_during_drain(0.005)
|
||||
continue
|
||||
record_result_history(self._result_history, collection)
|
||||
key = collection_key(collection)
|
||||
if key in replayed_keys:
|
||||
into[key] = collection
|
||||
return all(key in into for key in waiting_for)
|
||||
|
||||
def _ensure_replay_ring_writer(self) -> ShmRingWriter | None:
|
||||
"""Return a writer attached to the processor's input ring, creating it lazily.
|
||||
|
||||
Geometry comes from `_active_run_config` — the config the C++ side actually
|
||||
started with — never from the editable `_defaults_config`: `ShmRingWriter` owns
|
||||
the rings it opens and *recreates* a segment whose geometry disagrees, which
|
||||
would destroy the ring under a live processor.
|
||||
"""
|
||||
existing = getattr(self, "_replay_ring_writer", None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
config = getattr(self, "_active_run_config", None)
|
||||
if config is None:
|
||||
# Processor outlived the GUI that started it: its ring geometry is unknown,
|
||||
# and guessing risks recreating the segment underneath it.
|
||||
self._log_warning(
|
||||
"Cannot recompute the full B-scan: this GUI session did not start the "
|
||||
"pipeline, so the processor's ring geometry is unknown.",
|
||||
once_key="bscan_replay_no_active_run_config",
|
||||
)
|
||||
return None
|
||||
|
||||
ring = config.rings.preprocessed
|
||||
try:
|
||||
self._replay_ring_writer = ShmRingWriter(
|
||||
ring.name, int(ring.capacity), int(ring.slot_size_bytes)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to open the B-scan replay ring writer", exc, level="WARN")
|
||||
self._replay_ring_writer = None
|
||||
return self._replay_ring_writer
|
||||
|
||||
def _discard_bscan_replay_results(self) -> None:
|
||||
"""Fall back to the runtime history as the render source.
|
||||
|
||||
Called whenever the replayed set stops describing what should be on screen:
|
||||
acquisition resuming (live frames must win) or the history being edited.
|
||||
"""
|
||||
if not getattr(self, "_bscan_replay_results", None):
|
||||
return
|
||||
self._bscan_replay_results = []
|
||||
self._bscan_render_signature = None
|
||||
|
||||
def _close_replay_ring_writer(self) -> None:
|
||||
"""Detach from the processor's input ring (safe to call repeatedly)."""
|
||||
writer = getattr(self, "_replay_ring_writer", None)
|
||||
if writer is None:
|
||||
return
|
||||
try:
|
||||
writer.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to close the B-scan replay ring writer", exc, level="WARN")
|
||||
finally:
|
||||
self._replay_ring_writer = None
|
||||
|
||||
def _schedule_bscan_history_reprocess(self) -> None:
|
||||
"""Debounce a full recompute so dragging a spin box does not send hundreds of sweeps."""
|
||||
if not self._can_reprocess_history():
|
||||
return
|
||||
self._bscan_reprocess_timer.start(_BSCAN_REPROCESS_DEBOUNCE_MS)
|
||||
@@ -1,576 +0,0 @@
|
||||
"""GPR heatmap and object-only rendering helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QRectF
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import build_lut
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.orchestration.gpr_locator import (
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class AppWindowGprPlotMixin:
|
||||
"""Renders GPR accumulator heatmaps and detected object overlays."""
|
||||
|
||||
def _gpr_plot_title(self, *, objects_only: bool = False) -> str:
|
||||
"""Return title for the active GPR-like processing mode."""
|
||||
if self._processing_mode.currentText() == "legacy_gpr":
|
||||
title = f"Legacy GPR {self._legacy_gpr_config_mode.currentText()}"
|
||||
else:
|
||||
title = "GPR coherent BP"
|
||||
return f"{title} Objects Only" if objects_only else title
|
||||
|
||||
def _clear_gpr_plot(self) -> None:
|
||||
"""Clear latest GPR plot surface."""
|
||||
if not hasattr(self, "_gpr_plot"):
|
||||
return
|
||||
self._configure_gpr_plot_axes()
|
||||
self._clear_gpr_point_labels()
|
||||
self._clear_gpr_region_labels()
|
||||
self._clear_gpr_region_masks()
|
||||
if self._gpr_image_item is not None:
|
||||
self._gpr_image_item.hide()
|
||||
if self._gpr_tx_item is not None:
|
||||
self._gpr_tx_item.setData(x=[], y=[])
|
||||
self._gpr_tx_item.hide()
|
||||
if self._gpr_rx_item is not None:
|
||||
self._gpr_rx_item.setData(x=[], y=[])
|
||||
self._gpr_rx_item.hide()
|
||||
if self._gpr_points_item is not None:
|
||||
self._gpr_points_item.setData(x=[], y=[])
|
||||
self._gpr_points_item.hide()
|
||||
if self._gpr_region_centers_item is not None:
|
||||
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||
self._gpr_region_centers_item.hide()
|
||||
self._gpr_plot.setTitle(self._gpr_plot_title())
|
||||
|
||||
def _configure_gpr_plot_axes(self) -> None:
|
||||
"""Apply persistent GPR plot axis labels and base view settings."""
|
||||
plot = self._gpr_plot
|
||||
plot_item = plot.getPlotItem()
|
||||
plot_item.showAxis("left", show=True)
|
||||
plot_item.showAxis("bottom", show=True)
|
||||
plot_item.setClipToView(True)
|
||||
plot.setLabel("bottom", "X", units="m")
|
||||
plot.setLabel("left", "Range", units="m")
|
||||
view_box = plot.getViewBox()
|
||||
view_box.invertY(False)
|
||||
view_box.enableAutoRange(x=False, y=False)
|
||||
self._disable_gpr_plot_interaction(plot, view_box)
|
||||
|
||||
@staticmethod
|
||||
def _disable_gpr_plot_interaction(plot: pg.PlotWidget, view_box: pg.ViewBox) -> None:
|
||||
"""Disable mouse-driven GPR pan/zoom; ranges are controlled by widgets."""
|
||||
AppWindowGprPlotMixin._call_if_present(plot, "setMouseEnabled", x=False, y=False)
|
||||
AppWindowGprPlotMixin._call_if_present(plot, "setMenuEnabled", False)
|
||||
AppWindowGprPlotMixin._call_if_present(plot.getPlotItem(), "hideButtons")
|
||||
AppWindowGprPlotMixin._call_if_present(view_box, "setMouseEnabled", x=False, y=False)
|
||||
AppWindowGprPlotMixin._call_if_present(view_box, "setMenuEnabled", False)
|
||||
for axis_name in ("bottom", "left"):
|
||||
AppWindowGprPlotMixin._call_if_present(
|
||||
plot.getPlotItem().getAxis(axis_name),
|
||||
"setMouseEnabled",
|
||||
False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _call_if_present(obj: object, method_name: str, *args, **kwargs) -> None:
|
||||
"""Call optional pyqtgraph API when available in the installed version."""
|
||||
method = getattr(obj, method_name, None)
|
||||
if method is not None:
|
||||
method(*args, **kwargs)
|
||||
|
||||
def _ensure_gpr_plot_items(self) -> None:
|
||||
"""Create persistent GPR plot items once and reuse them on redraw."""
|
||||
if self._gpr_image_item is not None:
|
||||
return
|
||||
|
||||
plot = self._gpr_plot
|
||||
self._configure_gpr_plot_axes()
|
||||
|
||||
if self._gpr_lookup_table is None:
|
||||
self._gpr_lookup_table = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
|
||||
|
||||
self._gpr_image_item = pg.ImageItem(axisOrder="row-major")
|
||||
self._gpr_image_item.setZValue(0)
|
||||
self._gpr_image_item.hide()
|
||||
plot.addItem(self._gpr_image_item)
|
||||
|
||||
self._gpr_tx_item = pg.ScatterPlotItem()
|
||||
self._gpr_tx_item.setZValue(20)
|
||||
self._gpr_tx_item.hide()
|
||||
plot.addItem(self._gpr_tx_item)
|
||||
|
||||
self._gpr_rx_item = pg.ScatterPlotItem()
|
||||
self._gpr_rx_item.setZValue(20)
|
||||
self._gpr_rx_item.hide()
|
||||
plot.addItem(self._gpr_rx_item)
|
||||
|
||||
self._gpr_points_item = pg.ScatterPlotItem()
|
||||
self._gpr_points_item.setZValue(30)
|
||||
self._gpr_points_item.hide()
|
||||
plot.addItem(self._gpr_points_item)
|
||||
|
||||
self._gpr_region_centers_item = pg.ScatterPlotItem()
|
||||
self._gpr_region_centers_item.setZValue(30)
|
||||
self._gpr_region_centers_item.hide()
|
||||
plot.addItem(self._gpr_region_centers_item)
|
||||
|
||||
def _clear_gpr_point_labels(self) -> None:
|
||||
"""Remove dynamic point-score labels from GPR plot."""
|
||||
for item in self._gpr_point_labels:
|
||||
try:
|
||||
self._gpr_plot.removeItem(item)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
f"Failed to remove GPR point label: {type(exc).__name__}: {exc}",
|
||||
once_key="gpr_remove_point_label_failed",
|
||||
)
|
||||
self._gpr_point_labels.clear()
|
||||
|
||||
def _clear_gpr_region_labels(self) -> None:
|
||||
"""Remove dynamic region labels from GPR plot."""
|
||||
for item in self._gpr_region_center_labels:
|
||||
try:
|
||||
self._gpr_plot.removeItem(item)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
f"Failed to remove GPR region label: {type(exc).__name__}: {exc}",
|
||||
once_key="gpr_remove_region_label_failed",
|
||||
)
|
||||
self._gpr_region_center_labels.clear()
|
||||
|
||||
def _clear_gpr_region_masks(self) -> None:
|
||||
"""Remove dynamic region contour carriers from GPR plot."""
|
||||
for item in self._gpr_region_mask_items:
|
||||
try:
|
||||
self._gpr_plot.removeItem(item)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
f"Failed to remove GPR region mask: {type(exc).__name__}: {exc}",
|
||||
once_key="gpr_remove_region_mask_failed",
|
||||
)
|
||||
self._gpr_region_mask_items.clear()
|
||||
self._gpr_region_contours.clear()
|
||||
|
||||
@staticmethod
|
||||
def _collection_payload_by_name(collection: ResultCollection, name: str, kind: int | None = None):
|
||||
"""Return first collection payload matching name and optional kind."""
|
||||
return gpr_collection_payload_by_name(collection, name, kind)
|
||||
|
||||
@staticmethod
|
||||
def _collection_payloads_by_prefix(collection: ResultCollection, prefix: str, kind: int | None = None):
|
||||
"""Return collection payloads matching processing-name prefix."""
|
||||
return gpr_collection_payloads_by_prefix(collection, prefix, kind)
|
||||
|
||||
def _selected_gpr_geometry(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Resolve selected Tx/Rx geometry arrays for current GPR selection."""
|
||||
if self._processing_mode.currentText() == "legacy_gpr":
|
||||
input_positions_text = self._legacy_gpr_input_positions_input.text()
|
||||
output_positions_text = self._legacy_gpr_output_positions_input.text()
|
||||
else:
|
||||
input_positions_text = self._gpr_input_positions_input.text()
|
||||
output_positions_text = self._gpr_output_positions_input.text()
|
||||
requested_inputs = tuple(self._parse_csv_int_list(input_positions_text))
|
||||
requested_outputs = tuple(self._parse_csv_int_list(output_positions_text))
|
||||
tx_entries = self._gpr_tx_geometry_input.records()
|
||||
rx_entries = self._gpr_rx_geometry_input.records()
|
||||
signature = (
|
||||
tuple(tuple(entry.values()) for entry in tx_entries),
|
||||
tuple(tuple(entry.values()) for entry in rx_entries),
|
||||
requested_inputs,
|
||||
requested_outputs,
|
||||
)
|
||||
if signature == self._gpr_geometry_signature and self._gpr_selected_geometry is not None:
|
||||
return self._gpr_selected_geometry
|
||||
|
||||
requested_input_set = set(requested_inputs)
|
||||
requested_output_set = set(requested_outputs)
|
||||
|
||||
rx_entries = sorted(rx_entries, key=lambda entry: entry["input_pos"])
|
||||
tx_entries = sorted(tx_entries, key=lambda entry: entry["output_pos"])
|
||||
if requested_input_set:
|
||||
rx_entries = [entry for entry in rx_entries if entry["input_pos"] in requested_input_set]
|
||||
if requested_output_set:
|
||||
tx_entries = [entry for entry in tx_entries if entry["output_pos"] in requested_output_set]
|
||||
|
||||
x_tx = np.asarray([entry["x_m"] for entry in tx_entries], dtype=np.float32)
|
||||
x_rx = np.asarray([entry["x_m"] for entry in rx_entries], dtype=np.float32)
|
||||
self._gpr_geometry_signature = signature
|
||||
self._gpr_selected_geometry = (x_tx, x_rx)
|
||||
return self._gpr_selected_geometry
|
||||
|
||||
def _draw_gpr_map(self, collection: ResultCollection) -> bool:
|
||||
"""Draw latest collection-level GPR plot according to current render mode."""
|
||||
if self._gpr_render_mode_text() == "objects_only":
|
||||
return self._draw_gpr_objects_only(collection)
|
||||
return self._draw_gpr_heatmap(collection)
|
||||
|
||||
def _draw_gpr_heatmap(self, collection: ResultCollection) -> bool:
|
||||
"""Draw latest collection-level GPR accumulator and annotations."""
|
||||
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
|
||||
if accumulator_payload is None:
|
||||
self._clear_gpr_plot()
|
||||
return False
|
||||
|
||||
image = np.asarray(accumulator_payload.image, dtype=np.float32)
|
||||
x_axis = np.asarray(accumulator_payload.image_x_axis, dtype=np.float32)
|
||||
y_axis = np.asarray(accumulator_payload.image_y_axis, dtype=np.float32)
|
||||
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
|
||||
self._clear_gpr_plot()
|
||||
return False
|
||||
|
||||
x_min = float(x_axis[0])
|
||||
x_max = float(x_axis[-1])
|
||||
y_min = float(y_axis[0])
|
||||
y_max = float(y_axis[-1])
|
||||
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
|
||||
|
||||
plot = self._gpr_plot
|
||||
plot.setUpdatesEnabled(False)
|
||||
try:
|
||||
self._ensure_gpr_plot_items()
|
||||
plot.getViewBox().invertY(False)
|
||||
self._clear_gpr_point_labels()
|
||||
self._clear_gpr_region_labels()
|
||||
self._clear_gpr_region_masks()
|
||||
|
||||
self._gpr_image_item.setImage(image, autoLevels=False)
|
||||
self._gpr_image_item.setRect(rect)
|
||||
self._gpr_image_item.setLookupTable(self._gpr_lookup_table)
|
||||
self._gpr_image_item.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
|
||||
self._gpr_image_item.show()
|
||||
|
||||
visible_x_min, visible_x_max, visible_z_min, visible_z_max = self._gpr_visible_bounds()
|
||||
plot.setXRange(visible_x_min, visible_x_max, padding=0.0)
|
||||
plot.setYRange(self._gpr_display_y_min(visible_z_min, visible_z_max), visible_z_max, padding=0.0)
|
||||
|
||||
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)
|
||||
else:
|
||||
points = np.zeros((0, 3), dtype=np.float32)
|
||||
|
||||
if points.size > 0:
|
||||
self._gpr_points_item.setData(
|
||||
x=points[:, 0],
|
||||
y=points[:, 1],
|
||||
symbol="d",
|
||||
size=11,
|
||||
brush=pg.mkBrush("#ffffff"),
|
||||
pen=pg.mkPen("#111111", width=1.1),
|
||||
)
|
||||
self._gpr_points_item.show()
|
||||
for x_value, y_value, score in points:
|
||||
label_text = f"{float(score):.0f}" if self._processing_mode.currentText() == "legacy_gpr" else f"{float(score):.2f}"
|
||||
label = pg.TextItem(text=label_text, color="#ffffff", anchor=(0.0, 1.0))
|
||||
label.setZValue(40)
|
||||
label.setPos(float(x_value), float(y_value))
|
||||
plot.addItem(label)
|
||||
self._gpr_point_labels.append(label)
|
||||
else:
|
||||
self._gpr_points_item.setData(x=[], y=[])
|
||||
self._gpr_points_item.hide()
|
||||
|
||||
region_centers_payload = self._collection_payload_by_name(collection, "gpr_region_centers", kind=4)
|
||||
if region_centers_payload is not None and np.asarray(region_centers_payload.table).size > 0:
|
||||
centers = np.asarray(region_centers_payload.table, dtype=np.float32)
|
||||
self._gpr_region_centers_item.setData(
|
||||
x=centers[:, 0],
|
||||
y=centers[:, 1],
|
||||
symbol="o",
|
||||
size=10,
|
||||
brush=pg.mkBrush("#80ed99"),
|
||||
pen=pg.mkPen("#081c15", width=1.1),
|
||||
)
|
||||
self._gpr_region_centers_item.show()
|
||||
for row in centers:
|
||||
label = pg.TextItem(text=f"{float(row[2]):.0f}", color="#d8f3dc", anchor=(0.0, 1.0))
|
||||
label.setZValue(40)
|
||||
label.setPos(float(row[0]), float(row[1]))
|
||||
plot.addItem(label)
|
||||
self._gpr_region_center_labels.append(label)
|
||||
else:
|
||||
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||
self._gpr_region_centers_item.hide()
|
||||
|
||||
for payload in self._collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
|
||||
mask = np.asarray(payload.image, dtype=np.float32)
|
||||
if mask.ndim != 2 or mask.size == 0:
|
||||
continue
|
||||
mask_image = pg.ImageItem(axisOrder="row-major")
|
||||
mask_image.setZValue(5)
|
||||
mask_image.setImage(mask, autoLevels=False)
|
||||
mask_image.setRect(rect)
|
||||
mask_image.setOpacity(0.0)
|
||||
plot.addItem(mask_image)
|
||||
contour = pg.IsocurveItem(data=mask, level=0.5, pen=pg.mkPen("#4cc9f0", width=1.3))
|
||||
contour.setParentItem(mask_image)
|
||||
self._gpr_region_mask_items.append(mask_image)
|
||||
self._gpr_region_contours.append(contour)
|
||||
|
||||
plot.setTitle(self._gpr_plot_title())
|
||||
finally:
|
||||
plot.setUpdatesEnabled(True)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
|
||||
"""Return ordered display bounds with a non-zero span."""
|
||||
lower = min(float(start), float(stop))
|
||||
upper = max(float(start), float(stop))
|
||||
if upper - lower >= minimum_span:
|
||||
return lower, upper
|
||||
center = 0.5 * (lower + upper)
|
||||
half_span = 0.5 * minimum_span
|
||||
return center - half_span, center + half_span
|
||||
|
||||
def _gpr_visible_bounds(self) -> tuple[float, float, float, float]:
|
||||
"""Return normalized GPR visible X/Z bounds from GUI controls."""
|
||||
if self._processing_mode.currentText() == "legacy_gpr":
|
||||
x_min_widget = self._legacy_gpr_visible_x_min_m
|
||||
x_max_widget = self._legacy_gpr_visible_x_max_m
|
||||
z_min_widget = self._legacy_gpr_visible_z_min_m
|
||||
z_max_widget = self._legacy_gpr_visible_z_max_m
|
||||
else:
|
||||
x_min_widget = self._gpr_visible_x_min_m
|
||||
x_max_widget = self._gpr_visible_x_max_m
|
||||
z_min_widget = self._gpr_visible_z_min_m
|
||||
z_max_widget = self._gpr_visible_z_max_m
|
||||
x_min, x_max = self._normalized_display_range(
|
||||
float(x_min_widget.value()),
|
||||
float(x_max_widget.value()),
|
||||
minimum_span=0.1,
|
||||
)
|
||||
z_min, z_max = self._normalized_display_range(
|
||||
float(z_min_widget.value()),
|
||||
float(z_max_widget.value()),
|
||||
minimum_span=0.1,
|
||||
)
|
||||
return x_min, x_max, z_min, z_max
|
||||
|
||||
def _gpr_visible_object_bounds(self) -> tuple[float, float, float, float]:
|
||||
"""Return normalized object/locator visible X/Z bounds from GUI controls."""
|
||||
return self._gpr_visible_bounds()
|
||||
|
||||
def _gpr_render_mode_text(self) -> str:
|
||||
"""Return render mode for the active GPR-like processing mode."""
|
||||
if self._processing_mode.currentText() == "legacy_gpr":
|
||||
return self._legacy_gpr_render_mode.currentText()
|
||||
return self._gpr_render_mode.currentText()
|
||||
|
||||
@staticmethod
|
||||
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
|
||||
"""Return lower display bound, preserving surface markers only when surface is visible."""
|
||||
lower = float(z_min)
|
||||
if lower > 0.0:
|
||||
return lower
|
||||
span = max(float(z_max) - lower, 1e-6)
|
||||
marker_margin = max(span * 0.03, 0.06)
|
||||
return lower - marker_margin
|
||||
|
||||
def _draw_gpr_geometry_markers(self) -> None:
|
||||
"""Render selected Tx/Rx geometry markers on current GPR plot."""
|
||||
x_tx, x_rx = self._selected_gpr_geometry()
|
||||
if x_tx.size > 0:
|
||||
self._gpr_tx_item.setData(
|
||||
x=x_tx,
|
||||
y=np.zeros_like(x_tx),
|
||||
symbol="t",
|
||||
size=13,
|
||||
brush=pg.mkBrush("#ff595e"),
|
||||
pen=pg.mkPen("#ffca3a", width=1.0),
|
||||
)
|
||||
self._gpr_tx_item.show()
|
||||
else:
|
||||
self._gpr_tx_item.setData(x=[], y=[])
|
||||
self._gpr_tx_item.hide()
|
||||
|
||||
if x_rx.size > 0:
|
||||
self._gpr_rx_item.setData(
|
||||
x=x_rx,
|
||||
y=np.zeros_like(x_rx),
|
||||
symbol="t1",
|
||||
size=13,
|
||||
brush=pg.mkBrush("#4cc9f0"),
|
||||
pen=pg.mkPen("#e0fbfc", width=1.0),
|
||||
)
|
||||
self._gpr_rx_item.show()
|
||||
else:
|
||||
self._gpr_rx_item.setData(x=[], y=[])
|
||||
self._gpr_rx_item.hide()
|
||||
|
||||
def _format_gpr_object_label(self, x_m: float, z_m: float, score: float) -> str:
|
||||
"""Format object-only annotation text with normalized BP score and coordinates."""
|
||||
if self._processing_mode.currentText() == "legacy_gpr":
|
||||
return f"{int(round(score))} | x={x_m:.1f} | z={z_m:.1f}"
|
||||
return f"{score:.2f} | x={x_m:.1f} | z={z_m:.1f}"
|
||||
|
||||
@staticmethod
|
||||
def _expanded_scene_rect(rect: QRectF, *, padding_px: float = 4.0) -> QRectF:
|
||||
"""Return scene rect padded to keep labels visually separated."""
|
||||
return rect.adjusted(-padding_px, -padding_px, padding_px, padding_px)
|
||||
|
||||
@staticmethod
|
||||
def _scene_rect_intersects_any(rect: QRectF, occupied_rects: list[QRectF]) -> bool:
|
||||
"""Return whether candidate label rect intersects any already placed label."""
|
||||
return any(rect.intersects(occupied_rect) for occupied_rect in occupied_rects)
|
||||
|
||||
@staticmethod
|
||||
def _gpr_object_label_candidates(
|
||||
x_m: float,
|
||||
z_m: float,
|
||||
*,
|
||||
x_span: float,
|
||||
z_span: float,
|
||||
) -> list[tuple[float, float, tuple[float, float]]]:
|
||||
"""Return candidate label placements around one object."""
|
||||
x_offset = max(x_span * 0.015, 0.02)
|
||||
z_offset = max(z_span * 0.02, 0.02)
|
||||
return [
|
||||
(x_m + x_offset, z_m - z_offset, (0.0, 1.0)),
|
||||
(x_m + x_offset, z_m + z_offset, (0.0, 0.0)),
|
||||
(x_m - x_offset, z_m - z_offset, (1.0, 1.0)),
|
||||
(x_m - x_offset, z_m + z_offset, (1.0, 0.0)),
|
||||
(x_m, z_m - (z_offset * 1.35), (0.5, 1.0)),
|
||||
(x_m, z_m + (z_offset * 1.35), (0.5, 0.0)),
|
||||
(x_m + (x_offset * 2.2), z_m - (z_offset * 1.5), (0.0, 1.0)),
|
||||
(x_m + (x_offset * 2.2), z_m + (z_offset * 1.5), (0.0, 0.0)),
|
||||
(x_m - (x_offset * 2.2), z_m - (z_offset * 1.5), (1.0, 1.0)),
|
||||
(x_m - (x_offset * 2.2), z_m + (z_offset * 1.5), (1.0, 0.0)),
|
||||
]
|
||||
|
||||
def _place_gpr_object_label(
|
||||
self,
|
||||
*,
|
||||
label: pg.TextItem,
|
||||
x_m: float,
|
||||
z_m: float,
|
||||
x_span: float,
|
||||
z_span: float,
|
||||
occupied_scene_rects: list[QRectF],
|
||||
) -> None:
|
||||
"""Place one object label using the first non-overlapping candidate position."""
|
||||
last_rect: QRectF | None = None
|
||||
for label_x, label_z, anchor in self._gpr_object_label_candidates(
|
||||
x_m,
|
||||
z_m,
|
||||
x_span=x_span,
|
||||
z_span=z_span,
|
||||
):
|
||||
label.setAnchor(anchor)
|
||||
label.setPos(label_x, label_z)
|
||||
candidate_rect = self._expanded_scene_rect(label.sceneBoundingRect())
|
||||
last_rect = candidate_rect
|
||||
if not self._scene_rect_intersects_any(candidate_rect, occupied_scene_rects):
|
||||
occupied_scene_rects.append(candidate_rect)
|
||||
return
|
||||
|
||||
if last_rect is not None:
|
||||
occupied_scene_rects.append(last_rect)
|
||||
|
||||
def _gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
||||
"""Return object rows as `[x_m, z_m, score]` from current GPR result payload."""
|
||||
return extract_gpr_object_rows(collection)
|
||||
|
||||
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
|
||||
"""Return the object rows to draw for the active GPR mode.
|
||||
|
||||
Coherent BP is already finalized by the processor (visible window + N/M draw
|
||||
limits, no score threshold — exactly Horns_motion_3libre.py), so its rows are
|
||||
drawn verbatim. Legacy GPR is still filtered here by its pair-count threshold
|
||||
and the visible window.
|
||||
"""
|
||||
rows = self._gpr_object_rows(collection)
|
||||
if rows.size == 0 or self._processing_mode.currentText() != "legacy_gpr":
|
||||
return rows
|
||||
|
||||
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||
return gpr_filter_object_rows(
|
||||
rows,
|
||||
min_score=float(self._legacy_gpr_min_visible_pair_count.value()),
|
||||
x_bounds=(x_min, x_max),
|
||||
z_bounds=(z_min, z_max),
|
||||
draw_limits=None,
|
||||
)
|
||||
|
||||
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
|
||||
"""Draw only detected GPR objects inside configured X/Z bounds."""
|
||||
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
|
||||
object_rows = self._filtered_gpr_object_rows(collection)
|
||||
if accumulator_payload is None and object_rows.size == 0:
|
||||
self._clear_gpr_plot()
|
||||
return False
|
||||
|
||||
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
|
||||
plot = self._gpr_plot
|
||||
plot.setUpdatesEnabled(False)
|
||||
try:
|
||||
self._ensure_gpr_plot_items()
|
||||
plot.getViewBox().invertY(False)
|
||||
self._clear_gpr_point_labels()
|
||||
self._clear_gpr_region_labels()
|
||||
self._clear_gpr_region_masks()
|
||||
|
||||
self._gpr_image_item.hide()
|
||||
self._draw_gpr_geometry_markers()
|
||||
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||
self._gpr_region_centers_item.hide()
|
||||
|
||||
if object_rows.size > 0:
|
||||
self._gpr_points_item.setData(
|
||||
x=object_rows[:, 0],
|
||||
y=object_rows[:, 1],
|
||||
symbol="o",
|
||||
size=18,
|
||||
brush=pg.mkBrush("#ff4d4f"),
|
||||
pen=pg.mkPen("#ff4d4f", width=1.6),
|
||||
)
|
||||
self._gpr_points_item.show()
|
||||
|
||||
occupied_scene_rects: list[QRectF] = []
|
||||
x_span = x_max - x_min
|
||||
z_span = z_max - z_min
|
||||
for x_value, z_value, score in object_rows:
|
||||
label = pg.TextItem(
|
||||
text=self._format_gpr_object_label(
|
||||
float(x_value),
|
||||
float(z_value),
|
||||
float(score),
|
||||
),
|
||||
color="#ffd6d9",
|
||||
anchor=(0.0, 1.0),
|
||||
)
|
||||
label.setZValue(40)
|
||||
plot.addItem(label)
|
||||
self._place_gpr_object_label(
|
||||
label=label,
|
||||
x_m=float(x_value),
|
||||
z_m=float(z_value),
|
||||
x_span=x_span,
|
||||
z_span=z_span,
|
||||
occupied_scene_rects=occupied_scene_rects,
|
||||
)
|
||||
self._gpr_point_labels.append(label)
|
||||
else:
|
||||
self._gpr_points_item.setData(x=[], y=[])
|
||||
self._gpr_points_item.hide()
|
||||
|
||||
plot.setXRange(x_min, x_max, padding=0.0)
|
||||
plot.setYRange(self._gpr_display_y_min(z_min, z_max), z_max, padding=0.0)
|
||||
plot.setTitle(self._gpr_plot_title(objects_only=True))
|
||||
finally:
|
||||
plot.setUpdatesEnabled(True)
|
||||
return True
|
||||
@@ -1,458 +0,0 @@
|
||||
"""Trace plot rendering helpers for pass-through and single-trace views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection, TraceData
|
||||
from python_app.models.run_config_model import parse_combos_from_text
|
||||
|
||||
|
||||
class AppWindowTracePlotMixin:
|
||||
"""Renders pass-through trace plots on stacked magnitude/phase widgets."""
|
||||
|
||||
def _show_magnitude_curves(self) -> bool:
|
||||
"""Return whether magnitude curves should be rendered."""
|
||||
return self._show_magnitude_checkbox.isChecked()
|
||||
|
||||
def _show_phase_curves(self) -> bool:
|
||||
"""Return whether phase curves should be rendered."""
|
||||
return self._show_phase_checkbox.isChecked()
|
||||
|
||||
def _unwrap_phase_enabled(self) -> bool:
|
||||
"""Return whether the phase trace should be unwrapped (cumulative)."""
|
||||
return self._unwrap_phase_checkbox.isChecked()
|
||||
|
||||
def _pass_through_fixed_y_range(self) -> tuple[bool, float, float]:
|
||||
"""Return normalized magnitude Y-range override for pass-through mode."""
|
||||
y_min = float(self._pass_through_y_min_db.value())
|
||||
y_max = float(self._pass_through_y_max_db.value())
|
||||
return bool(self._pass_through_fixed_y_enabled.isChecked()), min(y_min, y_max), max(y_min, y_max)
|
||||
|
||||
def _pass_through_combo_filter(self) -> set[tuple[int, int]] | None:
|
||||
"""Return selected pass-through switch combos, or `None` when all are visible."""
|
||||
text = self._pass_through_combo_filter_input.text().strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return {
|
||||
(int(combo.input), int(combo.output))
|
||||
for combo in parse_combos_from_text(text)
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
"Invalid pass-through switch-combo filter.",
|
||||
details=f"{exc}\nExpected format: input:output,input:output",
|
||||
once_key=f"pass_through_combo_filter_invalid_{text}",
|
||||
)
|
||||
# Fail open: an unparseable filter is ignored (all traces stay visible),
|
||||
# not turned into an empty allow-set that silently hides every trace.
|
||||
return None
|
||||
|
||||
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
|
||||
"""Apply the magnitude Y-axis: a fixed window or autorange.
|
||||
|
||||
The X axis is set explicitly from the data band by the caller, so this
|
||||
never re-enables X autorange (which would scan all curves every frame).
|
||||
"""
|
||||
fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range()
|
||||
view_box = plot.getViewBox()
|
||||
view_box.invertY(False)
|
||||
if fixed_y_enabled:
|
||||
view_box.enableAutoRange(y=False)
|
||||
plot.setYRange(y_min, y_max, padding=0.0)
|
||||
else:
|
||||
view_box.enableAutoRange(y=True)
|
||||
|
||||
def _configure_pass_through_phase_axis(self, plot: pg.PlotWidget) -> None:
|
||||
"""Apply the phase Y-axis: fixed ±180° when wrapped, autorange when unwrapped."""
|
||||
view_box = plot.getViewBox()
|
||||
view_box.invertY(False)
|
||||
if self._unwrap_phase_enabled():
|
||||
view_box.enableAutoRange(y=True)
|
||||
else:
|
||||
view_box.enableAutoRange(y=False)
|
||||
plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||
|
||||
def _on_trace_visibility_changed(self, *_args) -> None:
|
||||
"""Redraw pass-through traces when magnitude/phase toggles changed."""
|
||||
if self._processing_mode.currentText() in {"bscan", "gpr", "legacy_gpr", "sar3d"}:
|
||||
return
|
||||
if self._result_history:
|
||||
self._draw_results(self._result_history[-1])
|
||||
return
|
||||
self._clear_trace_plots()
|
||||
|
||||
def _clear_trace_plots(self) -> None:
|
||||
"""Clear pass-through magnitude and phase plots."""
|
||||
self._trace_magnitude_plot.clear()
|
||||
self._trace_phase_plot.clear()
|
||||
self._clear_trace_legends()
|
||||
self._trace_magnitude_curves.clear()
|
||||
self._trace_phase_curves.clear()
|
||||
|
||||
def _clear_trace_legends(self) -> None:
|
||||
"""Remove trace plot legends to avoid stale combo-color mappings."""
|
||||
mag_legend = self._trace_magnitude_legend
|
||||
if mag_legend is not None:
|
||||
try:
|
||||
self._trace_magnitude_plot.getPlotItem().removeItem(mag_legend)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
f"Failed to remove pass-through magnitude legend: {type(exc).__name__}: {exc}",
|
||||
once_key="plot_remove_magnitude_legend_failed",
|
||||
)
|
||||
self._trace_magnitude_legend = None
|
||||
self._trace_magnitude_legend_combo_keys.clear()
|
||||
|
||||
phase_legend = self._trace_phase_legend
|
||||
if phase_legend is not None:
|
||||
try:
|
||||
self._trace_phase_plot.getPlotItem().removeItem(phase_legend)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
f"Failed to remove pass-through phase legend: {type(exc).__name__}: {exc}",
|
||||
once_key="plot_remove_phase_legend_failed",
|
||||
)
|
||||
self._trace_phase_legend = None
|
||||
self._trace_phase_legend_combo_keys.clear()
|
||||
|
||||
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
|
||||
"""Draw result payload traces as stacked magnitude/phase plots."""
|
||||
show_magnitude = self._show_magnitude_curves()
|
||||
show_phase = self._show_phase_curves()
|
||||
magnitude_plot = self._trace_magnitude_plot
|
||||
phase_plot = self._trace_phase_plot
|
||||
pass_through_channel = "S21"
|
||||
|
||||
magnitude_plot.setVisible(show_magnitude)
|
||||
phase_plot.setVisible(show_phase)
|
||||
if not show_magnitude and not show_phase:
|
||||
self._clear_trace_plots()
|
||||
return False
|
||||
combo_filter = self._pass_through_combo_filter()
|
||||
|
||||
if show_magnitude:
|
||||
mag_item = magnitude_plot.getPlotItem()
|
||||
mag_item.showAxis("left", show=True)
|
||||
mag_item.showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
magnitude_plot.setTitle(f"Pass-Through {pass_through_channel}")
|
||||
if not show_phase:
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
|
||||
if show_phase:
|
||||
phase_item = phase_plot.getPlotItem()
|
||||
phase_item.showAxis("left", show=True)
|
||||
phase_item.showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
phase_plot.setTitle(f"Pass-Through {pass_through_channel}")
|
||||
|
||||
palette = [
|
||||
"#4cc9f0",
|
||||
"#f72585",
|
||||
"#b8f2e6",
|
||||
"#ffd166",
|
||||
"#90be6d",
|
||||
"#ff595e",
|
||||
"#6a4c93",
|
||||
"#1982c4",
|
||||
]
|
||||
|
||||
combo_colors: dict[tuple[int, int], str] = {}
|
||||
legend_source_magnitude: dict[tuple[int, int], pg.PlotCurveItem] = {}
|
||||
legend_source_phase: dict[tuple[int, int], pg.PlotCurveItem] = {}
|
||||
active_magnitude_keys: set[tuple[int, int, int, str]] = set()
|
||||
active_phase_keys: set[tuple[int, int, int, str]] = set()
|
||||
has_data = False
|
||||
x_min = np.inf
|
||||
x_max = -np.inf
|
||||
for block in collection.blocks:
|
||||
combo_key = (int(block.combo.input), int(block.combo.output))
|
||||
if combo_filter is not None and combo_key not in combo_filter:
|
||||
continue
|
||||
if combo_key not in combo_colors:
|
||||
combo_colors[combo_key] = palette[len(combo_colors) % len(palette)]
|
||||
color = combo_colors[combo_key]
|
||||
|
||||
for payload_index, payload in enumerate(block.payloads):
|
||||
if payload.kind != 1 or payload.trace.size == 0:
|
||||
continue
|
||||
if payload.frequency_hz.size == 0 or payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
curve_key = (
|
||||
combo_key[0],
|
||||
combo_key[1],
|
||||
int(payload_index),
|
||||
str(payload.processing_name),
|
||||
)
|
||||
|
||||
local_x_min = float(np.min(payload.frequency_hz))
|
||||
local_x_max = float(np.max(payload.frequency_hz))
|
||||
x_min = min(x_min, local_x_min)
|
||||
x_max = max(x_max, local_x_max)
|
||||
|
||||
if show_magnitude:
|
||||
mag_x, magnitude_values = self._magnitude_display_arrays(
|
||||
payload.frequency_hz, payload.trace
|
||||
)
|
||||
active_magnitude_keys.add(curve_key)
|
||||
magnitude_curve = self._trace_magnitude_curves.get(curve_key)
|
||||
if magnitude_curve is None:
|
||||
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4), antialias=False)
|
||||
self._trace_magnitude_curves[curve_key] = magnitude_curve
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
else:
|
||||
magnitude_curve.setPen(pg.mkPen(color, width=1.4))
|
||||
magnitude_curve.setData(mag_x, magnitude_values)
|
||||
legend_source_magnitude.setdefault(combo_key, magnitude_curve)
|
||||
has_data = True
|
||||
|
||||
if show_phase:
|
||||
active_phase_keys.add(curve_key)
|
||||
phase_curve = self._trace_phase_curves.get(curve_key)
|
||||
if phase_curve is None:
|
||||
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2), antialias=False)
|
||||
self._trace_phase_curves[curve_key] = phase_curve
|
||||
phase_plot.addItem(phase_curve)
|
||||
else:
|
||||
phase_curve.setPen(pg.mkPen(color, width=1.2))
|
||||
phase_x, phase_values = self._phase_display_arrays(payload.frequency_hz, payload.trace)
|
||||
phase_curve.setData(phase_x, phase_values)
|
||||
legend_source_phase.setdefault(combo_key, phase_curve)
|
||||
has_data = True
|
||||
|
||||
if show_magnitude:
|
||||
self._remove_inactive_trace_curves(
|
||||
plot=magnitude_plot,
|
||||
cache=self._trace_magnitude_curves,
|
||||
active_keys=active_magnitude_keys,
|
||||
)
|
||||
else:
|
||||
self._remove_all_trace_curves(plot=magnitude_plot, cache=self._trace_magnitude_curves)
|
||||
|
||||
if show_phase:
|
||||
self._remove_inactive_trace_curves(
|
||||
plot=phase_plot,
|
||||
cache=self._trace_phase_curves,
|
||||
active_keys=active_phase_keys,
|
||||
)
|
||||
else:
|
||||
self._remove_all_trace_curves(plot=phase_plot, cache=self._trace_phase_curves)
|
||||
|
||||
self._sync_trace_legends(
|
||||
show_magnitude=show_magnitude,
|
||||
show_phase=show_phase,
|
||||
magnitude_sources=legend_source_magnitude,
|
||||
phase_sources=legend_source_phase,
|
||||
)
|
||||
|
||||
# X is the (constant) frequency band: set it explicitly rather than
|
||||
# autoranging every frame. Y is configured once per draw.
|
||||
if has_data and np.isfinite(x_min) and np.isfinite(x_max):
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_magnitude:
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
if show_phase:
|
||||
self._configure_pass_through_phase_axis(phase_plot)
|
||||
return has_data
|
||||
|
||||
@staticmethod
|
||||
def _remove_inactive_trace_curves(
|
||||
*,
|
||||
plot: pg.PlotWidget,
|
||||
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
|
||||
active_keys: set[tuple[int, int, int, str]],
|
||||
) -> None:
|
||||
"""Delete curve items no longer present in latest result collection."""
|
||||
for key in list(cache.keys()):
|
||||
if key in active_keys:
|
||||
continue
|
||||
curve = cache.pop(key)
|
||||
plot.removeItem(curve)
|
||||
|
||||
@staticmethod
|
||||
def _remove_all_trace_curves(
|
||||
*,
|
||||
plot: pg.PlotWidget,
|
||||
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
|
||||
) -> None:
|
||||
"""Delete all cached curves from selected plot."""
|
||||
for curve in cache.values():
|
||||
plot.removeItem(curve)
|
||||
cache.clear()
|
||||
|
||||
def _render_decimation_step(self, size: int) -> int:
|
||||
"""Stride that caps a rendered trace at the configured maximum point count.
|
||||
|
||||
Beyond ~1 point per screen pixel there is no visual gain, so decimating
|
||||
keeps the line plots responsive even with many combos on screen.
|
||||
"""
|
||||
max_points = int(getattr(self, "_trace_render_max_points", 800))
|
||||
if max_points > 0 and size > max_points:
|
||||
return max(1, int(np.ceil(size / max_points)))
|
||||
return 1
|
||||
|
||||
def _magnitude_display_arrays(
|
||||
self, frequency_hz: np.ndarray, trace: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return decimated (frequency, magnitude-dB) arrays for fast rendering."""
|
||||
step = self._render_decimation_step(int(trace.size))
|
||||
frequency_hz = frequency_hz[::step]
|
||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace[::step]), 1e-12))
|
||||
return frequency_hz, magnitude_db
|
||||
|
||||
def _phase_display_arrays(
|
||||
self, frequency_hz: np.ndarray, trace: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return decimated (frequency, phase-deg) arrays, unwrapped when enabled.
|
||||
|
||||
Unwrapping runs at full resolution (before decimation) so the cumulative
|
||||
phase is correct, then both arrays are decimated together for rendering.
|
||||
"""
|
||||
phase = np.angle(trace)
|
||||
if self._unwrap_phase_enabled():
|
||||
phase = np.unwrap(phase)
|
||||
phase_deg = np.degrees(phase)
|
||||
step = self._render_decimation_step(int(trace.size))
|
||||
return frequency_hz[::step], phase_deg[::step]
|
||||
|
||||
def _sync_trace_legends(
|
||||
self,
|
||||
*,
|
||||
show_magnitude: bool,
|
||||
show_phase: bool,
|
||||
magnitude_sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||
phase_sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||
) -> None:
|
||||
"""Rebuild legends only when active combo set changes."""
|
||||
self._sync_single_trace_legend(
|
||||
show=show_magnitude,
|
||||
plot=self._trace_magnitude_plot,
|
||||
legend_attr="_trace_magnitude_legend",
|
||||
legend_keys_attr="_trace_magnitude_legend_combo_keys",
|
||||
sources=magnitude_sources,
|
||||
)
|
||||
self._sync_single_trace_legend(
|
||||
show=show_phase,
|
||||
plot=self._trace_phase_plot,
|
||||
legend_attr="_trace_phase_legend",
|
||||
legend_keys_attr="_trace_phase_legend_combo_keys",
|
||||
sources=phase_sources,
|
||||
)
|
||||
|
||||
def _sync_single_trace_legend(
|
||||
self,
|
||||
*,
|
||||
show: bool,
|
||||
plot: pg.PlotWidget,
|
||||
legend_attr: str,
|
||||
legend_keys_attr: str,
|
||||
sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||
) -> None:
|
||||
"""Rebuild one legend from provided combo->curve mapping when needed."""
|
||||
legend = getattr(self, legend_attr)
|
||||
existing_keys = getattr(self, legend_keys_attr)
|
||||
active_keys = set(sources.keys())
|
||||
if not show or not active_keys:
|
||||
if legend is not None:
|
||||
try:
|
||||
plot.getPlotItem().removeItem(legend)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
f"Failed to clear plot legend: {type(exc).__name__}: {exc}",
|
||||
once_key=f"{legend_attr}_clear_failed",
|
||||
)
|
||||
setattr(self, legend_attr, None)
|
||||
existing_keys.clear()
|
||||
return
|
||||
|
||||
if legend is not None and existing_keys == active_keys:
|
||||
return
|
||||
|
||||
if legend is not None:
|
||||
try:
|
||||
plot.getPlotItem().removeItem(legend)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning(
|
||||
f"Failed to replace plot legend: {type(exc).__name__}: {exc}",
|
||||
once_key=f"{legend_attr}_replace_failed",
|
||||
)
|
||||
|
||||
legend = plot.addLegend(offset=(8, 8))
|
||||
for combo_key in sorted(active_keys):
|
||||
curve = sources[combo_key]
|
||||
legend.addItem(curve, f"in{combo_key[0]}/out{combo_key[1]}")
|
||||
setattr(self, legend_attr, legend)
|
||||
existing_keys.clear()
|
||||
existing_keys.update(active_keys)
|
||||
|
||||
def _draw_single_trace(self, trace: TraceData, title: str, *, channel: str = "s21") -> None:
|
||||
"""Draw one trace on stacked magnitude/phase plots."""
|
||||
show_magnitude = self._show_magnitude_curves()
|
||||
show_phase = self._show_phase_curves()
|
||||
magnitude_plot = self._trace_magnitude_plot
|
||||
phase_plot = self._trace_phase_plot
|
||||
samples = trace.s11 if channel == "s11" else trace.s21
|
||||
|
||||
magnitude_plot.setVisible(show_magnitude)
|
||||
phase_plot.setVisible(show_phase)
|
||||
self._clear_trace_plots()
|
||||
if not show_magnitude and not show_phase:
|
||||
return
|
||||
|
||||
if show_magnitude:
|
||||
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
|
||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
magnitude_plot.setTitle(title)
|
||||
if not show_phase:
|
||||
magnitude_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
if show_phase:
|
||||
phase_plot.getPlotItem().showAxis("bottom", show=True)
|
||||
phase_plot.setLabel("left", "Phase", units="deg")
|
||||
phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
phase_plot.setTitle(title)
|
||||
|
||||
if show_magnitude:
|
||||
mag_x, magnitude_db = self._magnitude_display_arrays(trace.frequency_hz, samples)
|
||||
magnitude_curve = pg.PlotCurveItem(
|
||||
mag_x,
|
||||
magnitude_db,
|
||||
pen=pg.mkPen("#ffd166", width=1.8),
|
||||
antialias=False,
|
||||
)
|
||||
magnitude_plot.addItem(magnitude_curve)
|
||||
self._trace_magnitude_curves[
|
||||
(int(trace.combo.input), int(trace.combo.output), 0, "__single_trace__")
|
||||
] = magnitude_curve
|
||||
|
||||
if show_phase:
|
||||
phase_x, phase_values = self._phase_display_arrays(trace.frequency_hz, samples)
|
||||
phase_curve = pg.PlotCurveItem(
|
||||
phase_x,
|
||||
phase_values,
|
||||
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
|
||||
antialias=False,
|
||||
)
|
||||
phase_plot.addItem(phase_curve)
|
||||
self._trace_phase_curves[
|
||||
(int(trace.combo.input), int(trace.combo.output), 0, "__single_trace__")
|
||||
] = phase_curve
|
||||
|
||||
if np.size(trace.frequency_hz) > 1:
|
||||
x_min = float(np.min(trace.frequency_hz))
|
||||
x_max = float(np.max(trace.frequency_hz))
|
||||
if show_magnitude:
|
||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_phase:
|
||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||
if show_magnitude:
|
||||
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||
if show_phase:
|
||||
self._configure_pass_through_phase_axis(phase_plot)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Facade mixin for processed-radar plot rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.controllers.app_window_plot import (
|
||||
AppWindowBscanPlotMixin,
|
||||
AppWindowBscanReplayMixin,
|
||||
AppWindowGprPlotMixin,
|
||||
AppWindowSar3dPlotMixin,
|
||||
AppWindowTracePlotMixin,
|
||||
)
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
|
||||
|
||||
class AppWindowPlotMixin(
|
||||
AppWindowTracePlotMixin,
|
||||
AppWindowBscanPlotMixin,
|
||||
AppWindowBscanReplayMixin,
|
||||
AppWindowGprPlotMixin,
|
||||
AppWindowSar3dPlotMixin,
|
||||
):
|
||||
"""Routes plotting to trace, B-scan, GPR, or 3D SAR-specific mixins."""
|
||||
|
||||
def _draw_results(self, collection: ResultCollection) -> bool:
|
||||
"""Draw collection based on currently selected processing mode."""
|
||||
expected_revision = getattr(self, "_required_processing_revision", None)
|
||||
if expected_revision is not None and self._is_gpr_like_processing_mode(self._processing_mode.currentText()):
|
||||
revision = next((payload for payload in collection.collection_payloads
|
||||
if payload.processing_name == "processing_revision" and payload.kind == 4), None)
|
||||
if revision is None or revision.table.shape != (1, 4):
|
||||
return False
|
||||
if any(not 0 <= word <= 0xffff or word != int(word) for word in revision.table[0]):
|
||||
return False
|
||||
actual_revision = sum(int(word) << (index * 16) for index, word in enumerate(revision.table[0]))
|
||||
if actual_revision != expected_revision:
|
||||
return False
|
||||
if self._processing_mode.currentText() == "bscan":
|
||||
return self._draw_bscan_heatmap(collection)
|
||||
if self._processing_mode.currentText() in {"gpr", "legacy_gpr"}:
|
||||
return self._draw_gpr_map(collection)
|
||||
if self._processing_mode.currentText() == "sar3d":
|
||||
return self._draw_sar3d_projections(collection)
|
||||
return self._draw_trace_lines(collection)
|
||||
|
||||
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
|
||||
"""Recognize processed output, including an empty trace after a valid depth/frequency cut."""
|
||||
if any(payload.processing_name != "processing_revision" for payload in collection.collection_payloads):
|
||||
return True
|
||||
for block in collection.blocks:
|
||||
for payload in block.payloads:
|
||||
if payload.kind == 1:
|
||||
return True
|
||||
return False
|
||||
@@ -1,921 +0,0 @@
|
||||
"""Preprocessing-set selection and sequential capture workflow mixin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.gui.controllers.object_calibration_controller import ObjectCalibrationController
|
||||
from python_app.gui.trace_png_export import export_trace_png
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_SPECS,
|
||||
VISIBLE_PREPROCESS_ASSET_KEYS,
|
||||
preprocess_asset_channel,
|
||||
preprocess_asset_display_name,
|
||||
)
|
||||
from python_app.workflows.kamil_adc_neutral_preprocess import (
|
||||
build_neutral_s21_sets,
|
||||
supports_neutral_preprocess_sets,
|
||||
)
|
||||
from python_app.workflows.multi_radar_capture_workflow import (
|
||||
MultiRadarCaptureBatch,
|
||||
MultiRadarSequentialCaptureSession,
|
||||
)
|
||||
from python_app.workflows.radar_config_variants import scan_radar_config_variants
|
||||
from python_app.workflows.reference_workflow import capture_reference_set
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
|
||||
TMP_REFERENCE_SET_NAME = "tmp_reference"
|
||||
|
||||
|
||||
class AppWindowPreprocessMixin:
|
||||
"""Handles preprocess set management and sequential capture workflows."""
|
||||
|
||||
@staticmethod
|
||||
def _capture_log_entries_for_session(session: SequentialCaptureSession | MultiRadarSequentialCaptureSession) -> list[str]:
|
||||
"""Build capture-log rows from current session traces."""
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
total_count = session.state().total_count
|
||||
if isinstance(session, MultiRadarSequentialCaptureSession):
|
||||
return [
|
||||
f"{display_name}: {index}/{total_count} | "
|
||||
f"input={batch.combo.input} output={batch.combo.output} | "
|
||||
f"radar_configs={session.radar_variant_count()}"
|
||||
for index, batch in enumerate(session.captured_batches(), start=1)
|
||||
]
|
||||
|
||||
entries: list[str] = []
|
||||
for index, trace in enumerate(session.captured_traces(), start=1):
|
||||
entries.append(
|
||||
f"{display_name}: {index}/{total_count} | "
|
||||
f"input={trace.combo.input} output={trace.combo.output}"
|
||||
)
|
||||
return entries
|
||||
|
||||
def _available_preprocess_sets_for_radar_key(self, radar_key: str) -> dict[str, list[str]]:
|
||||
"""Load available preprocess-set names for one radar key."""
|
||||
return {
|
||||
key: self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key)
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS
|
||||
}
|
||||
|
||||
def _reset_preprocess_selection_after_radar_key_change(self) -> None:
|
||||
"""Clear selected preprocess sets when radar-key-defining settings change."""
|
||||
try:
|
||||
radar_key = self._radar_key_from_ui()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Radar fields can be mid-edit (empty/partial) while signals fire; the
|
||||
# key cannot be computed yet, so skip until the inputs are valid again.
|
||||
self._log_debug(
|
||||
f"Skipping preprocess-selection reset; radar key unavailable: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
previous_radar_key = getattr(self, "_selected_preprocess_radar_key", radar_key)
|
||||
if radar_key == previous_radar_key:
|
||||
return
|
||||
|
||||
self._selected_preprocess_radar_key = radar_key
|
||||
cleared = {
|
||||
key: value
|
||||
for key, value in self._selected_preprocess_sets.items()
|
||||
if value
|
||||
}
|
||||
if not cleared:
|
||||
if self._preprocess_dialog is not None:
|
||||
self._refresh_sets()
|
||||
return
|
||||
|
||||
self._selected_preprocess_sets = {
|
||||
key: ""
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS
|
||||
}
|
||||
self._refresh_preprocess_summary_labels()
|
||||
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
|
||||
self._refresh_sets()
|
||||
|
||||
cleared_summary = ", ".join(
|
||||
f"{preprocess_asset_display_name(key)}={value}"
|
||||
for key, value in cleared.items()
|
||||
)
|
||||
self._log(
|
||||
"Preprocess selection reset after radar settings changed: "
|
||||
f"{previous_radar_key} -> {radar_key}; cleared {cleared_summary}"
|
||||
)
|
||||
|
||||
def _open_preprocess_panel(self) -> None:
|
||||
"""Open preprocessing dialog and refresh available sets."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
try:
|
||||
self._refresh_sets()
|
||||
self._update_capture_dialog_state()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to open preprocessing panel", exc)
|
||||
return
|
||||
|
||||
dialog.showMaximized()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
|
||||
def _capture_tmp_reference(self) -> None:
|
||||
"""Capture, save, and select a temporary S21 reference with current sweep settings."""
|
||||
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
|
||||
return
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Cannot capture tmp reference during active capture sequence",
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
# One shared re-entrancy guard for BOTH the GUI button and the GPIO trigger:
|
||||
# this capture spins the Qt event loop (stop/start run, dialogs), so a second
|
||||
# request from either source must not start a nested capture.
|
||||
if self._control_button_busy:
|
||||
self._log("Tmp reference capture already in progress; ignoring duplicate request.")
|
||||
return
|
||||
self._control_button_busy = True
|
||||
|
||||
pipeline_was_running = self._supervisor.is_running()
|
||||
pipeline_was_paused = False
|
||||
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
|
||||
try:
|
||||
if pipeline_was_running:
|
||||
self._log("Pipeline paused for tmp reference capture")
|
||||
self._stop_run()
|
||||
pipeline_was_paused = True
|
||||
if not self._pipeline_resume_allowed(resume_seq):
|
||||
return
|
||||
|
||||
config = self._build_config()
|
||||
radar_key = self._radar_key(config)
|
||||
self._log(
|
||||
"Tmp S21 Reference capture started: "
|
||||
f"set={TMP_REFERENCE_SET_NAME}, radar_key={radar_key}"
|
||||
)
|
||||
|
||||
radar_key, collection = capture_reference_set(
|
||||
config,
|
||||
TMP_REFERENCE_SET_NAME,
|
||||
self._store,
|
||||
median_sweep_count=self._preprocess_median_sweep_count,
|
||||
)
|
||||
self._selected_preprocess_sets["s21_reference"] = TMP_REFERENCE_SET_NAME
|
||||
self._selected_preprocess_radar_key = radar_key
|
||||
# Do NOT restart data_processor for a reference change: it does not consume
|
||||
# the S21 reference (the data_preprocessor does), and restarting it would tear
|
||||
# down the locator server it hosts and drop every connected client. The
|
||||
# preprocessor reloads the new reference when acquisition restarts below; the
|
||||
# processor keeps running. We only re-baseline the on-screen display history.
|
||||
self._history_run_signature = None
|
||||
self._reset_runtime_history()
|
||||
# Tell the still-running data_processor to drop its accumulated background/
|
||||
# history so the new reference takes effect cleanly (no old/new-reference
|
||||
# blend) without restarting the process. Reuses the live-config clear_all
|
||||
# command, applied on the processor's next poll.
|
||||
if self._supervisor.is_processor_running():
|
||||
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
|
||||
self._refresh_preprocess_summary_labels()
|
||||
if collection.traces:
|
||||
self._draw_single_trace(
|
||||
collection.traces[-1],
|
||||
title="Tmp S21 Reference last trace",
|
||||
channel="s21",
|
||||
)
|
||||
if self._preprocess_dialog is not None:
|
||||
self._refresh_sets()
|
||||
self._preprocess_dialog.set_status(
|
||||
f"S21 Reference set saved: {TMP_REFERENCE_SET_NAME} ({len(collection.traces)} traces)"
|
||||
)
|
||||
|
||||
self._log(
|
||||
"Tmp S21 Reference captured and selected: "
|
||||
f"set={TMP_REFERENCE_SET_NAME}, key={radar_key}, traces={len(collection.traces)}; "
|
||||
"runtime history reset"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to capture tmp reference", exc)
|
||||
finally:
|
||||
if pipeline_was_paused and self._pipeline_resume_allowed(resume_seq):
|
||||
self._start_run()
|
||||
self._control_button_busy = False
|
||||
|
||||
def _ensure_preprocess_dialog(self) -> PreprocessDialog:
|
||||
"""Create preprocessing dialog lazily and wire its signals once."""
|
||||
if self._preprocess_dialog is not None:
|
||||
return self._preprocess_dialog
|
||||
|
||||
dialog = PreprocessDialog(self)
|
||||
self._preprocess_dialog = dialog
|
||||
dialog.set_set_name(self._preprocess_set_name)
|
||||
dialog.set_radar_config_dir(self._preprocess_radar_config_dir)
|
||||
dialog.set_use_all_radar_configs(self._preprocess_use_all_radar_configs)
|
||||
dialog.set_median_sweep_count(self._preprocess_median_sweep_count)
|
||||
dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
|
||||
dialog.refresh_requested.connect(self._refresh_sets)
|
||||
dialog.selection_changed.connect(self._on_preprocess_selection_changed)
|
||||
dialog.radar_config_dir_changed.connect(self._on_preprocess_radar_config_inputs_changed)
|
||||
dialog.multi_radar_option_changed.connect(self._on_preprocess_radar_config_inputs_changed)
|
||||
dialog.median_sweep_count_changed.connect(self._on_preprocess_median_sweep_count_changed)
|
||||
dialog.start_sequence_requested.connect(self._start_capture_sequence)
|
||||
dialog.capture_next_requested.connect(self._capture_next_combo)
|
||||
dialog.capture_all_requested.connect(self._capture_all_remaining)
|
||||
dialog.undo_last_requested.connect(self._undo_last_capture)
|
||||
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
|
||||
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
|
||||
dialog.create_neutral_sets_requested.connect(self._create_neutral_sets)
|
||||
self._object_calibration_controller = ObjectCalibrationController(self)
|
||||
dialog.import_object_calibration_requested.connect(self._object_calibration_controller.import_calibration)
|
||||
dialog.create_object_calibration_requested.connect(self._object_calibration_controller.open_creation)
|
||||
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
|
||||
dialog.set_radar_config_summary(
|
||||
directory_path=self._preprocess_radar_scan_summary.directory_path,
|
||||
json_file_count=self._preprocess_radar_scan_summary.json_file_count,
|
||||
valid_variant_count=self._preprocess_radar_scan_summary.valid_variant_count,
|
||||
skipped_file_count=self._preprocess_radar_scan_summary.skipped_file_count,
|
||||
duplicate_variant_count=self._preprocess_radar_scan_summary.duplicate_variant_count,
|
||||
)
|
||||
self._update_capture_dialog_state()
|
||||
return dialog
|
||||
|
||||
def _on_preprocess_radar_config_inputs_changed(self) -> None:
|
||||
"""Persist preprocess radar-config directory options and refresh the scan result."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
self._preprocess_radar_config_dir = dialog.radar_config_dir()
|
||||
self._preprocess_use_all_radar_configs = dialog.use_all_radar_configs()
|
||||
self._refresh_sets()
|
||||
|
||||
def _on_preprocess_median_sweep_count_changed(self) -> None:
|
||||
"""Persist updated per-combo median sweep count from the preprocessing dialog."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
self._preprocess_median_sweep_count = dialog.median_sweep_count()
|
||||
|
||||
def _on_preprocess_selection_changed(self) -> None:
|
||||
"""Persist selected preprocessing set names from dialog."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
previous_selection = dict(self._selected_preprocess_sets)
|
||||
self._selected_preprocess_sets = dialog.selection_snapshot()
|
||||
self._refresh_preprocess_summary_labels()
|
||||
changes = []
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
|
||||
previous_value = previous_selection.get(key, "")
|
||||
current_value = self._selected_preprocess_sets.get(key, "")
|
||||
if previous_value == current_value:
|
||||
continue
|
||||
changes.append(
|
||||
f"{preprocess_asset_display_name(key)}: "
|
||||
f"{previous_value or '<not selected>'} -> {current_value or '<not selected>'}"
|
||||
)
|
||||
if changes:
|
||||
self._log("Preprocess selection changed: " + "; ".join(changes))
|
||||
|
||||
def _refresh_preprocess_summary_labels(self) -> None:
|
||||
"""Update compact summary labels in the main window."""
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
|
||||
self._selected_preprocess_labels[key].setText(self._selected_preprocess_sets.get(key, "") or "<not selected>")
|
||||
|
||||
def _refresh_sets(self) -> None:
|
||||
"""Refresh preprocess set lists for current radar key."""
|
||||
radar_key = self._radar_key_from_ui()
|
||||
self._selected_preprocess_radar_key = radar_key
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
self._preprocess_radar_config_dir = dialog.radar_config_dir()
|
||||
self._preprocess_use_all_radar_configs = dialog.use_all_radar_configs()
|
||||
|
||||
available_sets = self._available_preprocess_sets_for_radar_key(radar_key)
|
||||
dialog.set_available_sets(available_sets)
|
||||
self._refresh_preprocess_radar_variants()
|
||||
|
||||
unavailable_selections: list[str] = []
|
||||
for key, names in available_sets.items():
|
||||
current_value = self._selected_preprocess_sets.get(key, "")
|
||||
if not current_value or current_value in names:
|
||||
continue
|
||||
unavailable_selections.append(
|
||||
f"{preprocess_asset_display_name(key)}: "
|
||||
f"{current_value} (not available for current radar key)"
|
||||
)
|
||||
|
||||
dialog.set_selected_sets(self._selected_preprocess_sets)
|
||||
self._refresh_preprocess_summary_labels()
|
||||
available_counts = ", ".join(
|
||||
f"{preprocess_asset_display_name(key)}={len(names)}"
|
||||
for key, names in available_sets.items()
|
||||
)
|
||||
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults_config))
|
||||
self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}")
|
||||
if unavailable_selections:
|
||||
self._log_warning(
|
||||
"Some selected preprocess sets are not currently available for this radar key.",
|
||||
details="\n".join(unavailable_selections),
|
||||
)
|
||||
|
||||
def _refresh_preprocess_radar_variants(self) -> None:
|
||||
"""Reload valid radar sweep variants from the preprocess dialog directory."""
|
||||
config = self._build_config()
|
||||
variants, summary = scan_radar_config_variants(
|
||||
self._preprocess_radar_config_dir,
|
||||
base_config=config,
|
||||
)
|
||||
self._preprocess_radar_variants = variants
|
||||
self._preprocess_radar_scan_summary = summary
|
||||
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_dialog.set_radar_config_summary(
|
||||
directory_path=summary.directory_path,
|
||||
json_file_count=summary.json_file_count,
|
||||
valid_variant_count=summary.valid_variant_count,
|
||||
skipped_file_count=summary.skipped_file_count,
|
||||
duplicate_variant_count=summary.duplicate_variant_count,
|
||||
)
|
||||
|
||||
if summary.issues:
|
||||
self._log_warning(
|
||||
"Some radar config JSON files were skipped during preprocess scan.",
|
||||
details="\n".join(summary.issues),
|
||||
once_key=f"preprocess_radar_config_scan_{summary.directory_path}",
|
||||
)
|
||||
|
||||
def _start_capture_sequence(self, kind: str) -> None:
|
||||
"""Start sequential capture session for requested preprocess asset."""
|
||||
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
|
||||
return
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
if self._capture_session is not None:
|
||||
self._show_error("Another capture sequence is already active", details=self._capture_state_details())
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
set_name = dialog.set_name()
|
||||
if not set_name:
|
||||
self._show_error("Set name is required")
|
||||
return
|
||||
|
||||
was_running = self._supervisor.is_running()
|
||||
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
if was_running:
|
||||
self._log("Pipeline paused for exclusive hardware capture")
|
||||
self._stop_run()
|
||||
if not self._pipeline_resume_allowed(resume_seq):
|
||||
return
|
||||
|
||||
self._resume_pipeline_after_capture = was_running
|
||||
|
||||
try:
|
||||
config = self._build_config()
|
||||
display_name = preprocess_asset_display_name(kind)
|
||||
median_sweep_count = dialog.median_sweep_count()
|
||||
self._preprocess_median_sweep_count = median_sweep_count
|
||||
if self._preprocess_use_all_radar_configs:
|
||||
session = self._build_multi_radar_capture_session(
|
||||
config=config,
|
||||
kind=kind,
|
||||
set_name=set_name,
|
||||
median_sweep_count=median_sweep_count,
|
||||
)
|
||||
radar_summary = (
|
||||
f"radar_variants={session.radar_variant_count()}, "
|
||||
f"combos={session.state().total_count}, "
|
||||
f"median_sweep_count={median_sweep_count}"
|
||||
)
|
||||
else:
|
||||
session = self._build_single_radar_capture_session(
|
||||
config=config,
|
||||
kind=kind,
|
||||
set_name=set_name,
|
||||
median_sweep_count=median_sweep_count,
|
||||
)
|
||||
radar_summary = (
|
||||
f"radar_key={self._radar_key(config)}, "
|
||||
f"combos={session.state().total_count}, "
|
||||
f"median_sweep_count={median_sweep_count}"
|
||||
)
|
||||
|
||||
session.open()
|
||||
self._capture_session = session
|
||||
|
||||
dialog.clear_capture_log()
|
||||
dialog.reset_preview()
|
||||
dialog.set_status(f"{display_name} sequence started")
|
||||
self._clear_trace_plots()
|
||||
self._update_capture_dialog_state()
|
||||
self._log(
|
||||
f"{display_name} sequence started: set={set_name}, {radar_summary}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._cleanup_capture_session()
|
||||
self._show_exception(f"Failed to start {kind} sequence", exc)
|
||||
self._resume_pipeline_if_needed()
|
||||
|
||||
def _create_neutral_sets(self) -> None:
|
||||
"""Save neutral S21 calibration/reference sets for the current radar settings."""
|
||||
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
|
||||
return
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Cannot create neutral sets during active capture sequence",
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
set_name = dialog.set_name()
|
||||
if not set_name:
|
||||
self._show_error("Set name is required")
|
||||
return
|
||||
|
||||
pipeline_was_paused = False
|
||||
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
try:
|
||||
config = self._build_config()
|
||||
if not supports_neutral_preprocess_sets(config):
|
||||
self._show_error(
|
||||
"Neutral S21 sets are not supported for this radar configuration"
|
||||
)
|
||||
return
|
||||
|
||||
radar_key = self._radar_key(config)
|
||||
duplicate_assets = [
|
||||
preprocess_asset_display_name(key)
|
||||
for key in ("s21_calibration", "s21_reference")
|
||||
if set_name in self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key)
|
||||
]
|
||||
if duplicate_assets:
|
||||
raise RuntimeError(
|
||||
f"Set '{set_name}' already exists for: " + ", ".join(duplicate_assets)
|
||||
)
|
||||
|
||||
if self._supervisor.is_running():
|
||||
self._log("Pipeline paused for neutral-set creation")
|
||||
self._stop_run()
|
||||
pipeline_was_paused = True
|
||||
if not self._pipeline_resume_allowed(resume_seq):
|
||||
return
|
||||
|
||||
calibration, reference = build_neutral_s21_sets(config)
|
||||
point_count = int(calibration.traces[0].frequency_hz.size)
|
||||
self._store.save_sets(radar_key, set_name, {
|
||||
"s21_calibration": calibration, "s21_reference": reference,
|
||||
})
|
||||
|
||||
self._selected_preprocess_sets["s21_calibration"] = set_name
|
||||
self._selected_preprocess_sets["s21_reference"] = set_name
|
||||
self._selected_preprocess_radar_key = radar_key
|
||||
self._processor_run_signature = None
|
||||
self._history_run_signature = None
|
||||
self._reset_runtime_history()
|
||||
self._refresh_sets()
|
||||
dialog.set_status(
|
||||
f"Neutral S21 sets saved: {set_name} ({len(calibration.traces)} combos, {point_count} points)"
|
||||
)
|
||||
self._log(
|
||||
"Neutral S21 sets saved: "
|
||||
f"set={set_name}, radar_key={radar_key}, combos={len(calibration.traces)}, points={point_count}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to create neutral S21 sets", exc)
|
||||
finally:
|
||||
if pipeline_was_paused and self._pipeline_resume_allowed(resume_seq):
|
||||
self._start_run()
|
||||
|
||||
def _build_single_radar_capture_session(
|
||||
self,
|
||||
*,
|
||||
config,
|
||||
kind: str,
|
||||
set_name: str,
|
||||
median_sweep_count: int,
|
||||
) -> SequentialCaptureSession:
|
||||
"""Validate and create the existing single-radar capture session."""
|
||||
radar_key = self._radar_key(config)
|
||||
existing_sets = self._store.list_sets(PREPROCESS_ASSET_SPECS[kind].set_kind, radar_key)
|
||||
display_name = preprocess_asset_display_name(kind)
|
||||
if set_name in existing_sets:
|
||||
raise RuntimeError(f"Set '{set_name}' already exists for {display_name} and cannot be overwritten")
|
||||
return SequentialCaptureSession(
|
||||
config=config,
|
||||
kind=kind,
|
||||
set_name=set_name,
|
||||
median_sweep_count=median_sweep_count,
|
||||
)
|
||||
|
||||
def _build_multi_radar_capture_session(
|
||||
self,
|
||||
*,
|
||||
config,
|
||||
kind: str,
|
||||
set_name: str,
|
||||
median_sweep_count: int,
|
||||
) -> MultiRadarSequentialCaptureSession:
|
||||
"""Validate and create the multi-radar capture session."""
|
||||
self._refresh_preprocess_radar_variants()
|
||||
if not self._preprocess_radar_variants:
|
||||
raise RuntimeError("No valid radar config variants were found in the selected directory")
|
||||
|
||||
display_name = preprocess_asset_display_name(kind)
|
||||
duplicate_set_keys: list[str] = []
|
||||
for variant in self._preprocess_radar_variants:
|
||||
existing_sets = self._store.list_sets(PREPROCESS_ASSET_SPECS[kind].set_kind, variant.radar_key)
|
||||
if set_name in existing_sets:
|
||||
duplicate_set_keys.append(f"{variant.display_name} -> {variant.radar_key}")
|
||||
if duplicate_set_keys:
|
||||
raise RuntimeError(
|
||||
f"Set '{set_name}' already exists for {display_name} in these radar variants:\n"
|
||||
+ "\n".join(duplicate_set_keys)
|
||||
)
|
||||
|
||||
return MultiRadarSequentialCaptureSession(
|
||||
base_config=config,
|
||||
kind=kind,
|
||||
set_name=set_name,
|
||||
radar_variants=self._preprocess_radar_variants,
|
||||
median_sweep_count=median_sweep_count,
|
||||
)
|
||||
|
||||
def _capture_next_combo(self) -> None:
|
||||
"""Capture next combo in active sequential capture session."""
|
||||
session = self._capture_session
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
# The capture blocks the event loop, so clicks made during it are delivered
|
||||
# only after it finishes. `_begin_preprocess_capture` disables the action
|
||||
# buttons for that whole window (re-enabled via a posted event), so a queued
|
||||
# click lands on a disabled button instead of silently starting — and
|
||||
# advancing the combo cursor of — another capture.
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
try:
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _capture_all_remaining(self) -> None:
|
||||
"""Capture all remaining combos for the active preprocess session."""
|
||||
session = self._capture_session
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
if session.is_complete():
|
||||
self._show_error(
|
||||
"Capture sequence is already complete",
|
||||
details=self._capture_state_details(),
|
||||
)
|
||||
return
|
||||
if not self._begin_preprocess_capture():
|
||||
return
|
||||
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
stop_seq = getattr(self, "_stop_request_seq", 0)
|
||||
dialog.set_status(f"{display_name} batch capture started")
|
||||
self._log(
|
||||
f"{display_name} batch capture started: remaining="
|
||||
f"{session.state().total_count - session.state().captured_count}"
|
||||
)
|
||||
try:
|
||||
while self._capture_session is session and self._pipeline_resume_allowed(stop_seq) and not session.is_complete():
|
||||
try:
|
||||
capture_result = session.capture_current_combo()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._on_capture_combo_failed(session, exc)
|
||||
return
|
||||
self._record_preprocess_capture(session, capture_result)
|
||||
if not session.is_complete():
|
||||
self._pump_events_during_drain(0.01)
|
||||
if self._capture_session is session and not session.is_complete():
|
||||
dialog.set_status(f"{display_name} batch capture paused; previous captures kept")
|
||||
finally:
|
||||
self._end_preprocess_capture()
|
||||
|
||||
def _begin_preprocess_capture(self) -> bool:
|
||||
"""Mark a blocking combo capture as running; refuse when one already is.
|
||||
|
||||
Returns False for a duplicate request (e.g. a click delivered while an
|
||||
error dialog inside a capture pumps the event loop).
|
||||
"""
|
||||
if getattr(self, "_closing", False) or self._preprocess_capture_busy:
|
||||
self._log("Preprocess combo capture already in progress; ignoring duplicate request.")
|
||||
return False
|
||||
self._preprocess_capture_busy = True
|
||||
# Disable the sequence action buttons for the whole blocked window.
|
||||
self._update_capture_dialog_state()
|
||||
return True
|
||||
|
||||
def _end_preprocess_capture(self) -> None:
|
||||
"""Re-enable capture actions after the pending input backlog is discarded.
|
||||
|
||||
The zero-delay timer fires only after Qt has dispatched the window-system
|
||||
events queued while the capture blocked the loop; those clicks hit the
|
||||
still-disabled buttons and are dropped, then the buttons come back.
|
||||
"""
|
||||
self._preprocess_capture_busy = False
|
||||
QTimer.singleShot(0, self._update_capture_dialog_state)
|
||||
|
||||
def _on_capture_combo_failed(
|
||||
self,
|
||||
session: SequentialCaptureSession | MultiRadarSequentialCaptureSession,
|
||||
exc: BaseException,
|
||||
) -> None:
|
||||
"""Report a failed combo capture while preserving the session and prior captures."""
|
||||
state = session.state()
|
||||
combo = state.current_combo
|
||||
combo_text = (
|
||||
f"input={combo.input}, output={combo.output}" if combo is not None else "<unknown>"
|
||||
)
|
||||
self._show_exception(
|
||||
f"Failed to capture combo {combo_text}; previous captures kept, retry when ready",
|
||||
exc,
|
||||
)
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
dialog.set_status(
|
||||
f"{display_name} capture failed at {combo_text}: "
|
||||
f"{state.captured_count}/{state.total_count} kept, ready to retry"
|
||||
)
|
||||
self._update_capture_dialog_state()
|
||||
|
||||
def _record_preprocess_capture(
|
||||
self,
|
||||
session: SequentialCaptureSession | MultiRadarSequentialCaptureSession,
|
||||
capture_result,
|
||||
) -> None:
|
||||
"""Update UI, preview, and logs after one successful preprocess capture."""
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
state = session.state()
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
channel = preprocess_asset_channel(session.kind)
|
||||
if isinstance(session, MultiRadarSequentialCaptureSession):
|
||||
assert isinstance(capture_result, MultiRadarCaptureBatch)
|
||||
trace = capture_result.display_trace
|
||||
input_pos = capture_result.combo.input
|
||||
output_pos = capture_result.combo.output
|
||||
extra_details = f" | radar_configs={len(capture_result.traces)}"
|
||||
else:
|
||||
trace = capture_result
|
||||
input_pos = trace.combo.input
|
||||
output_pos = trace.combo.output
|
||||
extra_details = ""
|
||||
|
||||
dialog.append_capture_log_entry(
|
||||
kind=display_name,
|
||||
captured_count=state.captured_count,
|
||||
total_count=state.total_count,
|
||||
input_pos=input_pos,
|
||||
output_pos=output_pos,
|
||||
extra_details=extra_details,
|
||||
)
|
||||
dialog.draw_last_trace(trace, title=f"{display_name} captured", channel=channel)
|
||||
self._draw_single_trace(trace, title=f"{display_name} last trace", channel=channel)
|
||||
self._log(
|
||||
f"{display_name} capture: {state.captured_count}/{state.total_count} | "
|
||||
f"input={input_pos} output={output_pos}{extra_details}"
|
||||
)
|
||||
|
||||
self._update_capture_dialog_state()
|
||||
if session.is_complete():
|
||||
dialog.set_status(f"{display_name} sequence complete. Review captures or save the set.")
|
||||
self._log(
|
||||
f"{display_name} sequence capture complete: "
|
||||
f"{state.captured_count}/{state.total_count}; waiting for save or undo"
|
||||
)
|
||||
|
||||
def _undo_last_capture(self) -> None:
|
||||
"""Remove the most recently captured combo and rewind capture cursor."""
|
||||
session = self._capture_session
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
try:
|
||||
removed_capture = session.undo_last_capture()
|
||||
state = session.state()
|
||||
display_name = preprocess_asset_display_name(session.kind)
|
||||
channel = preprocess_asset_channel(session.kind)
|
||||
if isinstance(session, MultiRadarSequentialCaptureSession):
|
||||
assert isinstance(removed_capture, MultiRadarCaptureBatch)
|
||||
removed_input = removed_capture.combo.input
|
||||
removed_output = removed_capture.combo.output
|
||||
extra_details = f" | radar_configs={len(removed_capture.traces)}"
|
||||
else:
|
||||
removed_input = removed_capture.combo.input
|
||||
removed_output = removed_capture.combo.output
|
||||
extra_details = ""
|
||||
|
||||
dialog.set_capture_log_entries(self._capture_log_entries_for_session(session))
|
||||
last_trace = session.last_captured_trace()
|
||||
if last_trace is None:
|
||||
dialog.reset_preview()
|
||||
dialog.set_status(f"{display_name} last capture removed. No captured combos remain.")
|
||||
self._clear_trace_plots()
|
||||
else:
|
||||
dialog.draw_last_trace(last_trace, title=f"{display_name} last trace", channel=channel)
|
||||
dialog.set_status(f"{display_name} last capture removed. Ready to recapture.")
|
||||
self._draw_single_trace(last_trace, title=f"{display_name} last trace", channel=channel)
|
||||
|
||||
self._update_capture_dialog_state()
|
||||
self._log(
|
||||
f"{display_name} undo last capture: removed input={removed_input} "
|
||||
f"output={removed_output}{extra_details}; remaining={state.captured_count}/{state.total_count}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to undo last preprocess capture", exc)
|
||||
|
||||
def _finalize_capture_sequence(self) -> None:
|
||||
"""Persist completed capture session into preprocess-set storage."""
|
||||
session = self._capture_session
|
||||
if session is None:
|
||||
self._show_error("No active capture sequence")
|
||||
return
|
||||
if not session.is_complete():
|
||||
self._show_error(
|
||||
"Capture sequence is not complete",
|
||||
details=(
|
||||
f"Captured {session.state().captured_count}/{session.state().total_count} combos. "
|
||||
"Finish the remaining captures before saving."
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
try:
|
||||
set_name = session.set_name
|
||||
kind = session.kind
|
||||
display_name = preprocess_asset_display_name(kind)
|
||||
if isinstance(session, MultiRadarSequentialCaptureSession):
|
||||
saved_sets = session.finalize(self._store)
|
||||
else:
|
||||
radar_key, collection = session.finalize(self._store)
|
||||
self._cleanup_capture_session()
|
||||
|
||||
self._selected_preprocess_sets[kind] = set_name
|
||||
self._refresh_sets()
|
||||
if isinstance(session, MultiRadarSequentialCaptureSession):
|
||||
assert isinstance(saved_sets, list)
|
||||
dialog.set_status(
|
||||
f"{display_name} set saved: {set_name} ({len(saved_sets)} radar variants)"
|
||||
)
|
||||
saved_summary = ", ".join(
|
||||
f"{saved.display_name}:{saved.trace_count}"
|
||||
for saved in saved_sets
|
||||
)
|
||||
self._log(
|
||||
f"{display_name} sequence completed and saved: set={set_name}, "
|
||||
f"radar_variants={len(saved_sets)} [{saved_summary}]"
|
||||
)
|
||||
self._export_multi_radar_preview_pngs(
|
||||
session=session,
|
||||
saved_sets=saved_sets,
|
||||
kind=kind,
|
||||
set_name=set_name,
|
||||
)
|
||||
else:
|
||||
dialog.set_status(f"{display_name} set saved: {set_name} ({len(collection.traces)} traces)")
|
||||
self._log(f"{display_name} sequence completed and saved: set={set_name}, key={radar_key}")
|
||||
self._resume_pipeline_if_needed()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to save preprocess set", exc)
|
||||
|
||||
def _export_multi_radar_preview_pngs(
|
||||
self,
|
||||
*,
|
||||
session: MultiRadarSequentialCaptureSession,
|
||||
saved_sets: list,
|
||||
kind: str,
|
||||
set_name: str,
|
||||
) -> None:
|
||||
"""Save amplitude/phase PNGs for every captured combo across all radar configs.
|
||||
|
||||
The live preview only shows one trace per config, so this persists a graph for
|
||||
every config and every combo (port) under the store's ``preview_png/`` tree.
|
||||
Iterating per config over its full trace set (not the per-batch preview trace)
|
||||
is what makes matrix radars save all ports instead of only the last one. The
|
||||
set is already saved by the time we get here, so any rendering failure is
|
||||
logged but never aborts the save.
|
||||
"""
|
||||
channel = preprocess_asset_channel(kind)
|
||||
display_name = preprocess_asset_display_name(kind)
|
||||
saved_count = 0
|
||||
for saved in saved_sets:
|
||||
for trace in session.traces_for_radar_key(saved.radar_key):
|
||||
combo_label = f"input={trace.combo.input} output={trace.combo.output}"
|
||||
try:
|
||||
png_path = self._store.preview_png_dir(kind, set_name, saved.radar_key) / (
|
||||
f"i{trace.combo.input}_o{trace.combo.output}.png"
|
||||
)
|
||||
export_trace_png(
|
||||
trace,
|
||||
png_path,
|
||||
channel=channel,
|
||||
title=f"{display_name} | {saved.display_name} | {combo_label}",
|
||||
)
|
||||
saved_count += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(
|
||||
f"Failed to save preview PNG for {saved.display_name} ({combo_label}): {exc}"
|
||||
)
|
||||
if saved_count and saved_sets:
|
||||
png_root = self._store.preview_png_dir(kind, set_name, saved_sets[0].radar_key).parent
|
||||
self._log(
|
||||
f"Saved {saved_count} preview PNG(s) for {len(saved_sets)} radar config(s) under {png_root}"
|
||||
)
|
||||
|
||||
def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None:
|
||||
"""Abort active capture session and optionally resume pipeline."""
|
||||
if self._capture_session is None:
|
||||
return
|
||||
|
||||
display_name = preprocess_asset_display_name(self._capture_session.kind)
|
||||
self._cleanup_capture_session()
|
||||
dialog = self._ensure_preprocess_dialog()
|
||||
dialog.set_status(f"{display_name} sequence aborted")
|
||||
self._log(f"{display_name} sequence aborted")
|
||||
if resume_pipeline:
|
||||
self._resume_pipeline_if_needed()
|
||||
|
||||
def _update_capture_dialog_state(self) -> None:
|
||||
"""Sync dialog state widgets with active capture session."""
|
||||
if self._preprocess_dialog is None:
|
||||
return
|
||||
|
||||
if self._capture_session is None:
|
||||
self._preprocess_dialog.set_capture_state(
|
||||
kind=None,
|
||||
captured_count=0,
|
||||
total_count=0,
|
||||
next_input=None,
|
||||
next_output=None,
|
||||
can_undo=False,
|
||||
can_finalize=False,
|
||||
can_capture_all=False,
|
||||
variant_count=1,
|
||||
)
|
||||
return
|
||||
|
||||
state = self._capture_session.state()
|
||||
next_input = None
|
||||
next_output = None
|
||||
if state.current_combo is not None:
|
||||
next_input = state.current_combo.input
|
||||
next_output = state.current_combo.output
|
||||
|
||||
self._preprocess_dialog.set_capture_state(
|
||||
kind=state.kind,
|
||||
captured_count=state.captured_count,
|
||||
total_count=state.total_count,
|
||||
next_input=next_input,
|
||||
next_output=next_output,
|
||||
can_undo=state.can_undo,
|
||||
can_finalize=state.is_complete,
|
||||
can_capture_all=(
|
||||
state.supports_batch_capture
|
||||
and not state.is_complete
|
||||
and state.current_combo is not None
|
||||
),
|
||||
variant_count=state.variant_count,
|
||||
# While a blocking capture is executing, every action stays disabled no
|
||||
# matter what the session state allows: clicks queued during the freeze
|
||||
# must land on disabled buttons (see `_end_preprocess_capture`).
|
||||
actions_enabled=not self._preprocess_capture_busy,
|
||||
)
|
||||
|
||||
def _cleanup_capture_session(self) -> None:
|
||||
"""Close and clear the current capture session object."""
|
||||
if self._capture_session is not None:
|
||||
self._log_debug(
|
||||
f"Closing capture session: kind={self._capture_session.kind}."
|
||||
)
|
||||
self._capture_session.close()
|
||||
self._capture_session = None
|
||||
self._update_capture_dialog_state()
|
||||
|
||||
def _resume_pipeline_if_needed(self) -> None:
|
||||
"""Resume acquisition pipeline if it was paused for capture session."""
|
||||
should_resume = self._resume_pipeline_after_capture
|
||||
self._resume_pipeline_after_capture = False
|
||||
if not should_resume:
|
||||
return
|
||||
|
||||
try:
|
||||
self._start_run()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to resume pipeline after capture", exc)
|
||||
@@ -1,332 +0,0 @@
|
||||
"""Record the next N runtime measurements to disk, in chunks, on a background thread.
|
||||
|
||||
The manual "Save Dataset" button persists what is *already* in history (capped at the
|
||||
GUI history limit). This mixin adds the complementary "Start + Record" action: arm a
|
||||
recording, then stream the measurements that arrive *after* arming — up to the
|
||||
configured count — to disk without ever blocking acquisition.
|
||||
|
||||
Key properties:
|
||||
|
||||
* **Flat memory.** Measurements are flushed in chunks of :data:`_RECORDING_CHUNK_SIZE`
|
||||
and freed, and at most :data:`_RECORDING_QUEUE_CHUNKS` chunks are ever in flight, so
|
||||
RAM does not grow with the recording length.
|
||||
* **Off the GUI thread.** A single background writer thread does all disk I/O; the GUI
|
||||
poll tick only hands it chunks. If
|
||||
the disk genuinely cannot keep up, the bounded queue applies brief backpressure
|
||||
rather than growing memory without limit.
|
||||
* **Complete received stages.** Raw, preprocessed and result collections are saved
|
||||
independently with their original timestamps, including sweeps without a result.
|
||||
* **Only new sweeps.** Collections produced before the arm instant are ignored (gated
|
||||
on ``monotonic_ns``), so a pre-existing ring backlog is not recorded.
|
||||
* **No double-arm.** Arming is refused while a recording is still in progress.
|
||||
|
||||
The on-disk layout is identical to "Save Dataset" (one streaming dataset directory).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Flush to disk every this many results, then drop them from memory.
|
||||
_RECORDING_CHUNK_SIZE = 200
|
||||
# Max chunks queued for the writer thread before the producer applies backpressure;
|
||||
# bounds in-flight memory to roughly this many chunks of measurements.
|
||||
_RECORDING_QUEUE_CHUNKS = 8
|
||||
|
||||
|
||||
class AppWindowRecordingMixin:
|
||||
"""Stream the next N arriving measurements to a snapshot dataset on a worker thread."""
|
||||
|
||||
def _init_recording_state(self) -> None:
|
||||
"""Initialise the (idle) disk-recording state. Called once from ``__init__``."""
|
||||
self._recording_active = False
|
||||
self._recording_collecting = False
|
||||
self._recording_target = 0
|
||||
self._recording_since_ns = 0
|
||||
self._recording_enqueued = 0
|
||||
self._recording_received_count = 0
|
||||
self._recording_writer = None
|
||||
self._recording_queue: queue.Queue | None = None
|
||||
self._recording_thread: threading.Thread | None = None
|
||||
self._recording_stop = threading.Event()
|
||||
self._recording_write_error: str | None = None
|
||||
self._recording_chunk_raw: list = []
|
||||
self._recording_chunk_pre: list = []
|
||||
self._recording_chunk_results: list = []
|
||||
|
||||
# -- arming ---------------------------------------------------------------
|
||||
|
||||
def _start_run_with_recording(self) -> None:
|
||||
"""Arm a streamed recording of the next N measurements, starting the run if stopped.
|
||||
|
||||
``N`` comes from the record-count field; the destination is the shared save
|
||||
path/name. Refuses to start a second recording while one is in progress, and
|
||||
reports a name clash up front (before any data is collected).
|
||||
"""
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
if self._recording_active:
|
||||
self._show_error(
|
||||
"A recording is already in progress; wait for it to finish before starting another",
|
||||
details=f"progress={self._recording_collected_count()}/{self._recording_target}",
|
||||
)
|
||||
return
|
||||
|
||||
target = int(self._record_count.value())
|
||||
if target <= 0:
|
||||
self._show_error("Recording count must be greater than zero")
|
||||
return
|
||||
|
||||
destination = self._snapshot_destination_dir()
|
||||
if destination.exists():
|
||||
self._show_error(
|
||||
"Recording destination already exists; choose a new name or path",
|
||||
details=f"destination={destination}",
|
||||
)
|
||||
return
|
||||
|
||||
writer = None
|
||||
try:
|
||||
writer = self._store.create_snapshot_stream(
|
||||
Path(self._save_path_input.text().strip()).expanduser(),
|
||||
self._save_name_input.text().strip(),
|
||||
name_prefix=self._radar_config_name_prefix(),
|
||||
)
|
||||
# Adjacent config profile, mirroring the manual save (fail if it clashes).
|
||||
self._write_gui_profile_to_path(
|
||||
self._snapshot_config_profile_path(writer.directory), allow_overwrite=False,
|
||||
dataset=self._supervisor.is_running(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if writer is not None:
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(writer.directory)
|
||||
self._show_exception("Failed to start disk recording", exc)
|
||||
return
|
||||
|
||||
self._recording_writer = writer
|
||||
self._recording_target = target
|
||||
self._recording_since_ns = time.monotonic_ns()
|
||||
self._recording_enqueued = 0
|
||||
self._recording_received_count = 0
|
||||
self._recording_write_error = None
|
||||
self._recording_chunk_raw = []
|
||||
self._recording_chunk_pre = []
|
||||
self._recording_chunk_results = []
|
||||
self._recording_queue = queue.Queue(maxsize=_RECORDING_QUEUE_CHUNKS)
|
||||
self._recording_stop = threading.Event()
|
||||
try:
|
||||
self._recording_thread = threading.Thread(
|
||||
target=self._recording_writer_loop,
|
||||
args=(writer, self._recording_queue, self._recording_stop),
|
||||
name="disk-recorder",
|
||||
daemon=False,
|
||||
)
|
||||
self._recording_thread.start()
|
||||
except Exception as exc: # noqa: BLE001 - thread resource exhaustion must reach the operator
|
||||
self._init_recording_state()
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(writer.directory)
|
||||
self._show_exception("Failed to start disk recording", exc)
|
||||
return
|
||||
self._recording_active = True
|
||||
self._recording_collecting = True
|
||||
|
||||
if not self._supervisor.is_running():
|
||||
self._start_run()
|
||||
if not self._supervisor.is_running():
|
||||
# Startup can fail after producing data. Flush it, and release the
|
||||
# destination only when no collection was ever accepted.
|
||||
self._finalize_recording_on_stop()
|
||||
if not self._recording_active and not self._recording_received_count:
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(writer.directory)
|
||||
self._log("Disk recording canceled because acquisition did not start")
|
||||
return
|
||||
try:
|
||||
self._write_gui_profile_to_path(
|
||||
self._snapshot_config_profile_path(writer.directory), dataset=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - keep any accepted data and the initial profile
|
||||
self._finalize_recording_on_stop()
|
||||
self._show_exception("Failed to update disk recording config profile", exc)
|
||||
return
|
||||
|
||||
self._log(f"Disk recording armed: streaming the next {target} measurement(s) to {writer.directory}")
|
||||
|
||||
# -- background writer ----------------------------------------------------
|
||||
|
||||
def _recording_writer_loop(
|
||||
self, writer, work_queue: queue.Queue, stop: threading.Event
|
||||
) -> None:
|
||||
"""Write queued chunks to disk until a sentinel, a stop request, or an error.
|
||||
|
||||
Runs on a worker thread; touches only the writer (file I/O) and logging — never
|
||||
Qt. A write failure is recorded for the GUI thread to surface and ends the loop.
|
||||
"""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
chunk = work_queue.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if chunk is None: # sentinel: target reached, all chunks drained
|
||||
return
|
||||
raw, preprocessed, results = chunk
|
||||
try:
|
||||
writer.append(raw, preprocessed, results)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the GUI thread
|
||||
logger.exception("Disk recording chunk write failed")
|
||||
self._recording_write_error = f"{type(exc).__name__}: {exc}"
|
||||
return
|
||||
|
||||
# -- capture (called from the ring-read hooks on the poll tick) -----------
|
||||
|
||||
def _record_collection(self, stage: str, collection) -> None:
|
||||
"""Save each received stage, retaining raw/preprocessed sweeps without a result."""
|
||||
if not self._recording_collecting:
|
||||
return
|
||||
produced_ns = int(getattr(collection, "monotonic_ns", 0) or 0)
|
||||
if produced_ns and produced_ns < self._recording_since_ns:
|
||||
return
|
||||
|
||||
if stage == "raw":
|
||||
self._recording_chunk_raw.append(collection)
|
||||
elif stage == "preprocessed":
|
||||
self._recording_chunk_pre.append(collection)
|
||||
elif stage == "results":
|
||||
if self._recording_collected_count() >= self._recording_target:
|
||||
return
|
||||
self._recording_chunk_results.append(collection)
|
||||
else:
|
||||
raise ValueError(f"Unknown recording stage: {stage}")
|
||||
self._recording_received_count += 1
|
||||
|
||||
chunk_size = max(len(self._recording_chunk_raw), len(self._recording_chunk_pre), len(self._recording_chunk_results))
|
||||
if chunk_size >= _RECORDING_CHUNK_SIZE and not self._flush_recording_chunk():
|
||||
return
|
||||
if self._recording_collected_count() >= self._recording_target:
|
||||
if self._flush_recording_chunk():
|
||||
self._stop_collecting()
|
||||
|
||||
def _enqueue_recording_work(self, work) -> bool:
|
||||
"""Apply bounded backpressure while checking that the consumer remains healthy."""
|
||||
while self._recording_thread is not None:
|
||||
if self._recording_write_error is not None or not self._recording_thread.is_alive():
|
||||
self._abort_recording_after_write_error()
|
||||
return False
|
||||
try:
|
||||
self._recording_queue.put(work, timeout=0.1)
|
||||
return True
|
||||
except queue.Full:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _flush_recording_chunk(self) -> bool:
|
||||
"""Queue all received stages, including a chunk containing no results yet."""
|
||||
if not (self._recording_chunk_raw or self._recording_chunk_pre or self._recording_chunk_results):
|
||||
return True
|
||||
chunk = (self._recording_chunk_raw, self._recording_chunk_pre, self._recording_chunk_results)
|
||||
if not self._enqueue_recording_work(chunk):
|
||||
return False
|
||||
self._recording_enqueued += len(self._recording_chunk_results)
|
||||
self._recording_chunk_raw = []
|
||||
self._recording_chunk_pre = []
|
||||
self._recording_chunk_results = []
|
||||
return True
|
||||
|
||||
def _stop_collecting(self) -> None:
|
||||
"""Queue an exit sentinel after every accepted chunk, including a full queue."""
|
||||
self._recording_collecting = False
|
||||
self._enqueue_recording_work(None)
|
||||
|
||||
# -- completion / teardown (GUI thread) -----------------------------------
|
||||
|
||||
def _poll_recording_writer(self) -> None:
|
||||
"""Finalize a recording once the writer thread has drained (or failed).
|
||||
|
||||
Called every GUI poll tick. Cheap no-op while idle or still writing.
|
||||
"""
|
||||
if not self._recording_active:
|
||||
return
|
||||
if self._recording_write_error is not None:
|
||||
self._abort_recording_after_write_error()
|
||||
return
|
||||
if not self._recording_collecting and not self._recording_thread.is_alive():
|
||||
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
|
||||
written = self._recording_enqueued
|
||||
self._reset_recording_state()
|
||||
self._log(f"Disk recording complete: {written} measurement(s) written to {directory}")
|
||||
|
||||
def _finalize_recording_on_stop(self) -> None:
|
||||
"""Flush accepted data on Stop and retain the writer until its queue is drained."""
|
||||
if not self._recording_active:
|
||||
return
|
||||
if self._recording_collecting:
|
||||
if not self._flush_recording_chunk():
|
||||
return
|
||||
self._stop_collecting()
|
||||
thread = self._recording_thread
|
||||
if thread is not None:
|
||||
thread.join(timeout=3.0)
|
||||
self._poll_recording_writer()
|
||||
if self._recording_active:
|
||||
self._log("Disk recording is finishing queued writes; the dataset is not complete yet")
|
||||
|
||||
def _abort_recording_after_write_error(self) -> None:
|
||||
"""Surface a writer-thread failure on the GUI thread and disarm."""
|
||||
message = self._recording_write_error or "unknown error"
|
||||
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
|
||||
self._reset_recording_state()
|
||||
self._show_error(
|
||||
"Disk recording failed and was stopped",
|
||||
details=f"dataset={directory}\nerror={message}",
|
||||
)
|
||||
|
||||
def _reset_recording_state(self) -> None:
|
||||
"""Disarm recording, stop the writer thread, and release all buffers."""
|
||||
self._recording_stop.set()
|
||||
if self._recording_queue is not None:
|
||||
with suppress(Exception):
|
||||
self._recording_queue.put_nowait(None)
|
||||
self._recording_active = False
|
||||
self._recording_collecting = False
|
||||
self._recording_writer = None
|
||||
self._recording_queue = None
|
||||
self._recording_thread = None
|
||||
self._recording_write_error = None
|
||||
self._recording_enqueued = 0
|
||||
self._recording_chunk_raw = []
|
||||
self._recording_chunk_pre = []
|
||||
self._recording_chunk_results = []
|
||||
|
||||
def _shutdown_recording(self) -> None:
|
||||
"""Flush on close; a non-daemon writer also drains after the GUI event loop exits."""
|
||||
self._finalize_recording_on_stop()
|
||||
if self._recording_active:
|
||||
# Do not reset a live writer: setting its stop flag would discard its queue.
|
||||
# Python waits for this non-daemon thread during process exit.
|
||||
return
|
||||
self._reset_recording_state()
|
||||
|
||||
# -- status ---------------------------------------------------------------
|
||||
|
||||
def _recording_collected_count(self) -> int:
|
||||
"""Measurements accepted so far (handed to the writer + still buffered)."""
|
||||
return self._recording_enqueued + len(self._recording_chunk_results)
|
||||
|
||||
def _recording_status(self) -> dict:
|
||||
"""A compact snapshot of recording progress for the status feed / web UI."""
|
||||
return {
|
||||
"active": self._recording_active,
|
||||
"collected": self._recording_collected_count(),
|
||||
"target": self._recording_target,
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
"""Snapshot and ring-drain helper mixin for the main GUI window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
|
||||
from python_app.gui.runtime.history import remove_last_aligned_histories
|
||||
from python_app.storage.npz.paths import radar_config_filename_prefix
|
||||
|
||||
|
||||
class AppWindowSnapshotMixin:
|
||||
"""Saves runtime data snapshots and maintains ring-reader freshness."""
|
||||
|
||||
def _radar_config_name_prefix(self) -> str:
|
||||
"""Filename prefix encoding the current radar setup (model, sweep span, points, power).
|
||||
|
||||
Prepended to every saved dataset name so the file records the configuration it was
|
||||
captured with — identical for desktop and web saves, which share these handlers.
|
||||
"""
|
||||
radar = self._build_config().radar
|
||||
return radar_config_filename_prefix(
|
||||
radar.model,
|
||||
radar.sweep.start_hz,
|
||||
radar.sweep.stop_hz,
|
||||
radar.sweep.points,
|
||||
radar.sweep.power_dbm,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_config_profile_path(snapshot_dir: Path) -> Path:
|
||||
"""Return companion config-profile path inside a saved snapshot directory."""
|
||||
return snapshot_dir / "config_profile.json"
|
||||
|
||||
@staticmethod
|
||||
def _vna_json_config_profile_path(output_dir: Path) -> Path:
|
||||
"""Return companion config-profile path for one VNA-history JSON export batch."""
|
||||
return output_dir / "config_profile.json"
|
||||
|
||||
def _save_snapshot(self) -> None:
|
||||
"""Save up to 1000 recent runtime measurements as a numpy snapshot."""
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
self._drain_runtime_rings_for_snapshot()
|
||||
if getattr(self, "_closing", False):
|
||||
return
|
||||
|
||||
if not self._raw_history and not self._pre_history and not self._result_history:
|
||||
self._show_error("No runtime data is available for save", details=self._runtime_history_details())
|
||||
return
|
||||
|
||||
self._write_snapshot_dataset(
|
||||
list(self._raw_history),
|
||||
list(self._pre_history),
|
||||
list(self._result_history),
|
||||
GUI_SAVE_HISTORY_LIMIT,
|
||||
include_unaligned=True,
|
||||
)
|
||||
|
||||
def _snapshot_destination_dir(self) -> Path:
|
||||
"""The directory a save/record would create now, from the current path/name fields.
|
||||
|
||||
Used to surface a name clash *before* doing work (manual save and the disk
|
||||
recorder both create this directory and fail if it already exists).
|
||||
"""
|
||||
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
||||
return self._store.snapshot_directory(
|
||||
output_root,
|
||||
self._save_name_input.text().strip(),
|
||||
name_prefix=self._radar_config_name_prefix(),
|
||||
)
|
||||
|
||||
def _write_snapshot_dataset(
|
||||
self,
|
||||
raw_history: list,
|
||||
preprocessed_history: list,
|
||||
result_history: list,
|
||||
last_n: int,
|
||||
*,
|
||||
include_unaligned: bool = False,
|
||||
) -> None:
|
||||
"""Save the given histories as a numpy snapshot + adjacent config profile.
|
||||
|
||||
Shared by the manual "Save Last 1000" button (which passes the runtime history)
|
||||
and the on-the-fly disk recorder (which passes the freshly recorded buffers),
|
||||
so both produce byte-identical dataset layouts and identical error reporting.
|
||||
"""
|
||||
try:
|
||||
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
||||
snapshot_name = self._save_name_input.text().strip()
|
||||
snapshot_dir, summary = self._store.save_runtime_snapshot_numpy(
|
||||
output_root,
|
||||
snapshot_name,
|
||||
raw_history,
|
||||
preprocessed_history,
|
||||
result_history,
|
||||
last_n,
|
||||
name_prefix=self._radar_config_name_prefix(),
|
||||
include_unaligned=include_unaligned,
|
||||
)
|
||||
config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
|
||||
try:
|
||||
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=False, dataset=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_error(
|
||||
"Snapshot data was saved, but the adjacent config profile could not be written",
|
||||
details=(
|
||||
f"snapshot_dir={snapshot_dir}\n"
|
||||
f"config_profile_path={config_profile_path}\n\n"
|
||||
f"{self._exception_details(exc)}"
|
||||
),
|
||||
)
|
||||
return
|
||||
self._log(
|
||||
f"Saved numpy snapshot: {snapshot_dir} "
|
||||
f"(raw={summary.get('raw_count', 0)}, "
|
||||
f"preprocessed={summary.get('preprocessed_count', 0)}, "
|
||||
f"results={summary.get('result_count', 0)}, "
|
||||
f"mode={summary.get('selection_mode', 'unknown')}, "
|
||||
f"anchor={summary.get('anchor_stage', 'unknown')}, "
|
||||
f"aligned_keys={summary.get('aligned_key_count', 0)}, "
|
||||
f"raw_missing={summary.get('raw_missing_count', 0)}, "
|
||||
f"pre_missing={summary.get('preprocessed_missing_count', 0)}, "
|
||||
f"result_missing={summary.get('result_missing_count', 0)}, "
|
||||
f"requested_last_n={last_n}, "
|
||||
f"config_profile={config_profile_path})"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to save snapshot", exc)
|
||||
|
||||
def _save_vna_history_json(self) -> None:
|
||||
"""Save one VNA-history JSON per available combo in runtime history."""
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
self._drain_runtime_rings_for_snapshot()
|
||||
if getattr(self, "_closing", False):
|
||||
return
|
||||
|
||||
if not self._raw_history and not self._pre_history and not self._result_history:
|
||||
self._show_error("No runtime data is available for save", details=self._runtime_history_details())
|
||||
return
|
||||
|
||||
try:
|
||||
last_n = GUI_SAVE_HISTORY_LIMIT
|
||||
channel = "s21"
|
||||
output_root = Path(self._save_path_input.text().strip()).expanduser()
|
||||
output_name = self._save_name_input.text().strip()
|
||||
output_paths, summary = self._store.save_runtime_vna_history_json_batch(
|
||||
output_root,
|
||||
output_name,
|
||||
list(self._raw_history),
|
||||
list(self._pre_history),
|
||||
list(self._result_history),
|
||||
last_n,
|
||||
channel=channel,
|
||||
primary_stage="preprocessed",
|
||||
name_prefix=self._radar_config_name_prefix(),
|
||||
include_unaligned=True,
|
||||
)
|
||||
output_stem = str(summary.get("output_stem", "")).strip()
|
||||
if not output_stem:
|
||||
raise RuntimeError("VNA history JSON export did not report output_stem for config companion save")
|
||||
output_dir = Path(str(summary.get("output_dir", "")).strip() or output_paths[0].parent)
|
||||
|
||||
config_profile_path = self._vna_json_config_profile_path(output_dir)
|
||||
try:
|
||||
# Snapshot and VNA JSON exports intentionally share the same output directory stem.
|
||||
# Refresh the companion GUI profile in place so a prior dataset save does not block JSON export.
|
||||
self._write_gui_profile_to_path(config_profile_path, allow_overwrite=True, dataset=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
exported_preview = "\n".join(str(path) for path in output_paths[:8])
|
||||
if len(output_paths) > 8:
|
||||
exported_preview += "\n..."
|
||||
self._show_error(
|
||||
"VNA history JSON files were saved, but the adjacent config profile could not be written",
|
||||
details=(
|
||||
f"output_root={output_root}\n"
|
||||
f"output_dir={output_dir}\n"
|
||||
f"config_profile_path={config_profile_path}\n"
|
||||
f"saved_json_files={len(output_paths)}\n"
|
||||
f"{exported_preview}\n\n"
|
||||
f"{self._exception_details(exc)}"
|
||||
),
|
||||
)
|
||||
return
|
||||
combos = summary.get("combos", [])
|
||||
combo_preview = ", ".join(f"in{input_pos}/out{output_pos}" for input_pos, output_pos in combos[:6])
|
||||
if len(combos) > 6:
|
||||
combo_preview += ", ..."
|
||||
self._log(
|
||||
f"Saved VNA history JSON batch: files={len(output_paths)} "
|
||||
f"(combos={summary.get('combo_count', 0)}"
|
||||
f"{', ' + combo_preview if combo_preview else ''}, "
|
||||
f"preprocessed_records={summary.get('preprocessed_record_count', 0)}, "
|
||||
f"raw={summary.get('raw_count', 0)}, "
|
||||
f"preprocessed={summary.get('preprocessed_count', 0)}, "
|
||||
f"results={summary.get('result_count', 0)}, "
|
||||
f"mode={summary.get('selection_mode', 'unknown')}, "
|
||||
f"anchor={summary.get('anchor_stage', 'unknown')}, "
|
||||
f"channel={channel}, "
|
||||
f"requested_last_n={last_n}, "
|
||||
f"config_profile={config_profile_path})"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to save VNA history JSON", exc)
|
||||
|
||||
def _remove_last_runtime_history(self) -> None:
|
||||
"""Remove the newest runtime measurement from all stages and processor replay state."""
|
||||
self._apply_runtime_history_deletion(remove_last_only=True)
|
||||
|
||||
def _clear_all_runtime_history(self) -> None:
|
||||
"""Clear all runtime histories, ring backlogs, and processor replay state."""
|
||||
self._apply_runtime_history_deletion(remove_last_only=False)
|
||||
|
||||
def _apply_runtime_history_deletion(self, *, remove_last_only: bool) -> None:
|
||||
"""Apply destructive runtime-history deletion across readers, caches, and processor replay state."""
|
||||
if getattr(self, "_object_capture_job", None) is not None:
|
||||
self._show_error("Wait for object-calibration capture to finish")
|
||||
return
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Cannot modify runtime history during active capture sequence",
|
||||
details=f"{self._capture_state_details()}\n\n{self._runtime_history_details()}",
|
||||
)
|
||||
return
|
||||
|
||||
resume_acquisition = self._supervisor.is_running()
|
||||
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
dropped_raw = 0
|
||||
dropped_pre = 0
|
||||
dropped_results = 0
|
||||
history_command = "remove_last" if remove_last_only else "clear_all"
|
||||
action_label = "last runtime measurement removed" if remove_last_only else "runtime history fully cleared"
|
||||
error_action = "remove last runtime measurement" if remove_last_only else "clear runtime history"
|
||||
|
||||
try:
|
||||
if resume_acquisition:
|
||||
self._stop_run()
|
||||
if not self._pipeline_resume_allowed(resume_seq):
|
||||
return
|
||||
|
||||
dropped_raw = self._raw_reader.drop_all() if self._raw_reader is not None else 0
|
||||
dropped_pre = self._pre_reader.drop_all() if self._pre_reader is not None else 0
|
||||
dropped_results = self._result_reader.drop_all() if self._result_reader is not None else 0
|
||||
|
||||
if remove_last_only:
|
||||
retained_raw, retained_pre, retained_result = remove_last_aligned_histories(
|
||||
list(self._raw_history),
|
||||
list(self._pre_history),
|
||||
list(self._result_history),
|
||||
)
|
||||
else:
|
||||
retained_raw = []
|
||||
retained_pre = []
|
||||
retained_result = []
|
||||
|
||||
self._replace_runtime_history(
|
||||
retained_raw=retained_raw,
|
||||
retained_pre=retained_pre,
|
||||
retained_result=retained_result,
|
||||
)
|
||||
self._discard_bscan_replay_results()
|
||||
self._clear_history_mode_caches()
|
||||
|
||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||
|
||||
if self._supervisor.is_processor_running():
|
||||
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
|
||||
if self._result_reader is not None:
|
||||
dropped_results += self._result_reader.drop_all()
|
||||
|
||||
self._update_history_indicator()
|
||||
self._redraw_after_history_deletion()
|
||||
self._log(
|
||||
f"{action_label}: "
|
||||
f"dropped raw={dropped_raw}, "
|
||||
f"preprocessed={dropped_pre}, "
|
||||
f"results={dropped_results}"
|
||||
)
|
||||
|
||||
if resume_acquisition and self._pipeline_resume_allowed(resume_seq):
|
||||
self._start_run()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception(f"Failed to {error_action}", exc)
|
||||
|
||||
def _browse_save_path(self) -> None:
|
||||
"""Open directory picker for snapshot output path."""
|
||||
selected = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Snapshot Directory",
|
||||
self._save_path_input.text().strip() or str(self._project_root),
|
||||
)
|
||||
if selected:
|
||||
self._save_path_input.setText(selected)
|
||||
|
||||
def _drain_runtime_rings_for_snapshot(self) -> None:
|
||||
"""Drain readers before snapshot to reduce partial-history races."""
|
||||
if self._raw_reader is None and self._pre_reader is None and self._result_reader is None:
|
||||
return
|
||||
try:
|
||||
readers = (self._raw_reader, self._pre_reader, self._result_reader)
|
||||
observed = [False, False, False]
|
||||
deadline = time.monotonic() + 1.2
|
||||
|
||||
while time.monotonic() < deadline and not getattr(self, "_closing", False):
|
||||
limit = self._max_pop_per_snapshot_drain
|
||||
raw = self._read_all_raw(max_collections=limit) if self._raw_reader is not None else None
|
||||
preprocessed = self._read_all_preprocessed(max_collections=limit)
|
||||
result = self._read_all_results(max_collections=limit) if self._result_reader is not None else None
|
||||
latest = (raw, preprocessed, result)
|
||||
observed = [seen or collection is not None for seen, collection in zip(observed, latest)]
|
||||
|
||||
if any(collection is not None for collection in latest):
|
||||
continue
|
||||
|
||||
# Event-loop-friendly waits so the keepalive/watchdog timers and
|
||||
# queued signals keep firing instead of freezing the headless daemon.
|
||||
missing_stage = any(reader is not None and not seen for reader, seen in zip(readers, observed))
|
||||
awaiting_results = self._result_reader is not None and (
|
||||
max(len(self._raw_history), len(self._pre_history)) > len(self._result_history)
|
||||
)
|
||||
if (any(observed) and missing_stage) or awaiting_results:
|
||||
self._pump_events_during_drain(0.01)
|
||||
continue
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Snapshot drain warning", exc, level="WARN")
|
||||
|
||||
def _drop_pending_ring_payloads(self, *, include_results: bool = True) -> None:
|
||||
"""Drop unread payloads from active readers."""
|
||||
dropped_raw = self._raw_reader.drop_all() if self._raw_reader is not None else 0
|
||||
dropped_pre = self._pre_reader.drop_all() if self._pre_reader is not None else 0
|
||||
dropped_results = 0
|
||||
if include_results and self._result_reader is not None:
|
||||
dropped_results = self._result_reader.drop_all()
|
||||
|
||||
if dropped_raw or dropped_pre or dropped_results:
|
||||
self._log(
|
||||
"Single capture ring reset: "
|
||||
f"dropped raw={dropped_raw}, "
|
||||
f"preprocessed={dropped_pre}, "
|
||||
f"results={dropped_results}"
|
||||
)
|
||||
@@ -1,338 +0,0 @@
|
||||
"""UI construction mixin for the main radar control window.
|
||||
|
||||
Layout is intentionally split into two independent plot surfaces:
|
||||
- single `PlotWidget` for B-scan heatmap rendering;
|
||||
- single `PlotWidget` for GPR accumulator/annotation rendering;
|
||||
- stacked magnitude/phase `PlotWidget`s for pass-through traces.
|
||||
|
||||
`_set_plot_mode()` switches between these surfaces via `QStackedWidget`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import SAR3D_VIEWS
|
||||
from python_app.gui.controllers.sections import (
|
||||
build_data_actions_group,
|
||||
build_primary_actions_group,
|
||||
build_preprocess_summary_group,
|
||||
build_processing_group,
|
||||
build_radar_group,
|
||||
build_switch_group,
|
||||
)
|
||||
from python_app.logging_setup import LOG_LEVELS
|
||||
|
||||
|
||||
class AppWindowUiMixin:
|
||||
"""Build and wire all static widgets used by :class:`AppWindow`.
|
||||
|
||||
This mixin only creates UI objects and connects lightweight UI signals.
|
||||
Runtime logic (pipeline start/stop, polling, drawing, snapshot saving)
|
||||
is implemented in other mixins.
|
||||
"""
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
"""Build complete main-window layout.
|
||||
|
||||
High-level structure:
|
||||
1. Left: plot stack (`B-scan` page + `trace magnitude/phase` page).
|
||||
2. Middle: narrow toggle button to collapse/expand settings.
|
||||
3. Right: scrollable settings panel + status/history + runtime log.
|
||||
"""
|
||||
self.setWindowTitle("Radar System Control")
|
||||
root = QWidget(self)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
layout = QHBoxLayout(root)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(14)
|
||||
|
||||
self._build_plot_area(layout, root)
|
||||
self._build_settings_toggle(layout)
|
||||
self._build_settings_panel(layout, root)
|
||||
|
||||
self._toggle_settings_panel(visible=True)
|
||||
self.resize(1650, 940)
|
||||
|
||||
def _build_plot_area(self, root_layout: QHBoxLayout, root: QWidget) -> None:
|
||||
"""Build left plot area with stacked B-scan and trace pages."""
|
||||
# Plot stack holds mutually exclusive visualization surfaces.
|
||||
# We create both upfront and only switch active page at runtime.
|
||||
self._plot_stack = QStackedWidget(root)
|
||||
self._build_bscan_plot_page()
|
||||
self._build_gpr_plot_page()
|
||||
self._build_sar3d_plot_page()
|
||||
self._build_trace_plot_page()
|
||||
|
||||
# Default view on startup is pass-through traces.
|
||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||
root_layout.addWidget(self._plot_stack, stretch=11)
|
||||
|
||||
@staticmethod
|
||||
def _create_plot_widget(*, background: str) -> pg.PlotWidget:
|
||||
"""Create PlotWidget with pyqtgraph context menu disabled for PyQt6 compatibility."""
|
||||
return pg.PlotWidget(background=background, enableMenu=False)
|
||||
|
||||
def _build_bscan_plot_page(self) -> None:
|
||||
"""Create B-scan page in plot stack."""
|
||||
# B-scan surface: one PlotWidget used as canvas for ImageItem heatmap.
|
||||
self._bscan_plot = self._create_plot_widget(background="#0f141c")
|
||||
self._bscan_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._configure_bscan_plot_axes()
|
||||
self._plot_stack.addWidget(self._bscan_plot)
|
||||
|
||||
def _build_trace_plot_page(self) -> None:
|
||||
"""Create pass-through page with magnitude and phase plots."""
|
||||
# Pass-through surface: container with two synchronized line plots.
|
||||
# Upper plot shows magnitude, lower plot shows phase.
|
||||
self._trace_plots_container = QWidget()
|
||||
trace_layout = QVBoxLayout(self._trace_plots_container)
|
||||
trace_layout.setContentsMargins(0, 0, 0, 0)
|
||||
trace_layout.setSpacing(6)
|
||||
|
||||
self._trace_magnitude_plot = self._create_plot_widget(background="#0f141c")
|
||||
self._trace_magnitude_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._trace_magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||
self._trace_magnitude_plot.getPlotItem().showAxis("bottom", show=False)
|
||||
self._trace_magnitude_plot.getPlotItem().setDownsampling(mode="peak")
|
||||
self._trace_magnitude_plot.getPlotItem().setClipToView(True)
|
||||
trace_layout.addWidget(self._trace_magnitude_plot, stretch=1)
|
||||
|
||||
self._trace_phase_plot = self._create_plot_widget(background="#0f141c")
|
||||
self._trace_phase_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._trace_phase_plot.setLabel("left", "Phase", units="deg")
|
||||
self._trace_phase_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||
self._trace_phase_plot.getPlotItem().setDownsampling(mode="peak")
|
||||
self._trace_phase_plot.getPlotItem().setClipToView(True)
|
||||
trace_layout.addWidget(self._trace_phase_plot, stretch=1)
|
||||
|
||||
# Legends are created lazily only when trace mode draws line series.
|
||||
self._trace_magnitude_legend = None
|
||||
self._trace_phase_legend = None
|
||||
self._trace_magnitude_legend_combo_keys = set()
|
||||
self._trace_phase_legend_combo_keys = set()
|
||||
self._trace_magnitude_curves = {}
|
||||
self._trace_phase_curves = {}
|
||||
# Cap rendered points per trace (magnitude and phase alike): beyond ~1
|
||||
# point/pixel there is no visual gain, and decimation keeps pass-through
|
||||
# responsive with many combos on screen.
|
||||
self._trace_render_max_points = 800
|
||||
|
||||
self._plot_stack.addWidget(self._trace_plots_container)
|
||||
|
||||
def _build_gpr_plot_page(self) -> None:
|
||||
"""Create GPR page in plot stack."""
|
||||
self._gpr_plot = self._create_plot_widget(background="#0f141c")
|
||||
self._gpr_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._configure_gpr_plot_axes()
|
||||
self._plot_stack.addWidget(self._gpr_plot)
|
||||
|
||||
def _build_sar3d_plot_page(self) -> None:
|
||||
"""Create the 3D SAR page: front, side, top and 3D views in a 2x2 grid."""
|
||||
self._sar3d_plots_container = QWidget()
|
||||
layout = QVBoxLayout(self._sar3d_plots_container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
self._sar3d_status_label = QLabel()
|
||||
self._sar3d_status_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self._sar3d_status_label.setWordWrap(True)
|
||||
layout.addWidget(self._sar3d_status_label)
|
||||
self._sar3d_plot_grid = QWidget()
|
||||
grid = QGridLayout(self._sar3d_plot_grid)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setSpacing(6)
|
||||
layout.addWidget(self._sar3d_plot_grid, stretch=1)
|
||||
self._sar3d_plots = {}
|
||||
for index, name in enumerate(SAR3D_VIEWS):
|
||||
self._sar3d_plots[name] = self._create_plot_widget(background="#0f141c")
|
||||
grid.addWidget(self._sar3d_plots[name], index // 2, index % 2)
|
||||
self._init_sar3d_views()
|
||||
self._clear_sar3d_plot()
|
||||
self._plot_stack.addWidget(self._sar3d_plots_container)
|
||||
|
||||
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
|
||||
"""Create narrow button used to collapse or show settings panel."""
|
||||
self._settings_toggle_button = QPushButton("<")
|
||||
self._settings_toggle_button.setObjectName("settingsToggleButton")
|
||||
self._settings_toggle_button.setFixedWidth(26)
|
||||
self._settings_toggle_button.clicked.connect(lambda: self._toggle_settings_panel())
|
||||
root_layout.addWidget(self._settings_toggle_button, stretch=0)
|
||||
|
||||
def _build_settings_panel(self, root_layout: QHBoxLayout, root: QWidget) -> None:
|
||||
"""Build right settings panel with controls, history summary, and log."""
|
||||
self._settings_panel = QWidget(root)
|
||||
self._settings_panel.setMinimumWidth(610)
|
||||
right_layout = QVBoxLayout(self._settings_panel)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(10)
|
||||
|
||||
# Build log early so `_show_error()` can append text even during
|
||||
# subsequent group construction if something fails.
|
||||
self._log_panel = self._build_log_panel()
|
||||
|
||||
right_layout.addWidget(build_primary_actions_group(self), stretch=0)
|
||||
right_layout.addWidget(self._build_settings_scroll(), stretch=1)
|
||||
|
||||
self._status_label = QLabel("Status: idle", self._settings_panel)
|
||||
self._status_label.setObjectName("statusLabel")
|
||||
right_layout.addWidget(self._status_label)
|
||||
|
||||
self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
|
||||
self._history_label.setObjectName("hintLabel")
|
||||
right_layout.addWidget(self._history_label)
|
||||
|
||||
right_layout.addWidget(self._log_panel, stretch=0)
|
||||
root_layout.addWidget(self._settings_panel, stretch=7)
|
||||
|
||||
def _build_log_panel(self) -> QWidget:
|
||||
"""Build runtime log block with collapsible log body."""
|
||||
self._log_panel_title = QLabel("Runtime Log", self._settings_panel)
|
||||
self._log_panel_title.setObjectName("hintLabel")
|
||||
|
||||
self._log_toggle_button = QPushButton(self._settings_panel)
|
||||
self._log_toggle_button.setObjectName("sectionToggleButton")
|
||||
self._log_toggle_button.clicked.connect(lambda: self._toggle_log_panel())
|
||||
|
||||
# Log-level selector: live verbosity control, persisted to run_config.
|
||||
self._log_level_label = QLabel("Level", self._settings_panel)
|
||||
self._log_level_label.setObjectName("hintLabel")
|
||||
self._log_level_combo = QComboBox(self._settings_panel)
|
||||
self._log_level_combo.setObjectName("logLevelCombo")
|
||||
self._log_level_combo.addItems([name.capitalize() for name in LOG_LEVELS])
|
||||
self._log_level_combo.setToolTip("Logging verbosity — applied live and saved to run_config")
|
||||
current_level = self._defaults_config.logging.level.capitalize()
|
||||
current_index = self._log_level_combo.findText(current_level)
|
||||
if current_index >= 0:
|
||||
self._log_level_combo.setCurrentIndex(current_index)
|
||||
# Connect AFTER seeding the value so reflecting the config does not save it back.
|
||||
self._log_level_combo.currentTextChanged.connect(self._on_log_level_selected)
|
||||
|
||||
self._log_box = QTextEdit(self._settings_panel)
|
||||
self._log_box.setObjectName("runtimeLogBox")
|
||||
self._log_box.setReadOnly(True)
|
||||
self._log_box.setUndoRedoEnabled(False)
|
||||
self._log_box.setMinimumHeight(170)
|
||||
self._log_box.document().setMaximumBlockCount(1200)
|
||||
|
||||
panel = QWidget(self._settings_panel)
|
||||
panel_layout = QVBoxLayout(panel)
|
||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||||
panel_layout.setSpacing(6)
|
||||
|
||||
header_row = QWidget(panel)
|
||||
header_layout = QHBoxLayout(header_row)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
header_layout.setSpacing(8)
|
||||
header_layout.addWidget(self._log_panel_title)
|
||||
header_layout.addStretch(1)
|
||||
header_layout.addWidget(self._log_level_label)
|
||||
header_layout.addWidget(self._log_level_combo)
|
||||
header_layout.addWidget(self._log_toggle_button)
|
||||
|
||||
panel_layout.addWidget(header_row)
|
||||
panel_layout.addWidget(self._log_box)
|
||||
|
||||
self._toggle_log_panel(visible=True)
|
||||
return panel
|
||||
|
||||
def _build_settings_scroll(self) -> QScrollArea:
|
||||
"""Build scroll area with all control groups in display order."""
|
||||
# All control groups are placed into a scroll area so right panel
|
||||
# remains usable on smaller screens and with future extra controls.
|
||||
controls = QWidget(self._settings_panel)
|
||||
controls_layout = QVBoxLayout(controls)
|
||||
controls_layout.setContentsMargins(0, 0, 12, 0)
|
||||
controls_layout.setSpacing(10)
|
||||
for group in self._build_control_groups():
|
||||
controls_layout.addWidget(group)
|
||||
controls_layout.addStretch(1)
|
||||
|
||||
scroll = QScrollArea(self._settings_panel)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setWidget(controls)
|
||||
return scroll
|
||||
|
||||
def _build_control_groups(self) -> list[QGroupBox]:
|
||||
"""Create all settings groups in top-to-bottom order."""
|
||||
return [
|
||||
build_data_actions_group(self),
|
||||
build_switch_group(self),
|
||||
build_preprocess_summary_group(self),
|
||||
build_processing_group(self),
|
||||
build_radar_group(self),
|
||||
]
|
||||
|
||||
def _toggle_settings_panel(self, *, visible: bool | None = None) -> None:
|
||||
"""Toggle settings panel visibility or force a specific state.
|
||||
|
||||
When `visible` is `None`, state is toggled.
|
||||
When `visible` is set, panel visibility is forced to that value.
|
||||
"""
|
||||
if visible is None:
|
||||
visible = not self._settings_panel.isVisible()
|
||||
self._settings_panel.setVisible(visible)
|
||||
if visible:
|
||||
self._settings_toggle_button.setText(">")
|
||||
self._settings_toggle_button.setToolTip("Hide settings panel")
|
||||
else:
|
||||
self._settings_toggle_button.setText("<")
|
||||
self._settings_toggle_button.setToolTip("Show settings panel")
|
||||
|
||||
def _toggle_log_panel(self, *, visible: bool | None = None) -> None:
|
||||
"""Toggle runtime log body visibility or force a specific state."""
|
||||
if visible is None:
|
||||
visible = not self._log_box.isVisible()
|
||||
|
||||
self._log_box.setVisible(visible)
|
||||
if visible:
|
||||
self._log_toggle_button.setText("Collapse")
|
||||
self._log_toggle_button.setToolTip("Hide runtime log entries")
|
||||
else:
|
||||
self._log_toggle_button.setText("Expand")
|
||||
self._log_toggle_button.setToolTip("Show runtime log entries")
|
||||
|
||||
def _set_plot_mode(self, mode: str) -> None:
|
||||
"""Switch visible plot page according to processing mode.
|
||||
|
||||
`bscan` -> show `self._bscan_plot`
|
||||
`gpr` and `legacy_gpr` -> show `self._gpr_plot`
|
||||
`sar3d` -> show `self._sar3d_plots_container`
|
||||
otherwise -> show `self._trace_plots_container`
|
||||
"""
|
||||
if mode == "bscan":
|
||||
self._plot_stack.setCurrentWidget(self._bscan_plot)
|
||||
return
|
||||
if mode in {"gpr", "legacy_gpr"}:
|
||||
self._plot_stack.setCurrentWidget(self._gpr_plot)
|
||||
return
|
||||
if mode == "sar3d":
|
||||
self._plot_stack.setCurrentWidget(self._sar3d_plots_container)
|
||||
return
|
||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||
|
||||
@staticmethod
|
||||
def _set_combo_current_text(combo: QComboBox, value: str) -> None:
|
||||
"""Select combo item by text, appending it when missing."""
|
||||
index = combo.findText(value)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
return
|
||||
combo.addItem(value)
|
||||
combo.setCurrentIndex(combo.count() - 1)
|
||||
@@ -1,503 +0,0 @@
|
||||
"""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 os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||
|
||||
from python_app.gui.controllers.app_window_config.live_processing_mixin import web_apply_field_names
|
||||
from python_app.webui.controller import WebActionError
|
||||
|
||||
_WEBUI_PORT_ENV = "RADAR_SYSTEM_WEBUI_PORT"
|
||||
_DEFAULT_PORT = 8080
|
||||
# Upper bound on how long a browser control call waits for the GUI thread to run and
|
||||
# report the action. Comfortably above a real save/start, but bounded so a wedged GUI
|
||||
# thread surfaces as an error instead of hanging the HTTP worker forever.
|
||||
_WEB_ACTION_TIMEOUT_S = 30.0
|
||||
|
||||
|
||||
class _WebActionCall:
|
||||
"""One synchronous web action: the GUI thread fills the result, the web thread waits.
|
||||
|
||||
The web (uvicorn) thread emits a control signal carrying this object and blocks on
|
||||
:attr:`done`; the GUI thread runs the desktop action, records any surfaced error in
|
||||
:attr:`error`, and sets the event. This turns the fire-and-forget signal bridge into
|
||||
a request/response so failures reach the browser.
|
||||
"""
|
||||
|
||||
__slots__ = ("done", "error", "_lock", "_started")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.done = threading.Event()
|
||||
self.error: str | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._started = False
|
||||
|
||||
def begin(self) -> bool:
|
||||
with self._lock:
|
||||
if self.done.is_set() or self._started:
|
||||
return False
|
||||
self._started = True
|
||||
return True
|
||||
|
||||
def finish(self, error: str | None = None) -> None:
|
||||
with self._lock:
|
||||
if not self.done.is_set():
|
||||
self.error = error
|
||||
self.done.set()
|
||||
|
||||
def cancel(self, error: str, *, queued_only: bool = True) -> bool:
|
||||
with self._lock:
|
||||
if self.done.is_set():
|
||||
return True
|
||||
if queued_only and self._started:
|
||||
return False
|
||||
self.error = error
|
||||
self.done.set()
|
||||
return True
|
||||
# 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
|
||||
# Field names a web client may apply: the form's live/display 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()
|
||||
|
||||
|
||||
def _safe_run_config_path(run_configs_dir: Path, name: str) -> Path | None:
|
||||
"""Resolve a client-supplied config name to a file strictly inside ``run_configs_dir``.
|
||||
|
||||
The web sends only a bare file name; this is the trust boundary. The name must be a
|
||||
plain ``.json`` file living directly in the directory — ``name != Path(name).name``
|
||||
rejects any separator or ``..`` traversal. Returns the path, or ``None`` if invalid.
|
||||
"""
|
||||
if not name or name != Path(name).name or not name.lower().endswith(".json"):
|
||||
return None
|
||||
candidate = run_configs_dir / name
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def _list_run_config_names(run_configs_dir: Path) -> list[str]:
|
||||
"""Return the sorted ``.json`` file names directly inside ``run_configs_dir``."""
|
||||
if not run_configs_dir.is_dir():
|
||||
return []
|
||||
return sorted(entry.name for entry in run_configs_dir.glob("*.json") if entry.is_file())
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
# Control signals carry a trailing _WebActionCall the GUI slot fills in, so the web
|
||||
# thread can block on the real outcome and receive the updated state.
|
||||
start_requested = pyqtSignal(object)
|
||||
stop_requested = pyqtSignal(object)
|
||||
remove_last_requested = pyqtSignal(object)
|
||||
single_capture_requested = pyqtSignal(object)
|
||||
capture_requested = pyqtSignal(object)
|
||||
start_recording_requested = pyqtSignal(str, str, int, object)
|
||||
load_config_requested = pyqtSignal(str, object)
|
||||
save_dataset_requested = pyqtSignal(str, str, object)
|
||||
select_combo_requested = pyqtSignal(int, int, object)
|
||||
next_combo_requested = pyqtSignal(object)
|
||||
apply_settings_requested = pyqtSignal(dict, object)
|
||||
|
||||
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._run_configs_dir = run_configs_dir
|
||||
self._status: dict = {}
|
||||
self._live_settings: list = []
|
||||
self._frame: dict | None = None
|
||||
self._pending_lock = threading.Lock()
|
||||
self._pending_calls: set[_WebActionCall] = set()
|
||||
self._closed = False
|
||||
|
||||
# -- Snapshot refresh (Qt main thread) -----------------------------------
|
||||
|
||||
def update_snapshot(self, *, status: dict, live_settings: list, 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 list_configs(self) -> list[str]:
|
||||
"""Return the run-config file names available to load (a plain directory scan)."""
|
||||
return _list_run_config_names(self._run_configs_dir)
|
||||
|
||||
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 _dispatch(self, signal, *args) -> None:
|
||||
"""Emit a control signal and block until the GUI thread reports the outcome.
|
||||
|
||||
Runs on the web worker thread (the routes call this via a thread pool, so the
|
||||
event loop is never blocked). Raises :class:`WebActionError` if the desktop
|
||||
action surfaced an error or did not finish within the timeout.
|
||||
"""
|
||||
call = _WebActionCall()
|
||||
with self._pending_lock:
|
||||
if self._closed:
|
||||
raise WebActionError("The application is closing")
|
||||
self._pending_calls.add(call)
|
||||
try:
|
||||
signal.emit(*args, call)
|
||||
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
|
||||
if not call.cancel("The desktop did not start the action in time; the request was cancelled"):
|
||||
raise WebActionError("The desktop action is still running; check its status before retrying")
|
||||
if call.error is not None:
|
||||
raise WebActionError(call.error)
|
||||
finally:
|
||||
with self._pending_lock:
|
||||
self._pending_calls.discard(call)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Release waiting HTTP workers and reject queued controls during teardown."""
|
||||
with self._pending_lock:
|
||||
self._closed = True
|
||||
pending = tuple(self._pending_calls)
|
||||
for call in pending:
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
|
||||
def start(self) -> None:
|
||||
self._dispatch(self.start_requested)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._dispatch(self.stop_requested)
|
||||
|
||||
def single_capture(self) -> None:
|
||||
self._dispatch(self.single_capture_requested)
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
self._dispatch(self.capture_requested)
|
||||
|
||||
def remove_last_measurement(self) -> None:
|
||||
self._dispatch(self.remove_last_requested)
|
||||
|
||||
def start_recording(self, path: str, name: str, count: int) -> None:
|
||||
"""Arm a run + disk recording of the next ``count`` measurements (the desktop button)."""
|
||||
if type(count) is not int or count < 1:
|
||||
raise ValueError("Recording count must be a positive integer")
|
||||
self._dispatch(self.start_recording_requested, path, name, count)
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> list:
|
||||
unknown = set(fields) - _LIVE_FIELD_NAMES
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
|
||||
self._dispatch(self.apply_settings_requested, dict(fields))
|
||||
return self.current_live_settings()
|
||||
|
||||
def load_config(self, name: str) -> None:
|
||||
"""Request loading the run-config named ``name`` (the desktop "Load Config" action).
|
||||
|
||||
Validates the name against the directory here — on the web thread — so an invalid
|
||||
or unsafe name fails the HTTP request immediately; the load itself then runs
|
||||
synchronously on the Qt side and any load error is surfaced too.
|
||||
"""
|
||||
if _safe_run_config_path(self._run_configs_dir, name) is None:
|
||||
raise ValueError(f"Unknown run config: {name}")
|
||||
self._dispatch(self.load_config_requested, name)
|
||||
|
||||
def save_dataset(self, path: str, name: str) -> None:
|
||||
"""Save the last 1000 runtime measurements to ``path``/``name``."""
|
||||
self._dispatch(self.save_dataset_requested, path, name)
|
||||
|
||||
def select_combo(self, input_pos: int, output_pos: int) -> None:
|
||||
"""Select the matrix B-scan channel through the desktop display controls."""
|
||||
if any(type(pos) is not int or pos < 0 for pos in (input_pos, output_pos)):
|
||||
raise ValueError("Channel positions must be nonnegative integers")
|
||||
self._dispatch(self.select_combo_requested, input_pos, output_pos)
|
||||
|
||||
def next_combo(self) -> None:
|
||||
"""Advance the matrix B-scan display to the next configured channel pair."""
|
||||
self._dispatch(self.next_combo_requested)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# The web picker browses this directory; ensure it exists on fresh deploys.
|
||||
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Capture slot for errors a web-triggered action surfaces (None = no web
|
||||
# action in flight). Read default-safe by `_show_error`/`_show_exception`.
|
||||
self._web_action_error_capture: list[str] | None = None
|
||||
|
||||
controller = AppWindowWebController(self._run_configs_dir, parent=self)
|
||||
# Each control signal carries a _WebActionCall the wrapper finalizes, so the
|
||||
# browser learns whether the desktop action actually succeeded.
|
||||
controller.start_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._start_run)
|
||||
)
|
||||
controller.stop_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._stop_run)
|
||||
)
|
||||
controller.single_capture_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._start_single_capture)
|
||||
)
|
||||
controller.capture_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._capture_tmp_reference)
|
||||
)
|
||||
controller.remove_last_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._remove_last_runtime_history)
|
||||
)
|
||||
controller.start_recording_requested.connect(
|
||||
lambda path, name, count, call: self._run_web_action(
|
||||
call, self._start_web_recording, path, name, count
|
||||
)
|
||||
)
|
||||
controller.load_config_requested.connect(
|
||||
lambda name, call: self._run_web_action(call, self._load_web_config, name)
|
||||
)
|
||||
controller.save_dataset_requested.connect(
|
||||
lambda path, name, call: self._run_web_action(call, self._save_web_dataset, path, name)
|
||||
)
|
||||
controller.select_combo_requested.connect(
|
||||
lambda inp, out, call: self._run_web_action(call, self._select_display_combo, inp, out)
|
||||
)
|
||||
controller.next_combo_requested.connect(
|
||||
lambda call: self._run_web_action(call, self._select_next_combo)
|
||||
)
|
||||
controller.apply_settings_requested.connect(
|
||||
lambda fields, call: self._run_web_action(call, self._apply_web_live_settings, fields)
|
||||
)
|
||||
|
||||
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 _run_web_action(self, call: _WebActionCall, action, *args) -> None:
|
||||
"""Run a web-triggered desktop action on the GUI thread, capturing its outcome.
|
||||
|
||||
Errors the action reports through ``_show_error``/``_show_exception`` are
|
||||
captured into the call (and still logged/shown on the desktop) instead of
|
||||
vanishing from the browser's view. Re-entrancy-safe: a nested action — e.g. a
|
||||
modal error dialog pumping the event loop in GUI mode — saves and restores the
|
||||
capture slot, so each action only sees its own first error.
|
||||
"""
|
||||
if getattr(self, "_closing", False):
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
return
|
||||
if not call.begin():
|
||||
return
|
||||
previous_capture = self._web_action_error_capture
|
||||
capture: list[str] = []
|
||||
self._web_action_error_capture = capture
|
||||
error = None
|
||||
try:
|
||||
action(*args)
|
||||
error = capture[0] if capture else None
|
||||
self._web_update_snapshot()
|
||||
except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop
|
||||
error = self._exception_summary(exc)
|
||||
finally:
|
||||
self._web_action_error_capture = previous_capture
|
||||
call.finish(error)
|
||||
|
||||
def _load_web_config(self, name: str) -> None:
|
||||
"""Load a run config chosen in the browser through the shared desktop load path.
|
||||
|
||||
Runs on the Qt main thread (queued from the web thread). The name is re-resolved
|
||||
against the directory here as the authoritative trust boundary before any file
|
||||
access, then handed to the same loader the desktop "Load Config" button uses.
|
||||
"""
|
||||
config_path = _safe_run_config_path(self._run_configs_dir, name)
|
||||
if config_path is None:
|
||||
raise ValueError(f"Unknown run config: {name}")
|
||||
self._load_config_from_path(config_path)
|
||||
|
||||
def _save_web_dataset(self, path: str, name: str) -> None:
|
||||
"""Save the last 1000 measurements via the shared desktop handler.
|
||||
|
||||
The web fields are remote editors of the shared save-path/name widgets: the name
|
||||
is mirrored as-is (blank is valid — it yields a timestamped file), while a blank
|
||||
path is ignored to avoid wiping the configured destination. The save itself — and
|
||||
the radar-config filename prefix — is the unchanged desktop action.
|
||||
"""
|
||||
if path.strip():
|
||||
self._save_path_input.setText(path)
|
||||
self._save_name_input.setText(name)
|
||||
self._save_snapshot()
|
||||
|
||||
def _start_web_recording(self, path: str, name: str, count: int) -> None:
|
||||
"""Arm disk recording from the browser via the same handler as the desktop button.
|
||||
|
||||
The save path/name fields are mirrored exactly like ``_save_web_dataset`` (blank
|
||||
path keeps the configured destination), the record count is applied to the shared
|
||||
spinbox, then the unchanged desktop arming action runs.
|
||||
"""
|
||||
if type(count) is not int or not self._record_count.minimum() <= count <= self._record_count.maximum():
|
||||
raise ValueError(
|
||||
f"Recording count must be an integer from {self._record_count.minimum()} "
|
||||
f"to {self._record_count.maximum()}"
|
||||
)
|
||||
if path.strip():
|
||||
self._save_path_input.setText(path)
|
||||
self._save_name_input.setText(name)
|
||||
self._record_count.setValue(count)
|
||||
self._start_run_with_recording()
|
||||
|
||||
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,
|
||||
# File name of the currently loaded config profile, so the web picker
|
||||
# can show what is active (the desktop tracks the full path).
|
||||
"active_config": self._active_profile_path.name,
|
||||
# Current save path/name, so the web fields can prefill the desktop values.
|
||||
"save_path": self._save_path_input.text(),
|
||||
"save_name": self._save_name_input.text(),
|
||||
# Default record count + live disk-recording progress for the web UI.
|
||||
"record_count": int(self._record_count.value()),
|
||||
"recording": self._recording_status(),
|
||||
# Per-stage capture counts, identical to the desktop history
|
||||
# label (raw -> preprocessed -> results), mirrored to the browser.
|
||||
"raw_count": len(self._raw_history),
|
||||
"preprocessed_count": len(self._pre_history),
|
||||
"result_count": len(self._result_history),
|
||||
"sar3d_status": self._sar3d_status_label.text()
|
||||
if self._processing_mode.currentText() == "sar3d" else "",
|
||||
"display_combo": {
|
||||
"enabled": self._is_matrix_radar_model_selected()
|
||||
and self._processing_mode.currentText() == "bscan",
|
||||
"input": self._single_combo_input.text(),
|
||||
"output": self._single_combo_output.text(),
|
||||
"options": self._available_display_combo_keys()
|
||||
if self._is_matrix_radar_model_selected() else [],
|
||||
},
|
||||
},
|
||||
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 active plots; SAR3D status is sent separately as web text."""
|
||||
widget = self._plot_stack.currentWidget()
|
||||
if widget is self._sar3d_plots_container:
|
||||
widget = self._sar3d_plot_grid
|
||||
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."""
|
||||
controller = getattr(self, "_web_controller", None)
|
||||
if controller is not None:
|
||||
controller.shutdown()
|
||||
server = getattr(self, "_web_server", None)
|
||||
if server is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
server.stop()
|
||||
self._web_server = None
|
||||
self._log("Web UI stopped.")
|
||||
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
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Re-feed the GUI's retained sweeps to recompute the full stopped B-scan.
|
||||
|
||||
The processor retains a shorter history than the GUI. Replay preserves acquisition
|
||||
keys, bounds bursts by both ring capacities, and bypasses recording and live metrics.
|
||||
Only the caller decides when the processor's input ring is safe to write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
|
||||
from python_app.gui.controllers.history_controller import HistoryController
|
||||
from python_app.models.collection_identity import collection_key
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.models.run_config_schema import RingEndpointModel
|
||||
from python_app.orchestration.shm.decoder import PREPROC_MAGIC
|
||||
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_trace_collection
|
||||
|
||||
_REPLAY_CHUNK_SIZE = 12
|
||||
_REPLAY_CHUNK_TIMEOUT_S = 2.0
|
||||
_REPROCESS_DEBOUNCE_MS = 300
|
||||
|
||||
|
||||
class WarningSink(Protocol):
|
||||
def __call__(
|
||||
self, text: str, *, details: str | None = None, once_key: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class BscanReplay:
|
||||
def __init__(
|
||||
self, history: HistoryController, on_timeout: Callable[[], None],
|
||||
warning: WarningSink, debug: Callable[[str], None],
|
||||
) -> None:
|
||||
self.history = history
|
||||
self._warning = warning
|
||||
self._debug = debug
|
||||
self._writer: ShmRingWriter | None = None
|
||||
self._generation = 0
|
||||
self.active = False
|
||||
self.timer = QTimer()
|
||||
self.timer.setSingleShot(True)
|
||||
self.timer.timeout.connect(on_timeout)
|
||||
|
||||
def schedule(self, enabled: bool) -> None:
|
||||
if enabled:
|
||||
if self.active:
|
||||
self.discard()
|
||||
self.timer.start(_REPROCESS_DEBOUNCE_MS)
|
||||
|
||||
def run(
|
||||
self, *, window: int, revision: int, ring: RingEndpointModel | None,
|
||||
reader: ShmRingReader | None, pump_events: Callable[[float], None],
|
||||
continue_replay: Callable[[], bool],
|
||||
) -> bool:
|
||||
if self.active or reader is None:
|
||||
return False
|
||||
generation = self._generation
|
||||
if not self._continues(generation, continue_replay):
|
||||
return False
|
||||
tail = list(self.history.preprocessed)[-window:]
|
||||
if not tail:
|
||||
return False
|
||||
writer = self._ensure_writer(ring)
|
||||
if writer is None or ring is None:
|
||||
return False
|
||||
|
||||
keys = [collection_key(collection) for collection in tail]
|
||||
result_keys = {collection_key(collection) for collection in self.history.results}
|
||||
self._debug(
|
||||
f"B-scan replay starting: window={window}, preprocessed history="
|
||||
f"{len(self.history.preprocessed)}, result history={len(self.history.results)}, "
|
||||
f"to re-send={len(tail)}, of those already in result history="
|
||||
f"{sum(key in result_keys for key in keys)}."
|
||||
)
|
||||
replayed: dict[tuple[int, int], ResultCollection] = {}
|
||||
replayed_keys = set(keys)
|
||||
sent = 0
|
||||
chunk_size = min(_REPLAY_CHUNK_SIZE, ring.capacity, reader.capacity)
|
||||
# The caller suspends result polling. Event pumping may still change settings
|
||||
# or close the pipeline, so each boundary checks this session remains valid.
|
||||
self.active = True
|
||||
try:
|
||||
for start in range(0, len(tail), chunk_size):
|
||||
chunk = tail[start:start + chunk_size]
|
||||
pushed = 0
|
||||
for collection in chunk:
|
||||
if not self._continues(generation, continue_replay):
|
||||
return False
|
||||
payload = serialize_trace_collection(collection, PREPROC_MAGIC)
|
||||
if not writer.push(payload):
|
||||
self._warning(
|
||||
"B-scan replay stopped: a preprocessed sweep does not fit the ring slot.",
|
||||
details=f"payload={len(payload)} bytes, slot={writer.slot_size_bytes} bytes",
|
||||
once_key="bscan_replay_payload_too_large",
|
||||
)
|
||||
break
|
||||
pushed += 1
|
||||
sent += pushed
|
||||
waiting_for = keys[start:start + pushed]
|
||||
complete = self._collect(
|
||||
reader, waiting_for, replayed_keys, replayed, pump_events, generation, continue_replay, revision,
|
||||
)
|
||||
if not self._continues(generation, continue_replay):
|
||||
return False
|
||||
if not complete:
|
||||
self._debug(f"B-scan replay chunk at offset {start} came back incomplete.")
|
||||
if pushed != len(chunk):
|
||||
break
|
||||
finally:
|
||||
self.active = False
|
||||
|
||||
# Tap and results rings drop independently: only these replayed keys belong
|
||||
# in the refreshed image; unrelated runtime results may still use old settings.
|
||||
self.history.replay_results[:] = [replayed[key] for key in keys if key in replayed]
|
||||
received = sum(key in replayed for key in keys[:sent])
|
||||
if received < sent:
|
||||
self._warning(
|
||||
f"B-scan replay recovered {received} of {sent} re-sent sweeps; "
|
||||
"some columns may still show their captured settings.",
|
||||
once_key=f"bscan_replay_incomplete_{sent}_{received}",
|
||||
)
|
||||
self._debug(f"B-scan replay finished: sent={sent}, recovered={received}.")
|
||||
return True
|
||||
|
||||
def _collect(
|
||||
self, reader: ShmRingReader, waiting_for: list[tuple[int, int]],
|
||||
replayed_keys: set[tuple[int, int]], into: dict[tuple[int, int], ResultCollection],
|
||||
pump_events: Callable[[float], None], generation: int, continue_replay: Callable[[], bool],
|
||||
revision: int,
|
||||
) -> bool:
|
||||
deadline = time.monotonic() + _REPLAY_CHUNK_TIMEOUT_S
|
||||
while any(key not in into for key in waiting_for) and time.monotonic() < deadline:
|
||||
if not self._continues(generation, continue_replay):
|
||||
return False
|
||||
collection = reader.pop_result_collection()
|
||||
if collection is None:
|
||||
pump_events(0.005)
|
||||
if not self._continues(generation, continue_replay):
|
||||
return False
|
||||
continue
|
||||
if self.history.is_removed(collection):
|
||||
continue
|
||||
self.history.record_result(collection)
|
||||
key = collection_key(collection)
|
||||
# An old processor replay can return this exact sweep before the fresh
|
||||
# result. Its identity alone does not acknowledge the current request.
|
||||
if key in replayed_keys and collection.settings_revision == revision:
|
||||
into[key] = collection
|
||||
return all(key in into for key in waiting_for)
|
||||
|
||||
def _continues(self, generation: int, continue_replay: Callable[[], bool]) -> bool:
|
||||
if generation != self._generation:
|
||||
return False
|
||||
if not continue_replay():
|
||||
self.discard()
|
||||
return False
|
||||
return True
|
||||
|
||||
def _ensure_writer(self, ring: RingEndpointModel | None) -> ShmRingWriter | None:
|
||||
if self._writer is not None:
|
||||
return self._writer
|
||||
# Geometry must come from the active run. Guessing from an editable profile
|
||||
# could recreate the segment underneath the processor.
|
||||
if ring is None:
|
||||
self._warning(
|
||||
"Cannot recompute the full B-scan: this GUI session did not start the "
|
||||
"pipeline, so the processor's ring geometry is unknown.",
|
||||
once_key="bscan_replay_no_active_run_config",
|
||||
)
|
||||
return None
|
||||
try:
|
||||
self._writer = ShmRingWriter(ring.name, ring.capacity, ring.slot_size_bytes)
|
||||
except Exception as exc: # noqa: BLE001 - report boundary failures through the GUI
|
||||
self._warning("Failed to open the B-scan replay ring writer", details=str(exc))
|
||||
return self._writer
|
||||
|
||||
def discard(self) -> None:
|
||||
self._generation += 1
|
||||
self.timer.stop()
|
||||
self.history.replay_results.clear()
|
||||
|
||||
def close(self) -> None:
|
||||
self._generation += 1
|
||||
self.timer.stop()
|
||||
writer, self._writer = self._writer, None
|
||||
if writer is not None:
|
||||
try:
|
||||
writer.close()
|
||||
except Exception as exc: # noqa: BLE001 - cleanup must leave no attached writer
|
||||
self._warning("Failed to close the B-scan replay ring writer", details=str(exc))
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Bind configuration models to settings widgets without owning runtime state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import ExitStack
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QCheckBox, QComboBox, QDoubleSpinBox, QSpinBox
|
||||
|
||||
from python_app.storage.npz.paths import radar_config_filename_prefix
|
||||
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
|
||||
from python_app.gui.controllers.sections.views import ScalarWidget, SettingWidget, read_setting, write_setting
|
||||
from python_app.models.gui_profile_schema import GuiPreprocessDialogStateModel, GuiProcessingStateModel, GuiStateModel, Scalar
|
||||
from python_app.models.run_config_schema import ComboModel, GprModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
|
||||
from python_app.models.run_config_validation import parse_combos_from_text, parse_position_csv, validate_gpr_model
|
||||
from python_app.models.setting_fields import GUI_FIELDS, active_live_fields, active_web_fields
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_NAMES, preprocess_asset_model
|
||||
|
||||
# Stable fields have one mapping for both applying a profile and reading edits.
|
||||
_SWEEP_FIELDS = (
|
||||
("radar.sweep.start_hz", "_start_hz_input"),
|
||||
("radar.sweep.stop_hz", "_stop_hz_input"),
|
||||
("radar.sweep.points", "_points_input"),
|
||||
("radar.sweep.if_bandwidth_hz", "_ifbw_input"),
|
||||
("radar.sweep.power_dbm", "_power_input"),
|
||||
)
|
||||
_ADC_FIELDS = (
|
||||
("radar.kamil_adc.project_dir", "_adc_project_dir_input"),
|
||||
("radar.kamil_adc.executable_path", "_adc_executable_path_input"),
|
||||
("radar.kamil_adc.tty_path", "_adc_tty_path_input"),
|
||||
("radar.kamil_adc.args", "_adc_args_input"),
|
||||
("radar.kamil_adc.env", "_adc_env_input"),
|
||||
("radar.kamil_adc.startup_timeout_s", "_adc_startup_timeout_s_input"),
|
||||
("radar.kamil_adc.sweep_timeout_s", "_adc_sweep_timeout_s_input"),
|
||||
("radar.kamil_adc.stop_timeout_s", "_adc_stop_timeout_s_input"),
|
||||
("radar.laser_control.enabled", "_optical_enabled_checkbox"),
|
||||
("radar.laser_control.port", "_optical_port_input"),
|
||||
("radar.laser_control.mode", "_optical_mode_combo"),
|
||||
("radar.laser_control.pi_coeff1_p", "_optical_pi_coeff1_p_input"),
|
||||
("radar.laser_control.pi_coeff1_i", "_optical_pi_coeff1_i_input"),
|
||||
("radar.laser_control.pi_coeff2_p", "_optical_pi_coeff2_p_input"),
|
||||
("radar.laser_control.pi_coeff2_i", "_optical_pi_coeff2_i_input"),
|
||||
("radar.laser_control.manual.temp1", "_optical_manual_temp1_input"),
|
||||
("radar.laser_control.manual.temp2", "_optical_manual_temp2_input"),
|
||||
("radar.laser_control.manual.current1", "_optical_manual_current1_input"),
|
||||
("radar.laser_control.manual.current2", "_optical_manual_current2_input"),
|
||||
("radar.laser_control.variation.variation_type", "_optical_variation_type_combo"),
|
||||
("radar.laser_control.variation.static_temp1", "_optical_static_temp1_input"),
|
||||
("radar.laser_control.variation.static_temp2", "_optical_static_temp2_input"),
|
||||
("radar.laser_control.variation.static_current1", "_optical_static_current1_input"),
|
||||
("radar.laser_control.variation.static_current2", "_optical_static_current2_input"),
|
||||
("radar.laser_control.variation.min_value", "_optical_min_value_input"),
|
||||
("radar.laser_control.variation.max_value", "_optical_max_value_input"),
|
||||
("radar.laser_control.variation.step", "_optical_step_input"),
|
||||
("radar.laser_control.variation.time_step", "_optical_time_step_input"),
|
||||
("radar.laser_control.variation.delay_time", "_optical_delay_time_input"),
|
||||
)
|
||||
_GEOMETRY_FIELDS = {
|
||||
"relative_permittivity": "_gpr_relative_permittivity",
|
||||
"tx_geometry": "_gpr_tx_geometry_input",
|
||||
"rx_geometry": "_gpr_rx_geometry_input",
|
||||
}
|
||||
|
||||
|
||||
def web_apply_field_names() -> frozenset[str]:
|
||||
return frozenset(binding.spec.web for binding in GUI_FIELDS if binding.spec.web) | frozenset(_GEOMETRY_FIELDS)
|
||||
|
||||
|
||||
def _model_field(config: RunConfigModel, path: str) -> tuple[object, str]:
|
||||
owner: object = config
|
||||
*parents, name = path.split(".")
|
||||
for parent in parents:
|
||||
owner = getattr(owner, parent)
|
||||
return owner, name
|
||||
|
||||
|
||||
def _scalar_widget(widget: SettingWidget) -> ScalarWidget:
|
||||
if isinstance(widget, AntennaGeometryEditor):
|
||||
raise TypeError("Geometry is not a scalar setting")
|
||||
return widget
|
||||
|
||||
|
||||
def _read_widget(widget: SettingWidget) -> Scalar | list[dict]:
|
||||
return widget.records() if isinstance(widget, AntennaGeometryEditor) else read_setting(widget)
|
||||
|
||||
|
||||
def _set_web_widget(widget: SettingWidget, value: object) -> None:
|
||||
"""Validate untrusted web input before Qt can coerce or clamp it."""
|
||||
if isinstance(widget, AntennaGeometryEditor):
|
||||
widget.set_records(value)
|
||||
return
|
||||
if isinstance(widget, QCheckBox):
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("Checkbox value must be true or false")
|
||||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ValueError("Numeric setting requires a number")
|
||||
numeric = float(value)
|
||||
if not math.isfinite(numeric) or not widget.minimum() <= numeric <= widget.maximum():
|
||||
raise ValueError("Numeric setting is outside its allowed range")
|
||||
if isinstance(widget, QSpinBox) and numeric != int(numeric):
|
||||
raise ValueError("Setting requires an integer")
|
||||
value = int(numeric) if isinstance(widget, QSpinBox) else numeric
|
||||
elif isinstance(widget, QComboBox):
|
||||
if not isinstance(value, str) or widget.findText(value) < 0:
|
||||
raise ValueError(f"Unknown setting option: {value}")
|
||||
elif isinstance(value, list):
|
||||
if any(isinstance(item, bool) or not isinstance(item, int) for item in value):
|
||||
raise ValueError("Position selection requires integer values")
|
||||
value = ",".join(str(item) for item in value)
|
||||
if not isinstance(value, (str, int, float, bool)):
|
||||
raise ValueError("Setting requires a scalar value")
|
||||
write_setting(widget, value)
|
||||
|
||||
|
||||
class ConfigController:
|
||||
"""Read and apply editor values; callers own persistence and runtime effects."""
|
||||
|
||||
def __init__(self, fields: Mapping[str, SettingWidget], single_combo_selected: Callable[[], bool]) -> None:
|
||||
self.fields = fields
|
||||
self._single_combo_selected = single_combo_selected
|
||||
|
||||
def _scalar(self, name: str) -> Scalar:
|
||||
value = read_setting(_scalar_widget(self.fields[name]))
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
def dataset_destination(self, config: RunConfigModel) -> tuple[Path, str, str]:
|
||||
radar = config.radar
|
||||
prefix = radar_config_filename_prefix(radar.model, radar.sweep.start_hz, radar.sweep.stop_hz,
|
||||
radar.sweep.points, radar.sweep.power_dbm)
|
||||
return Path(str(self._scalar("_save_path_input"))).expanduser(), str(self._scalar("_save_name_input")), prefix
|
||||
|
||||
def _read_radar(self, config: RunConfigModel) -> None:
|
||||
for path, widget_id in (*_SWEEP_FIELDS, *(_ADC_FIELDS if config.is_kamil_adc else ())):
|
||||
owner, name = _model_field(config, path)
|
||||
original = getattr(owner, name)
|
||||
value = self._scalar(widget_id)
|
||||
if isinstance(original, bool):
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{path} must be a boolean")
|
||||
parsed = value
|
||||
elif isinstance(original, int):
|
||||
parsed = int(str(value))
|
||||
elif isinstance(original, float):
|
||||
parsed = float(value)
|
||||
elif isinstance(original, list):
|
||||
parsed = [line.strip() for line in str(value).splitlines() if line.strip()]
|
||||
elif isinstance(original, dict):
|
||||
parsed = json.loads(str(value) or "{}")
|
||||
if not isinstance(parsed, dict) or any(
|
||||
not isinstance(key, str) or not isinstance(item, str) for key, item in parsed.items()
|
||||
):
|
||||
raise ValueError("Environment JSON must contain only string keys and values")
|
||||
else:
|
||||
parsed = str(value)
|
||||
setattr(owner, name, parsed)
|
||||
|
||||
def build_gui_state(self, defaults: RunConfigModel, preprocess: GuiPreprocessDialogStateModel) -> GuiStateModel:
|
||||
gui = GuiStateModel(preprocess_dialog=deepcopy(preprocess))
|
||||
for binding in GUI_FIELDS:
|
||||
if binding.spec.widget:
|
||||
binding.write(gui, self._scalar(binding.spec.widget))
|
||||
gui.switches.combo_mode = "single" if not defaults.is_multi_device and self._single_combo_selected() else "text"
|
||||
if defaults.is_multi_device:
|
||||
gui.switches.combos_text = ",".join(f"{combo.input}:{combo.output}" for combo in defaults.build_runtime_combos())
|
||||
return gui
|
||||
|
||||
def build_config(
|
||||
self, defaults: RunConfigModel, selected_sets: Mapping[str, str], live_path: Path, log_level: str,
|
||||
) -> RunConfigModel:
|
||||
config = defaults.clone()
|
||||
self._read_radar(config)
|
||||
config.runtime.settling_ms = int(str(self._scalar("_settling_ms")))
|
||||
config.runtime.processing_live_config_path = str(live_path)
|
||||
config.logging.level = log_level.strip().lower()
|
||||
if config.is_multi_device:
|
||||
if len(config.radar.multi_device.slave_serials) != 2:
|
||||
raise ValueError("LibreVNA multi-device mode requires exactly two slave serials")
|
||||
config.apply_device_model_constraints()
|
||||
else:
|
||||
config.combos = (
|
||||
[ComboModel(int(str(self._scalar("_single_combo_input"))), int(str(self._scalar("_single_combo_output"))))]
|
||||
if self._single_combo_selected() else parse_combos_from_text(str(self._scalar("_combos_text")))
|
||||
)
|
||||
config.ensure_combos()
|
||||
static = (
|
||||
config.input_switch.positions <= 1 and config.output_switch.positions <= 1
|
||||
or config.input_switch.driver_mode == config.output_switch.driver_mode == "mock"
|
||||
)
|
||||
if self._scalar("_processing_mode") not in {"gpr", "legacy_gpr", "sar3d"} and static:
|
||||
config.combos = [ComboModel(0, 0)]
|
||||
for key in PREPROCESS_ASSET_NAMES:
|
||||
asset = preprocess_asset_model(config, key)
|
||||
asset.bundle_path = ""
|
||||
asset.set_name = selected_sets.get(key, "")
|
||||
config.gpr = self.geometry()
|
||||
validate_gpr_model(config.gpr, input_switch_positions=config.input_switch.positions,
|
||||
output_switch_positions=config.output_switch.positions, sweep=config.radar.sweep)
|
||||
return config
|
||||
|
||||
def radar_key(self, defaults: RunConfigModel) -> str:
|
||||
config = defaults.clone()
|
||||
self._read_radar(config)
|
||||
return config.radar_key()
|
||||
|
||||
def _geometry(self, name: str) -> AntennaGeometryEditor:
|
||||
widget = self.fields[_GEOMETRY_FIELDS[name]]
|
||||
if not isinstance(widget, AntennaGeometryEditor):
|
||||
raise TypeError(f"{name} requires an antenna geometry editor")
|
||||
return widget
|
||||
|
||||
def processing_settings(self) -> GuiProcessingStateModel:
|
||||
gui = GuiStateModel()
|
||||
gui.processing.selected_mode = str(self._scalar("_processing_mode"))
|
||||
for binding in GUI_FIELDS:
|
||||
if binding.mode in {gui.processing.selected_mode, "pass_through"} and binding.spec.widget:
|
||||
binding.write(gui, self._scalar(binding.spec.widget))
|
||||
return gui.processing
|
||||
|
||||
def geometry(self) -> GprModel:
|
||||
return GprModel(
|
||||
relative_permittivity=float(self._scalar("_gpr_relative_permittivity")),
|
||||
tx_geometry=[GprTxGeometryModel(**entry) for entry in self._geometry("tx_geometry").records()],
|
||||
rx_geometry=[GprRxGeometryModel(**entry) for entry in self._geometry("rx_geometry").records()],
|
||||
)
|
||||
|
||||
def build_live_config(
|
||||
self, *, reprocess_current_result: bool = True, history_clear_before_ns: int = 0,
|
||||
history_removed: Sequence[dict[str, int]] = (), settings_revision: int = 0,
|
||||
) -> ProcessingLiveConfig:
|
||||
config = ProcessingLiveConfig(
|
||||
reprocess_current_result=reprocess_current_result, history_clear_before_ns=history_clear_before_ns,
|
||||
history_removed=deepcopy(list(history_removed)), settings_revision=settings_revision,
|
||||
)
|
||||
for binding in active_live_fields(str(self._scalar("_processing_mode"))):
|
||||
value = self._scalar(binding.spec.widget)
|
||||
if binding.spec.live in {"gpr_input_positions", "gpr_output_positions"}:
|
||||
value = parse_position_csv(str(value))
|
||||
setattr(config, binding.spec.live, value)
|
||||
config.relative_permittivity = float(self._scalar("_gpr_relative_permittivity"))
|
||||
config.tx_geometry = self._geometry("tx_geometry").records()
|
||||
config.rx_geometry = self._geometry("rx_geometry").records()
|
||||
for axis in ("x", "z"):
|
||||
lower_name, upper_name = f"gpr_visible_{axis}_min_m", f"gpr_visible_{axis}_max_m"
|
||||
lower, upper = sorted((getattr(config, lower_name), getattr(config, upper_name)))
|
||||
if upper - lower < .1:
|
||||
center = .5 * (lower + upper)
|
||||
lower, upper = center - .05, center + .05
|
||||
setattr(config, lower_name, lower)
|
||||
setattr(config, upper_name, upper)
|
||||
return config
|
||||
|
||||
def apply_profile_widgets(self, config: RunConfigModel, gui: GuiStateModel) -> None:
|
||||
with ExitStack() as blockers:
|
||||
for widget in self.fields.values():
|
||||
blockers.enter_context(QSignalBlocker(widget))
|
||||
for path, widget_id in (*_SWEEP_FIELDS, *_ADC_FIELDS, ("runtime.settling_ms", "_settling_ms")):
|
||||
owner, name = _model_field(config, path)
|
||||
value = getattr(owner, name)
|
||||
if isinstance(value, dict):
|
||||
value = json.dumps(value, indent=2, sort_keys=True)
|
||||
elif isinstance(value, list):
|
||||
value = "\n".join(value)
|
||||
elif isinstance(value, float):
|
||||
value = f"{value:g}"
|
||||
write_setting(_scalar_widget(self.fields[widget_id]), value)
|
||||
# Set frequency ranges before values so a previously narrow profile
|
||||
# cannot silently clamp the newly loaded processing band.
|
||||
for binding in GUI_FIELDS:
|
||||
if binding.path[-1] in {"start_freq_mhz", "stop_freq_mhz"}:
|
||||
widget = self.fields[binding.spec.widget]
|
||||
if isinstance(widget, QDoubleSpinBox):
|
||||
widget.setRange(config.radar.sweep.start_hz / 1e6, config.radar.sweep.stop_hz / 1e6)
|
||||
if binding.spec.widget:
|
||||
write_setting(_scalar_widget(self.fields[binding.spec.widget]), binding.read(gui))
|
||||
write_setting(_scalar_widget(self.fields["_log_level_combo"]), config.logging.level.capitalize())
|
||||
write_setting(_scalar_widget(self.fields["_gpr_relative_permittivity"]), config.gpr.relative_permittivity)
|
||||
for name, count, records in (
|
||||
("tx_geometry", config.output_switch.positions, config.gpr.tx_geometry),
|
||||
("rx_geometry", config.input_switch.positions, config.gpr.rx_geometry),
|
||||
):
|
||||
editor = self._geometry(name)
|
||||
editor.set_position_count(count)
|
||||
editor.set_records(records)
|
||||
|
||||
def _web_widgets(self, mode: str) -> dict[str, tuple[str, SettingWidget]]:
|
||||
widgets = {binding.spec.web: (binding.spec.group, self.fields[binding.spec.widget])
|
||||
for binding in active_web_fields(mode)}
|
||||
for name, widget_id in _GEOMETRY_FIELDS.items():
|
||||
if mode in ({"legacy_gpr"} if name == "relative_permittivity" else {"gpr", "legacy_gpr", "sar3d"}):
|
||||
widgets[name] = ("Geometry & medium", self.fields[widget_id])
|
||||
return widgets
|
||||
|
||||
def web_schema(self) -> list[dict[str, object]]:
|
||||
schema = []
|
||||
for name, (group, widget) in self._web_widgets(str(self._scalar("_processing_mode"))).items():
|
||||
entry: dict[str, object] = {"name": name, "group": group, "enabled": widget.isEnabled(), "value": _read_widget(widget)}
|
||||
if isinstance(widget, QComboBox):
|
||||
entry.update(kind="select", options=[widget.itemText(i) for i in range(widget.count())])
|
||||
elif isinstance(widget, QCheckBox):
|
||||
entry.update(kind="bool")
|
||||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
entry.update(kind="int" if isinstance(widget, QSpinBox) else "float",
|
||||
min=widget.minimum(), max=widget.maximum(), step=widget.singleStep())
|
||||
if isinstance(widget, QDoubleSpinBox):
|
||||
entry["decimals"] = widget.decimals()
|
||||
elif isinstance(widget, AntennaGeometryEditor):
|
||||
entry.update(kind="geometry", position_field=widget.position_field, position_count=widget.position_count)
|
||||
else:
|
||||
entry.update(kind="text")
|
||||
schema.append(entry)
|
||||
return schema
|
||||
|
||||
def restore_live_config(self, config: Mapping[str, object]) -> None:
|
||||
"""Restore desktop edits from the last successfully published revision."""
|
||||
with ExitStack() as blockers:
|
||||
for widget in self.fields.values():
|
||||
blockers.enter_context(QSignalBlocker(widget))
|
||||
for binding in active_live_fields(str(config["processor_mode"])):
|
||||
value = config[binding.spec.live]
|
||||
if isinstance(value, list):
|
||||
value = ",".join(map(str, value))
|
||||
if not isinstance(value, (str, int, float, bool)):
|
||||
raise TypeError(f"Invalid published setting: {binding.spec.live}")
|
||||
write_setting(_scalar_widget(self.fields[binding.spec.widget]), value)
|
||||
for name, widget_id in _GEOMETRY_FIELDS.items():
|
||||
_set_web_widget(self.fields[widget_id], config[name])
|
||||
|
||||
def apply_web_fields(self, fields: Mapping[str, object], commit: Callable[[], None]) -> None:
|
||||
"""Apply one batch without signals; restore editors if validation or publication fails."""
|
||||
with ExitStack() as blockers:
|
||||
scalar_snapshots = []
|
||||
geometry_snapshots = []
|
||||
combo_snapshots = []
|
||||
ranges = []
|
||||
for widget in self.fields.values():
|
||||
blockers.enter_context(QSignalBlocker(widget))
|
||||
if isinstance(widget, QComboBox):
|
||||
combo_snapshots.append((widget, widget.currentIndex()))
|
||||
elif isinstance(widget, AntennaGeometryEditor):
|
||||
geometry_snapshots.append((widget, widget.records()))
|
||||
else:
|
||||
scalar_snapshots.append((widget, read_setting(widget)))
|
||||
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
ranges.append((widget, widget.minimum(), widget.maximum()))
|
||||
try:
|
||||
if "processor_mode" in fields:
|
||||
_set_web_widget(self.fields["_processing_mode"], fields["processor_mode"])
|
||||
bindings = self._web_widgets(str(self._scalar("_processing_mode")))
|
||||
for name, value in fields.items():
|
||||
if name not in bindings:
|
||||
raise ValueError(f"Unknown setting for current mode: {name}")
|
||||
_set_web_widget(bindings[name][1], value)
|
||||
commit()
|
||||
except Exception:
|
||||
for widget, lower, upper in ranges:
|
||||
widget.setRange(lower, upper)
|
||||
for widget, index in combo_snapshots:
|
||||
widget.setCurrentIndex(index)
|
||||
for widget, records in geometry_snapshots:
|
||||
widget.set_records(records)
|
||||
for widget, value in scalar_snapshots:
|
||||
write_setting(widget, value)
|
||||
raise
|
||||
|
||||
def mode_summary(self, mode: str) -> str:
|
||||
values = (f"{binding.spec.web}={self._scalar(binding.spec.widget)}"
|
||||
for binding in active_web_fields(mode) if binding.spec.web != "processor_mode")
|
||||
return "(" + ", ".join(values) + ")"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Bind the optional GPIO watcher to the shared reference-capture action."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from PyQt6.QtCore import QObject
|
||||
|
||||
from python_app.gui.control_button import ControlButtonWatcher
|
||||
from python_app.gui.controllers.log_controller import LogController
|
||||
from python_app.models.run_config_schema import ControlButtonModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ControlButtonController(QObject):
|
||||
def __init__(self, parent: QObject, current_config: Callable[[], ControlButtonModel],
|
||||
capture_reference: Callable[[], None], log: LogController) -> None:
|
||||
super().__init__(parent)
|
||||
self._config, self._capture_reference = current_config, capture_reference
|
||||
self._log = log
|
||||
self._watcher: ControlButtonWatcher | None = None
|
||||
self._closed = False
|
||||
|
||||
def start(self) -> None:
|
||||
if self._closed or self._watcher is not None:
|
||||
return
|
||||
config = self._config()
|
||||
if not config.enabled:
|
||||
return
|
||||
if config.pin < 0 or not config.gpio_chip or config.action != ControlButtonModel.ACTION_CAPTURE_TMP_REFERENCE:
|
||||
logger.warning("GPIO control button configuration is incomplete or unsupported; watcher not started")
|
||||
return
|
||||
watcher = None
|
||||
try:
|
||||
watcher = ControlButtonWatcher(chip=config.gpio_chip, pin=config.pin, active_low=config.active_low,
|
||||
bias=config.bias, debounce_ms=config.debounce_ms, parent=self)
|
||||
watcher.pressed.connect(self._pressed)
|
||||
watcher.failed.connect(self._failed)
|
||||
watcher.start()
|
||||
self._watcher = watcher
|
||||
logger.info("GPIO control button watching %s pin=%d action=%s", config.gpio_chip, config.pin, config.action)
|
||||
except Exception:
|
||||
logger.warning("Failed to start GPIO control button watcher", exc_info=True)
|
||||
if watcher is not None:
|
||||
self._disconnect(watcher)
|
||||
watcher.deleteLater()
|
||||
|
||||
def _pressed(self) -> None:
|
||||
if not self._closed and self._watcher is not None:
|
||||
logger.info("GPIO control button pressed: capturing tmp reference")
|
||||
with self._log.capture_errors():
|
||||
self._capture_reference()
|
||||
|
||||
def _failed(self, message: str) -> None:
|
||||
logger.warning("GPIO control button watcher stopped: %s", message)
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
watcher, self._watcher = self._watcher, None
|
||||
if watcher is None:
|
||||
return
|
||||
self._disconnect(watcher)
|
||||
try:
|
||||
watcher.stop()
|
||||
except Exception:
|
||||
logger.warning("Error stopping GPIO control button watcher", exc_info=True)
|
||||
finally:
|
||||
self._disconnect(watcher)
|
||||
watcher.deleteLater()
|
||||
|
||||
@staticmethod
|
||||
def _disconnect(watcher: ControlButtonWatcher) -> None:
|
||||
for signal in (watcher.pressed, watcher.failed):
|
||||
try:
|
||||
signal.disconnect()
|
||||
except (TypeError, RuntimeError):
|
||||
pass
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Retained acquisitions and pending processor-history deletions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from itertools import chain
|
||||
import time
|
||||
from typing import TypeVar
|
||||
|
||||
from python_app.gui.settings_defaults import GUI_SAVE_HISTORY_LIMIT
|
||||
from python_app.gui.runtime.history import record_result_history
|
||||
from python_app.models.collection_identity import CollectionIdentity, collection_key, history_collection_removed
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
|
||||
_Collection = TypeVar("_Collection", SweepCollection, ResultCollection)
|
||||
|
||||
|
||||
def _remove_acquisition(history: deque[_Collection], key: tuple[int, int]) -> None:
|
||||
retained = [item for item in history if collection_key(item) != key]
|
||||
history.clear()
|
||||
history.extend(retained)
|
||||
|
||||
|
||||
class HistoryController:
|
||||
"""Own GUI history without opening rings, starting workers, or drawing plots."""
|
||||
|
||||
def __init__(self, limit: int = GUI_SAVE_HISTORY_LIMIT) -> None:
|
||||
self.raw: deque[SweepCollection] = deque(maxlen=limit)
|
||||
self.preprocessed: deque[SweepCollection] = deque(maxlen=limit)
|
||||
self.results: deque[ResultCollection] = deque(maxlen=limit)
|
||||
self.replay_results: list[ResultCollection] = []
|
||||
self.run_signature: tuple[object, ...] | None = None
|
||||
self.clear_before_ns = 0
|
||||
self.removed: list[dict[str, int]] = []
|
||||
|
||||
def is_removed(self, collection: CollectionIdentity) -> bool:
|
||||
return history_collection_removed(collection, self.clear_before_ns, self.removed)
|
||||
|
||||
def record_result(self, collection: ResultCollection) -> bool:
|
||||
"""Replace a recomputed sweep or insert a new one in acquisition order."""
|
||||
return not self.is_removed(collection) and record_result_history(self.results, collection)
|
||||
|
||||
def apply_deletion(self, command: str, target: dict[str, int] | None = None) -> None:
|
||||
"""Stage deletions before publishing settings; a failed write must retain them."""
|
||||
if command == "clear_all":
|
||||
self.clear_before_ns = time.monotonic_ns()
|
||||
self.removed.clear()
|
||||
elif command == "remove_last" and target is not None:
|
||||
self.removed.append(dict(target))
|
||||
|
||||
def delete(self, *, remove_last: bool) -> None:
|
||||
"""Mutate retained stages and stage their deletion before any external action."""
|
||||
self.replay_results.clear()
|
||||
if not remove_last:
|
||||
self.apply_deletion("clear_all")
|
||||
self.raw.clear()
|
||||
self.preprocessed.clear()
|
||||
self.results.clear()
|
||||
return
|
||||
newest = max(chain(self.raw, self.preprocessed, self.results), key=lambda item: item.capture_start_ns, default=None)
|
||||
if newest is None:
|
||||
return
|
||||
key = collection_key(newest)
|
||||
self.apply_deletion("remove_last", {"collection_id": key[0], "capture_start_ns": key[1]})
|
||||
_remove_acquisition(self.raw, key)
|
||||
_remove_acquisition(self.preprocessed, key)
|
||||
_remove_acquisition(self.results, key)
|
||||
|
||||
def bscan_source(self) -> list[ResultCollection]:
|
||||
"""A completed replay contains only the re-fed sweeps with current settings."""
|
||||
return list(self.replay_results or self.results)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Publish live processing revisions and apply desktop or web editor changes."""
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
|
||||
|
||||
from python_app.gui.controllers.config_controller import ConfigController
|
||||
from python_app.gui.controllers.log_controller import LogController
|
||||
from python_app.gui.controllers.pipeline_controller import PipelineController
|
||||
from python_app.gui.controllers.preprocess_controller import PreprocessController
|
||||
from python_app.gui.controllers.radar_settings_controller import RadarSettingsController
|
||||
from python_app.gui.controllers.sections.views import ProcessingView, connect_setting, write_setting
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.models.setting_fields import active_live_fields
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig, ProcessingLiveConfigWriter
|
||||
|
||||
_GPR_MODES = {"gpr", "legacy_gpr"}
|
||||
_GEOMETRY_MODES = _GPR_MODES | {"sar3d"}
|
||||
|
||||
|
||||
class LiveSettingsController:
|
||||
def __init__(
|
||||
self, processing: ProcessingView, config: ConfigController, pipeline: PipelineController,
|
||||
preprocess: PreprocessController, radar: RadarSettingsController,
|
||||
writer: ProcessingLiveConfigWriter, processing_settings: dict[int, dict], log: LogController,
|
||||
*, build_config: Callable[[], RunConfigModel],
|
||||
) -> None:
|
||||
self.config, self.writer, self.processing_settings = config, writer, processing_settings
|
||||
self._processing, self._pipeline, self._preprocess = processing, pipeline, preprocess
|
||||
self._radar, self._log, self._build_config = radar, log, build_config
|
||||
self._mode = processing.mode
|
||||
self.revision = 0
|
||||
self.active_mode = "pass_through"
|
||||
self._mode.currentTextChanged.connect(self.mode_changed)
|
||||
special = {self._mode, *processing.trace_controls, *processing.visual_controls,
|
||||
*processing.locator_controls, *(toggle for toggle, _ in processing.speed_controls)}
|
||||
for widgets, callback in ((processing.trace_controls, self.trace_changed),
|
||||
(processing.visual_controls, self.visual_changed),
|
||||
(processing.locator_controls, self.locator_changed)):
|
||||
for widget in widgets:
|
||||
connect_setting(widget, callback)
|
||||
for toggle, _ in processing.speed_controls:
|
||||
toggle.toggled.connect(self.speed_changed)
|
||||
for widget in processing.fields.values():
|
||||
if isinstance(widget, AntennaGeometryEditor):
|
||||
widget.validate_change = lambda: self.validate_mode(self._mode.currentText())
|
||||
widget.changed.connect(self.geometry_changed)
|
||||
elif widget not in special:
|
||||
connect_setting(widget, self.changed)
|
||||
|
||||
def build(self, *, reprocess_current_result: bool = True) -> ProcessingLiveConfig:
|
||||
self._radar.sync_frequency_limits()
|
||||
history = self._pipeline.history
|
||||
return self.config.build_live_config(
|
||||
reprocess_current_result=reprocess_current_result,
|
||||
history_clear_before_ns=history.clear_before_ns, history_removed=history.removed,
|
||||
settings_revision=self.revision,
|
||||
)
|
||||
|
||||
def publish(self) -> None:
|
||||
revision = time.monotonic_ns()
|
||||
config = self.build(reprocess_current_result=not self._pipeline.can_reprocess_history(mode=self._mode.currentText()))
|
||||
config.settings_revision = revision
|
||||
self.writer.write(config)
|
||||
self.revision = revision
|
||||
self.processing_settings[revision] = config.to_dict()
|
||||
active = self._pipeline.active_config
|
||||
if active is not None and self._pipeline.running:
|
||||
geometry = self.config.geometry()
|
||||
active.gpr.tx_geometry, active.gpr.rx_geometry = geometry.tx_geometry, geometry.rx_geometry
|
||||
self._pipeline.plots.required_revision = revision
|
||||
|
||||
def validate_mode(self, mode: str) -> None:
|
||||
validate_processing_mode_constraints(mode, self._build_config(), self.build())
|
||||
|
||||
def apply_web(self, fields: Mapping[str, object]) -> None:
|
||||
try:
|
||||
settings = dict(fields)
|
||||
self._log.debug(f"Applying web live settings: fields={sorted(settings)}.")
|
||||
|
||||
def commit() -> None:
|
||||
self.validate_mode(self._mode.currentText())
|
||||
live_fields = {binding.spec.web for binding in active_live_fields(self._mode.currentText())}
|
||||
if settings.keys() & (live_fields | {"tx_geometry", "rx_geometry", "relative_permittivity"}):
|
||||
self.publish()
|
||||
|
||||
self.config.apply_web_fields(settings, commit)
|
||||
self.sync_controls()
|
||||
if "processor_mode" in settings:
|
||||
self._show_mode(self._mode.currentText())
|
||||
if {"tx_geometry", "rx_geometry"} & settings.keys():
|
||||
self._preprocess.invalidate_object_calibration_after_geometry_change()
|
||||
self._redraw()
|
||||
except Exception as exc:
|
||||
self._log.show_exception("Failed to apply web live settings", exc)
|
||||
|
||||
def _restore_published(self) -> None:
|
||||
previous = self.processing_settings.get(self.revision)
|
||||
if previous is not None:
|
||||
self.config.restore_live_config(previous)
|
||||
else:
|
||||
with QSignalBlocker(self._mode):
|
||||
write_setting(self._mode, self.active_mode)
|
||||
self.sync_controls()
|
||||
|
||||
def _publish_changes(self) -> bool:
|
||||
try:
|
||||
self.publish()
|
||||
except Exception as exc:
|
||||
self._restore_published()
|
||||
self._log.show_exception("Failed to update live processing settings", exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
def changed(self, *_args: object) -> None:
|
||||
if self._publish_changes():
|
||||
self._redraw()
|
||||
|
||||
def _redraw(self) -> None:
|
||||
try:
|
||||
mode = self._mode.currentText()
|
||||
pipeline, plots, history = self._pipeline, self._pipeline.plots, self._pipeline.history
|
||||
if mode == "bscan":
|
||||
if pipeline.can_reprocess_history():
|
||||
pipeline.schedule_replay()
|
||||
else:
|
||||
pipeline.drain_results(timeout_s=0.25, poll_s=0.01)
|
||||
plots.draw_bscan()
|
||||
elif mode in _GEOMETRY_MODES:
|
||||
latest = pipeline.drain_results(timeout_s=0.8, poll_s=0.02)
|
||||
collection = latest if latest is not None else history.results[-1] if history.results else None
|
||||
if mode == "sar3d":
|
||||
plots.sar3d.clear()
|
||||
if collection is not None:
|
||||
plots.draw(collection)
|
||||
elif collection is None or not plots.draw(collection):
|
||||
plots.clear_gpr()
|
||||
elif history.results:
|
||||
plots.draw(history.results[-1])
|
||||
else:
|
||||
plots.trace.clear()
|
||||
except Exception as exc:
|
||||
self._log.show_exception("Failed to render processing settings", exc)
|
||||
|
||||
def geometry_changed(self) -> None:
|
||||
if self._publish_changes():
|
||||
self._preprocess.invalidate_object_calibration_after_geometry_change()
|
||||
self._redraw()
|
||||
|
||||
def sync_controls(self) -> None:
|
||||
for widget in self._processing.y_bounds:
|
||||
widget.setEnabled(self._processing.fixed_y.isChecked())
|
||||
for toggle, speed in self._processing.speed_controls:
|
||||
speed.setEnabled(toggle.isChecked())
|
||||
|
||||
def trace_changed(self, *_args: object) -> None:
|
||||
self.sync_controls()
|
||||
self.visual_changed()
|
||||
|
||||
def speed_changed(self, *_args: object) -> None:
|
||||
self.sync_controls()
|
||||
self.changed()
|
||||
|
||||
def visual_changed(self, *_args: object) -> None:
|
||||
try:
|
||||
self._pipeline.plots.redraw()
|
||||
except Exception as exc:
|
||||
self._log.show_exception("Failed to update plot display settings", exc)
|
||||
|
||||
def locator_changed(self, *_args: object) -> None:
|
||||
if self._mode.currentText() in _GPR_MODES and self._publish_changes():
|
||||
self.visual_changed()
|
||||
|
||||
def set_mode_page(self, mode: str) -> None:
|
||||
pages = self._processing.pages
|
||||
pages.setCurrentIndex({"pass_through": 0, "bscan": 1, "gpr": 2, "legacy_gpr": 3, "sar3d": 4}.get(mode, 0))
|
||||
self._processing.geometry_page.setVisible(mode in _GEOMETRY_MODES)
|
||||
page = pages.currentWidget()
|
||||
if page is not None:
|
||||
pages.setFixedHeight(page.sizeHint().height())
|
||||
pages.updateGeometry()
|
||||
self._pipeline.plots.sync_display_combos()
|
||||
|
||||
def mode_changed(self, mode: str) -> None:
|
||||
try:
|
||||
if mode != self.active_mode:
|
||||
self.validate_mode(mode)
|
||||
self.publish()
|
||||
except Exception as exc:
|
||||
self._restore_published()
|
||||
self._log.show_exception(f"Cannot switch processing mode to {mode}", exc)
|
||||
return
|
||||
self._show_mode(mode)
|
||||
self._redraw()
|
||||
|
||||
def _show_mode(self, mode: str) -> None:
|
||||
self.active_mode = mode
|
||||
self._pipeline.plots.set_mode(mode)
|
||||
self.set_mode_page(mode)
|
||||
self._log.info(f"Processing mode selected: {mode} {self.config.mode_summary(mode)}")
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Bounded GUI logging, error presentation and request-scoped error capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
import html
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker, QTimer
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QWidget
|
||||
|
||||
from python_app.gui.settings_defaults import env_enabled
|
||||
from python_app.logging_setup import LOG_LEVELS, add_handler, configure_logging, get_logger, package_logger, set_log_level
|
||||
|
||||
|
||||
class _PanelLogBuffer:
|
||||
"""Worker threads enqueue records; the GUI flushes one bounded batch per tick."""
|
||||
|
||||
_CAPACITY = 2000
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._entries: deque[tuple[str, str, str | None, str | None]] = deque(maxlen=self._CAPACITY)
|
||||
self._dropped_count = 0
|
||||
|
||||
def append(self, level: str, text: str, details: str | None, once_key: str | None) -> None:
|
||||
with self._lock:
|
||||
if len(self._entries) == self._CAPACITY:
|
||||
self._dropped_count += 1
|
||||
self._entries.append((level, text, details, once_key))
|
||||
|
||||
def drain(self) -> tuple[list[tuple[str, str, str | None, str | None]], int]:
|
||||
with self._lock:
|
||||
entries, dropped = list(self._entries), self._dropped_count
|
||||
self._entries.clear()
|
||||
self._dropped_count = 0
|
||||
return entries, dropped
|
||||
|
||||
|
||||
class _PanelHandler(logging.Handler):
|
||||
def __init__(self, buffer: _PanelLogBuffer) -> None:
|
||||
super().__init__()
|
||||
self._buffer = buffer
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
details = getattr(record, "panel_details", None)
|
||||
once_key = getattr(record, "panel_once_key", None)
|
||||
self._buffer.append(
|
||||
"WARN" if record.levelname == "WARNING" else record.levelname, record.getMessage(),
|
||||
details if isinstance(details, str) else None, once_key if isinstance(once_key, str) else None,
|
||||
)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
def _entry_html(level: str, text: str, details: str | None = None) -> str:
|
||||
palette = {"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"), "INFO": ("#1d5fbf", "#1f2937", "#526277"),
|
||||
"WARN": ("#9a5b00", "#5c4300", "#7a6640"), "ERROR": ("#c43d4d", "#6b1f2a", "#8b5d66")}
|
||||
accent, message, detail = palette.get(level.upper(), palette["INFO"])
|
||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
body = (f"<span style='color:{accent}; font-weight:700;'>{html.escape(level)}</span>"
|
||||
f" <span style='color:#60758d;'>{timestamp}</span>"
|
||||
f" <span style='color:{message};'>{html.escape(text).replace(chr(10), '<br>')}</span>")
|
||||
if details:
|
||||
body += f"<pre style='margin:3px 0 0 16px; color:{detail};'>{html.escape(details)}</pre>"
|
||||
return f"<div style='margin:0 0 6px 0;'>{body}</div>"
|
||||
|
||||
|
||||
class LogController:
|
||||
def __init__(self, parent: QWidget, project_root: Path, status: Callable[[str], None]) -> None:
|
||||
self._parent, self._status = parent, status
|
||||
configure_logging(log_dir=project_root / "python_app/runtime/logs")
|
||||
self._logger = get_logger("gui")
|
||||
self._buffer = _PanelLogBuffer()
|
||||
self._seen: set[str] = set()
|
||||
self._capture: list[str] | None = None
|
||||
self.panel = QWidget(parent)
|
||||
layout = QVBoxLayout(self.panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
header = QHBoxLayout()
|
||||
header.setSpacing(8)
|
||||
title = QLabel("Runtime Log")
|
||||
title.setObjectName("hintLabel")
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
label = QLabel("Level")
|
||||
label.setObjectName("hintLabel")
|
||||
header.addWidget(label)
|
||||
self.level = QComboBox()
|
||||
self.level.setObjectName("logLevelCombo")
|
||||
self.level.addItems([name.capitalize() for name in LOG_LEVELS])
|
||||
self.level.setCurrentText("Info")
|
||||
self.level.setToolTip("Logging verbosity — applied live and saved to run_config")
|
||||
self.level.currentTextChanged.connect(self.set_level)
|
||||
header.addWidget(self.level)
|
||||
self._toggle = QPushButton("Collapse")
|
||||
self._toggle.setObjectName("sectionToggleButton")
|
||||
self._toggle.setToolTip("Hide runtime log entries")
|
||||
self._toggle.clicked.connect(self.toggle)
|
||||
header.addWidget(self._toggle)
|
||||
layout.addLayout(header)
|
||||
self._box = QTextEdit()
|
||||
self._box.setObjectName("runtimeLogBox")
|
||||
self._box.setReadOnly(True)
|
||||
self._box.setUndoRedoEnabled(False)
|
||||
self._box.setMinimumHeight(170)
|
||||
document = self._box.document()
|
||||
assert document is not None
|
||||
document.setMaximumBlockCount(1200)
|
||||
layout.addWidget(self._box)
|
||||
self._handler = _PanelHandler(self._buffer)
|
||||
add_handler(self._handler)
|
||||
self._timer = QTimer(self.panel)
|
||||
self._timer.setInterval(100)
|
||||
self._timer.timeout.connect(self.flush)
|
||||
self._timer.start()
|
||||
|
||||
def set_level(self, text: str) -> None:
|
||||
with QSignalBlocker(self.level):
|
||||
self.level.setCurrentText(text.capitalize())
|
||||
set_log_level(text)
|
||||
self.info(f"Log level set to {text.upper()}.")
|
||||
|
||||
def toggle(self) -> None:
|
||||
visible = self._box.isHidden()
|
||||
self._box.setVisible(visible)
|
||||
self._toggle.setText("Collapse" if visible else "Expand")
|
||||
self._toggle.setToolTip("Hide runtime log entries" if visible else "Show runtime log entries")
|
||||
|
||||
def flush(self) -> None:
|
||||
entries, dropped = self._buffer.drain()
|
||||
rendered: list[str] = []
|
||||
if dropped:
|
||||
rendered.append(_entry_html("WARN", f"Log panel overflow: {dropped} record(s) dropped (they are still in the log file)."))
|
||||
for level, text, details, once_key in entries:
|
||||
if once_key is not None:
|
||||
if once_key in self._seen:
|
||||
continue
|
||||
self._seen.add(once_key)
|
||||
rendered.append(_entry_html(level, text, details))
|
||||
if level == "ERROR":
|
||||
self._status("Status: error")
|
||||
if rendered:
|
||||
cursor = self._box.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self._box.setTextCursor(cursor)
|
||||
self._box.insertHtml("".join(rendered))
|
||||
self._box.insertPlainText("\n")
|
||||
self._box.ensureCursorVisible()
|
||||
|
||||
def debug(self, text: str, *, once_key: str | None = None) -> None:
|
||||
self._logger.debug(text, extra={"panel_once_key": once_key})
|
||||
|
||||
def info(self, text: str, *, once_key: str | None = None) -> None:
|
||||
self._logger.info(text, extra={"panel_once_key": once_key})
|
||||
|
||||
def warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
self._logger.warning(text, extra={"panel_details": details, "panel_once_key": once_key})
|
||||
|
||||
def error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
self._logger.error(text, extra={"panel_details": details, "panel_once_key": once_key})
|
||||
|
||||
@staticmethod
|
||||
def exception_summary(exc: Exception) -> str:
|
||||
message = str(exc).strip()
|
||||
return f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
||||
|
||||
@staticmethod
|
||||
def exception_details(exc: Exception) -> str:
|
||||
return "".join(traceback.TracebackException.from_exception(exc).format(chain=True)).strip()
|
||||
|
||||
def exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]:
|
||||
message, details = f"{context}: {self.exception_summary(exc)}", self.exception_details(exc)
|
||||
(self.warning if level.upper() in {"WARN", "WARNING"} else self.error)(message, details=details)
|
||||
return message, details
|
||||
|
||||
@contextmanager
|
||||
def capture_errors(self) -> Iterator[list[str]]:
|
||||
previous, captured = self._capture, []
|
||||
self._capture = captured
|
||||
try:
|
||||
yield captured
|
||||
finally:
|
||||
self._capture = previous
|
||||
|
||||
@contextmanager
|
||||
def suspend_error_capture(self) -> Iterator[None]:
|
||||
"""Keep unrelated Qt callbacks out of the active web or GPIO action."""
|
||||
previous, self._capture = self._capture, None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture = previous
|
||||
|
||||
def show_error(self, message: str, *, details: str | None = None) -> None:
|
||||
self.error(message, details=details)
|
||||
if self._capture is not None:
|
||||
if not self._capture:
|
||||
self._capture.append(message)
|
||||
return
|
||||
if env_enabled("RADAR_SYSTEM_HEADLESS"):
|
||||
return
|
||||
dialog = QMessageBox(self._parent)
|
||||
dialog.setIcon(QMessageBox.Icon.Critical)
|
||||
dialog.setWindowTitle("Error")
|
||||
dialog.setText(message)
|
||||
if details:
|
||||
dialog.setDetailedText(details)
|
||||
dialog.exec()
|
||||
|
||||
def show_exception(self, context: str, exc: Exception) -> None:
|
||||
self.show_error(f"{context}: {self.exception_summary(exc)}", details=self.exception_details(exc))
|
||||
|
||||
def close(self) -> None:
|
||||
self._timer.stop()
|
||||
package_logger().removeHandler(self._handler)
|
||||
self._handler.close()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Acquire fresh object-calibration sweeps through the existing native preprocessor."""
|
||||
"""Acquire fresh object-calibration sweeps through the native preprocessor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,88 +6,75 @@ import time
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
|
||||
from python_app.gui.controllers.pipeline_controller import PipelineController
|
||||
from python_app.gui.object_calibration_dialog import ObjectCalibrationDialog
|
||||
from python_app.gui.runtime.pipeline_pause import PipelinePause
|
||||
from python_app.models.collection_identity import collection_key
|
||||
from python_app.models.dataset_model import SweepCollection
|
||||
from python_app.models.run_config_model import ComboModel
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
class ObjectSweepCapture(QObject):
|
||||
"""Temporarily acquire the full antenna matrix without applying an object correction."""
|
||||
"""Hold a pipeline pause while collecting a fresh matrix without object correction."""
|
||||
|
||||
finished = pyqtSignal()
|
||||
TIMEOUT_S = 300
|
||||
|
||||
def __init__(self, owner, dialog) -> None:
|
||||
if getattr(owner, "_object_capture_job", None) is not None:
|
||||
raise RuntimeError("Object-calibration capture is already in progress")
|
||||
def __init__(self, pipeline: PipelineController, dialog: ObjectCalibrationDialog,
|
||||
config: RunConfigModel, count: int, provenance: dict) -> None:
|
||||
super().__init__(dialog)
|
||||
owner._object_capture_job = self
|
||||
self._owner = owner
|
||||
self._dialog = dialog
|
||||
self._pipeline, self._dialog = pipeline, dialog
|
||||
self._config, self._count, self._provenance = config, count, provenance
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._poll)
|
||||
self._done = False
|
||||
self._paused = False
|
||||
self._started = False
|
||||
self._resume = False
|
||||
self._done = self._started = self._starting = False
|
||||
self._pending_error: str | None = None
|
||||
self._pause: PipelinePause | None = None
|
||||
self._collections: list[SweepCollection] = []
|
||||
self._seen: set[tuple[int, int]] = set()
|
||||
self._stop_seq = getattr(owner, "_stop_request_seq", 0)
|
||||
self._stop_generation = pipeline.stop_generation
|
||||
self._start_ns = 0
|
||||
dialog.set_capture_pending(True)
|
||||
QTimer.singleShot(0, self._start)
|
||||
|
||||
def _start(self) -> None:
|
||||
if self._done:
|
||||
return
|
||||
owner = self._owner
|
||||
self._starting = True
|
||||
try:
|
||||
if not owner._pipeline_resume_allowed(self._stop_seq):
|
||||
if not self._pipeline.can_resume(self._stop_generation):
|
||||
self.cancel()
|
||||
return
|
||||
if getattr(owner, "_closing", False) or owner._capture_session is not None or owner._preprocess_capture_busy:
|
||||
raise RuntimeError("Finish the active capture before taking object-calibration data")
|
||||
if owner._single_capture_active or getattr(owner, "_recording_active", False):
|
||||
raise RuntimeError("Finish the single capture or disk recording before taking calibration data")
|
||||
self._config = owner._build_config()
|
||||
if self._config.radar.replay.dataset_dir.strip():
|
||||
raise RuntimeError("Object-calibration capture requires live acquisition; disable archive replay")
|
||||
self._config.preprocess.s21.object_calibration.set_name = ""
|
||||
self._config.preprocess.s21.object_calibration.bundle_path = ""
|
||||
tx = sorted({entry.output_pos for entry in self._config.gpr.tx_geometry})
|
||||
rx = sorted({entry.input_pos for entry in self._config.gpr.rx_geometry})
|
||||
if not tx or not rx:
|
||||
raise RuntimeError("Configure Tx/Rx antenna positions before taking calibration data")
|
||||
self._config.combos = [ComboModel(input=i, output=o) for o in tx for i in rx]
|
||||
self._count = owner._ensure_preprocess_dialog().median_sweep_count()
|
||||
self._resume = owner._supervisor.is_running()
|
||||
if self._resume:
|
||||
self._paused = True
|
||||
owner._stop_run()
|
||||
self._stop_seq += 1
|
||||
if not owner._pipeline_resume_allowed(self._stop_seq):
|
||||
self.cancel()
|
||||
return
|
||||
self._source_config = self._config.to_dict()
|
||||
self._pause = self._pipeline.pause()
|
||||
if self._pending_error is not None:
|
||||
return
|
||||
self._start_ns = time.monotonic_ns()
|
||||
owner._start_run(config_override=self._config)
|
||||
self._started = owner._supervisor.is_running()
|
||||
if not self._started:
|
||||
self._started = self._pipeline.start(config_override=self._config, pause=self._pause)
|
||||
if self._pending_error is not None:
|
||||
return
|
||||
if not self._started or not self._pipeline.acquisition_healthy:
|
||||
raise RuntimeError("Calibration acquisition did not start; check the preprocessing sets and log")
|
||||
self._dialog.set_status(f"Taking calibration data: 0 / {self._count} sweeps")
|
||||
self._timer.start(50)
|
||||
except Exception as exc:
|
||||
self._finish(error=str(exc))
|
||||
self._pending_error = str(exc)
|
||||
finally:
|
||||
self._starting = False
|
||||
if self._pending_error is not None:
|
||||
self._finish(error=self._pending_error)
|
||||
|
||||
def _poll(self) -> None:
|
||||
owner = self._owner
|
||||
if self._done:
|
||||
return
|
||||
try:
|
||||
if not owner._pipeline_resume_allowed(self._stop_seq):
|
||||
if self._pause is None or not self._pause.valid:
|
||||
self.cancel()
|
||||
return
|
||||
if not owner._supervisor.is_running():
|
||||
if not self._pipeline.acquisition_healthy:
|
||||
raise RuntimeError("Calibration acquisition stopped before all sweeps were received")
|
||||
for collection in owner._pre_history:
|
||||
identity = (collection.collection_id, collection.monotonic_ns)
|
||||
starts = [collection.capture_start_ns, *(trace.capture_start_ns for trace in collection.traces)]
|
||||
acquired_ns = min((start for start in starts if start > 0), default=collection.monotonic_ns)
|
||||
if acquired_ns < self._start_ns or identity in self._seen:
|
||||
for collection in self._pipeline.history.preprocessed:
|
||||
identity = collection_key(collection)
|
||||
if collection.capture_start_ns < self._start_ns or identity in self._seen:
|
||||
continue
|
||||
self._seen.add(identity)
|
||||
self._collections.append(collection)
|
||||
@@ -95,46 +82,41 @@ class ObjectSweepCapture(QObject):
|
||||
self._finish()
|
||||
return
|
||||
self._dialog.set_status(f"Taking calibration data: {len(self._collections)} / {self._count} sweeps")
|
||||
if time.monotonic_ns() - self._start_ns > owner._SINGLE_CAPTURE_TIMEOUT_S * 1e9:
|
||||
if time.monotonic_ns() - self._start_ns > self.TIMEOUT_S * 1e9:
|
||||
raise TimeoutError("Timed out waiting for preprocessed calibration sweeps")
|
||||
except Exception as exc:
|
||||
self._finish(error=str(exc))
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel this capture and restore the previous acquisition when it is still allowed."""
|
||||
self._finish(error="Calibration capture cancelled")
|
||||
|
||||
def _finish(self, *, error: str | None = None) -> None:
|
||||
if self._done:
|
||||
return
|
||||
if self._starting:
|
||||
# Stop/start drains dispatch Qt events. Keep the lease until startup
|
||||
# returns, so Cancel cannot start a second run inside the first one.
|
||||
self._pending_error = error or "Calibration capture cancelled"
|
||||
return
|
||||
self._done = True
|
||||
self._timer.stop()
|
||||
owner = self._owner
|
||||
resume_allowed = owner._pipeline_resume_allowed(self._stop_seq)
|
||||
try:
|
||||
if self._started and resume_allowed:
|
||||
owner._stop_run()
|
||||
self._stop_seq += 1
|
||||
if not owner._pipeline_resume_allowed(self._stop_seq):
|
||||
if self._started:
|
||||
self._pipeline.stop_pipeline(keep_processor=True)
|
||||
if self._pause is not None and not self._pause.valid:
|
||||
error = "Calibration capture cancelled"
|
||||
if error is None:
|
||||
self._dialog.add_capture(self._collections, self._source_config)
|
||||
self._dialog.add_capture(self._collections, self._config.to_dict(), self._provenance)
|
||||
else:
|
||||
self._dialog.set_status(error)
|
||||
except Exception as exc:
|
||||
self._dialog.set_status(f"Calibration capture failed: {exc}")
|
||||
finally:
|
||||
if getattr(owner, "_object_capture_job", None) is self:
|
||||
owner._object_capture_job = None
|
||||
if self._resume and (self._paused or self._started) and resume_allowed and owner._pipeline_resume_allowed(self._stop_seq):
|
||||
owner._start_run()
|
||||
self._dialog.set_capture_pending(False)
|
||||
# The owner clears its job before finish() can start normal acquisition.
|
||||
self.finished.emit()
|
||||
self.deleteLater()
|
||||
|
||||
|
||||
def capture_object_sweeps(owner, dialog) -> ObjectSweepCapture:
|
||||
"""Start an asynchronous capture; the dialog receives sweeps or a visible error."""
|
||||
capture = ObjectSweepCapture(owner, dialog)
|
||||
dialog.set_capture_pending(True)
|
||||
return capture
|
||||
try:
|
||||
if self._pause is not None:
|
||||
self._pause.finish()
|
||||
finally:
|
||||
self.deleteLater()
|
||||
|
||||
@@ -1,183 +1,181 @@
|
||||
"""GUI coordination for importing and estimating optional object calibration."""
|
||||
"""GUI coordination for estimating and saving optional object calibration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
from python_app.gui.controllers.object_calibration_capture import ObjectSweepCapture
|
||||
from python_app.gui.object_calibration_dialog import ObjectCalibrationDialog
|
||||
from python_app.models.dataset_model import SweepCollection
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
from python_app.workflows.object_calibration import validate_object_set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _calculate(groups, config, settings, cancelled, messages, result) -> None:
|
||||
"""The worker handles only Python data; every Qt action stays on the GUI thread."""
|
||||
@dataclass
|
||||
class _CalculationResult:
|
||||
calibration: tuple[SweepCollection, dict] | None = None
|
||||
error: Exception | None = None
|
||||
cancelled: bool = False
|
||||
|
||||
|
||||
def _calculate(groups: list[dict], config: dict, settings: dict, provenance: dict,
|
||||
cancelled: threading.Event, messages: queue.Queue[str], result: queue.Queue[_CalculationResult]) -> None:
|
||||
"""Only immutable inputs and queues cross the worker boundary, never Qt objects."""
|
||||
try:
|
||||
from python_app.workflows.mimo_calibration import Settings, compute_from_collections
|
||||
|
||||
calibration = compute_from_collections(
|
||||
groups, config, settings=Settings(**settings),
|
||||
groups, config, settings=Settings(**settings), provenance=provenance,
|
||||
progress=messages.put, cancelled=cancelled.is_set,
|
||||
)
|
||||
result.put(("cancelled", None) if cancelled.is_set() else ("ready", calibration))
|
||||
result.put(_CalculationResult(calibration=calibration, cancelled=cancelled.is_set()))
|
||||
except InterruptedError:
|
||||
result.put(("cancelled", None))
|
||||
except Exception as exc: # noqa: BLE001 - report worker failures on the GUI thread
|
||||
result.put(("error", exc))
|
||||
result.put(_CalculationResult(cancelled=True))
|
||||
except Exception as exc:
|
||||
result.put(_CalculationResult(error=exc))
|
||||
|
||||
|
||||
class ObjectCalibrationController(QObject):
|
||||
"""Own one modeless editor and cancellable calculation for an AppWindow."""
|
||||
"""Own one modeless editor, acquisition job and cancellable calculation."""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
super().__init__(owner)
|
||||
self._owner = owner
|
||||
self._dialog: ObjectCalibrationDialog | None = None
|
||||
self._capture = None
|
||||
def __init__(self, parent: QWidget, store: NpzStore, build_config: Callable[[], RunConfigModel],
|
||||
capture: Callable[[ObjectCalibrationDialog], ObjectSweepCapture], select_saved: Callable[[str], None],
|
||||
error: Callable[[str, Exception], None]) -> None:
|
||||
super().__init__(parent)
|
||||
self._parent, self._store, self._build_config = parent, store, build_config
|
||||
self._start_capture, self._select_saved, self._error = capture, select_saved, error
|
||||
self.dialog: ObjectCalibrationDialog | None = None
|
||||
self.capture: ObjectSweepCapture | None = None
|
||||
self._closed = False
|
||||
self._worker: threading.Thread | None = None
|
||||
self._cancel = threading.Event()
|
||||
self._messages: queue.Queue = queue.Queue()
|
||||
self._result: queue.Queue = queue.Queue(maxsize=1)
|
||||
self._messages: queue.Queue[str] = queue.Queue()
|
||||
self._result: queue.Queue[_CalculationResult] = queue.Queue(maxsize=1)
|
||||
self._poll = QTimer(self)
|
||||
self._poll.setInterval(100)
|
||||
self._poll.timeout.connect(self._poll_calculation)
|
||||
self._save_name = ""
|
||||
self._provenance: dict = {}
|
||||
|
||||
def import_calibration(self) -> None:
|
||||
"""Validate and copy a legacy cal_*.npy folder into the app's own store."""
|
||||
if getattr(self._owner, "_closing", False):
|
||||
def open_creation(self, set_name: str) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
if getattr(self._owner, "_object_capture_job", None) is not None or self._worker is not None:
|
||||
self._owner._show_error("Wait for the object-calibration operation to finish")
|
||||
return
|
||||
panel = self._owner._ensure_preprocess_dialog()
|
||||
name = panel.set_name()
|
||||
if not name:
|
||||
self._owner._show_error("Set name is required")
|
||||
return
|
||||
directory = QFileDialog.getExistingDirectory(panel, "Import MIMO calibration")
|
||||
if not directory:
|
||||
if self.dialog is None:
|
||||
self.dialog = ObjectCalibrationDialog(set_name, self._parent)
|
||||
self.dialog.capture_requested.connect(self.capture_data)
|
||||
self.dialog.calculate_requested.connect(self._start_calculation)
|
||||
self.dialog.cancel_requested.connect(self.cancel)
|
||||
self.dialog.show()
|
||||
self.dialog.raise_()
|
||||
self.dialog.activateWindow()
|
||||
|
||||
def capture_data(self) -> None:
|
||||
if self._closed or self.capture is not None or self._worker is not None or self.dialog is None:
|
||||
return
|
||||
try:
|
||||
from python_app.workflows.mimo_calibration import load_calibration, validate_for_config
|
||||
|
||||
config = self._owner._build_config()
|
||||
calibration = load_calibration(Path(directory))
|
||||
validate_for_config(calibration, config.to_dict())
|
||||
radar_key = self._owner._radar_key(config)
|
||||
self._owner._store.save_object_calibration(radar_key, name, calibration)
|
||||
self._select_saved(name)
|
||||
panel.set_status(f"Object calibration imported: {name}. Applies on the next Start.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._owner._show_exception("Failed to import object calibration", exc)
|
||||
|
||||
def open_creation(self) -> None:
|
||||
if getattr(self._owner, "_closing", False):
|
||||
return
|
||||
if self._dialog is None:
|
||||
self._dialog = ObjectCalibrationDialog(self._owner._ensure_preprocess_dialog().set_name(), self._owner)
|
||||
self._dialog.capture_requested.connect(self._capture_data)
|
||||
self._dialog.calculate_requested.connect(self._start_calculation)
|
||||
self._dialog.cancel_requested.connect(self.cancel)
|
||||
self._dialog.show()
|
||||
self._dialog.raise_()
|
||||
self._dialog.activateWindow()
|
||||
|
||||
def _capture_data(self) -> None:
|
||||
if (getattr(self._owner, "_closing", False) or self._capture is not None
|
||||
or self._worker is not None or self._dialog is None):
|
||||
return
|
||||
from python_app.gui.controllers.object_calibration_capture import capture_object_sweeps
|
||||
|
||||
try:
|
||||
self._capture = capture_object_sweeps(self._owner, self._dialog)
|
||||
self._capture.finished.connect(self._capture_finished)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._dialog.set_capture_pending(False)
|
||||
self._dialog.set_status(str(exc))
|
||||
self._owner._show_exception("Failed to capture object-calibration data", exc)
|
||||
self.capture = self._start_capture(self.dialog)
|
||||
self.capture.finished.connect(self._capture_finished)
|
||||
except Exception as exc:
|
||||
self.dialog.set_capture_pending(False)
|
||||
self.dialog.set_status(str(exc))
|
||||
self._error("Failed to capture object-calibration data", exc)
|
||||
|
||||
def _capture_finished(self) -> None:
|
||||
self._capture = None
|
||||
self.capture = None
|
||||
|
||||
def _start_calculation(self) -> None:
|
||||
if getattr(self._owner, "_closing", False) or self._worker is not None or self._capture is not None or self._dialog is None:
|
||||
if self._closed or self._worker is not None or self.capture is not None or self.dialog is None:
|
||||
return
|
||||
try:
|
||||
name = self._dialog.set_name()
|
||||
name = self.dialog.set_name()
|
||||
if not name:
|
||||
raise ValueError("Set name is required")
|
||||
groups, config, settings = self._dialog.calculation_inputs()
|
||||
self._save_name = name
|
||||
groups, config, settings, provenance = self.dialog.calculation_inputs()
|
||||
if name in self._store.list_sets("s21_object_calibration", provenance["radar_key"]):
|
||||
raise FileExistsError(f"Object calibration already exists: {name}")
|
||||
self._provenance, self._save_name = provenance, name
|
||||
self._cancel = threading.Event()
|
||||
self._messages = queue.Queue()
|
||||
self._result = queue.Queue(maxsize=1)
|
||||
self._messages, self._result = queue.Queue(), queue.Queue(maxsize=1)
|
||||
self._worker = threading.Thread(
|
||||
target=_calculate,
|
||||
args=(groups, config, settings, self._cancel, self._messages, self._result),
|
||||
target=_calculate, args=(groups, config, settings, provenance, self._cancel, self._messages, self._result),
|
||||
name="object-calibration", daemon=True,
|
||||
)
|
||||
self._dialog.set_calculating(True)
|
||||
self._dialog.set_status("Расчёт калибровки…")
|
||||
self.dialog.set_calculating(True)
|
||||
self.dialog.set_status("Calculating calibration…")
|
||||
self._worker.start()
|
||||
self._poll.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
self._worker = None
|
||||
self._dialog.set_calculating(False)
|
||||
self._dialog.set_status(f"Ошибка расчёта: {exc}")
|
||||
self._owner._show_exception("Failed to calculate object calibration", exc)
|
||||
self.dialog.set_calculating(False)
|
||||
self.dialog.set_status(f"Calculation failed: {exc}")
|
||||
self._error("Failed to calculate object calibration", exc)
|
||||
|
||||
def _poll_calculation(self) -> None:
|
||||
if self._dialog is None or getattr(self._owner, "_closing", False):
|
||||
if self.dialog is None or self._closed:
|
||||
self._poll.stop()
|
||||
return
|
||||
while not self._messages.empty():
|
||||
self._dialog.set_status(self._messages.get_nowait())
|
||||
self.dialog.set_status(self._messages.get_nowait())
|
||||
try:
|
||||
state, payload = self._result.get_nowait()
|
||||
outcome = self._result.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
self._poll.stop()
|
||||
self._worker = None
|
||||
self._dialog.set_calculating(False)
|
||||
if self._cancel.is_set() or state == "cancelled":
|
||||
self._dialog.set_status("Расчёт отменён. Снятые данные сохранены в этом окне.")
|
||||
self.dialog.set_calculating(False)
|
||||
if self._cancel.is_set() or outcome.cancelled:
|
||||
self.dialog.set_status("Calculation cancelled. Captured data remain in this window.")
|
||||
return
|
||||
if state == "error":
|
||||
self._dialog.set_status(f"Ошибка расчёта: {payload}")
|
||||
self._owner._show_exception("Failed to calculate object calibration", payload)
|
||||
if outcome.error is not None:
|
||||
self.dialog.set_status(f"Calculation failed: {outcome.error}")
|
||||
self._error("Failed to calculate object calibration", outcome.error)
|
||||
return
|
||||
assert outcome.calibration is not None
|
||||
try:
|
||||
from python_app.workflows.mimo_calibration import validate_for_config
|
||||
|
||||
current_config = self._owner._build_config()
|
||||
validate_for_config(payload, current_config.to_dict())
|
||||
radar_key = self._owner._radar_key(current_config)
|
||||
self._owner._store.save_object_calibration(radar_key, self._save_name, payload)
|
||||
self._select_saved(self._save_name)
|
||||
self._owner._ensure_preprocess_dialog().set_set_name(self._save_name)
|
||||
self._dialog.show_calibration(payload)
|
||||
self._dialog.set_status(f"Калибровка сохранена: {self._save_name}. Применится при следующем Start.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._dialog.set_status(f"Калибровка не сохранена: {exc}")
|
||||
self._owner._show_exception("Failed to save object calibration", exc)
|
||||
|
||||
def _select_saved(self, name: str) -> None:
|
||||
self._owner._selected_preprocess_sets["s21_object_calibration"] = name
|
||||
self._owner._refresh_sets()
|
||||
self._owner._log(f"Selected object calibration: {name}")
|
||||
# Save under captured provenance even when the pending GUI profile changed.
|
||||
radar_key = self._provenance["radar_key"]
|
||||
if self._save_name in self._store.list_sets("s21_object_calibration", radar_key):
|
||||
raise FileExistsError(f"Object calibration already exists: {self._save_name}")
|
||||
collection, metadata = outcome.calibration
|
||||
self._store.save_set("s21_object_calibration", radar_key, self._save_name, collection, metadata=metadata)
|
||||
self.dialog.show_calibration(metadata)
|
||||
status = f"Calibration saved: {self._save_name} (capture radar: {radar_key})."
|
||||
try:
|
||||
current = self._build_config()
|
||||
current_key = current.radar_key()
|
||||
through = self._store.load_set("s21_calibration", current_key, current.preprocess.s21.calibration.set_name)
|
||||
validate_object_set(collection, metadata, current.to_dict(), current_key, through)
|
||||
except Exception as exc:
|
||||
status += f" Not selected for the current configuration: {exc}"
|
||||
else:
|
||||
self._select_saved(self._save_name)
|
||||
status += " Applies on the next Start."
|
||||
self.dialog.set_status(status)
|
||||
logger.info("%s", status)
|
||||
except Exception as exc:
|
||||
self.dialog.set_status(f"Calibration could not be saved: {exc}")
|
||||
self._error("Failed to save object calibration", exc)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancel.set()
|
||||
if self._capture is not None:
|
||||
self._capture.cancel()
|
||||
if self._worker is not None and self._dialog is not None:
|
||||
self._dialog.set_status("Отмена расчёта…")
|
||||
if self.capture is not None:
|
||||
self.capture.cancel()
|
||||
if self._worker is not None and self.dialog is not None:
|
||||
self.dialog.set_status("Cancelling calculation…")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._closed = True
|
||||
self.cancel()
|
||||
self._poll.stop()
|
||||
if self._dialog is not None:
|
||||
self._dialog.close()
|
||||
if self.dialog is not None:
|
||||
self.dialog.close()
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
"""Own pipeline processes, shared ingestion, recovery, and exclusive operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import QCoreApplication, QEventLoop, QTimer
|
||||
|
||||
from python_app.gui.controllers.bscan_replay import BscanReplay
|
||||
from python_app.gui.controllers.history_controller import HistoryController
|
||||
from python_app.gui.controllers.sections.views import PrimaryView
|
||||
from python_app.gui.controllers.plot_controller import PlotController
|
||||
from python_app.gui.controllers.recording_controller import RecordingController
|
||||
from python_app.gui.controllers.renderers.sar3d import SAR3D_STATES, sar3d_status
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.runtime.history import build_processor_run_signature, build_run_history_signature
|
||||
from python_app.gui.runtime.pipeline_pause import PipelinePause
|
||||
from python_app.gui.settings_defaults import env_enabled
|
||||
from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.collection_identity import collection_key, index_collections
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.models.run_config_validation import validate_acquisition_config
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||
from python_app.orchestration.pipeline_metrics import PipelineMetrics
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_NAMES, REQUIRED_PREPROCESS_ASSET_KEYS,
|
||||
preprocess_asset_model, runtime_preprocess_asset_keys,
|
||||
)
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.restart_policy import RestartPolicy
|
||||
from python_app.orchestration.shm.ring_reader import ShmRingReader
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
from python_app.workflows.object_calibration import validate_object_set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
Readers = tuple[ShmRingReader | None, ShmRingReader | None, ShmRingReader | None]
|
||||
|
||||
|
||||
class PipelineController:
|
||||
_READER_ERROR_RELOG_EVERY = 200
|
||||
_READER_ERROR_RECONNECT_AT = 40
|
||||
_READER_ERROR_STOP_AT = 400
|
||||
_RESTART_POLICY = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0)
|
||||
SINGLE_CAPTURE_TIMEOUT_S = 300.0
|
||||
|
||||
def __init__(
|
||||
self, project_root: Path, store: NpzStore, config_writer: ConfigWriter,
|
||||
history: HistoryController, plots: PlotController, recording: RecordingController,
|
||||
*, build_config: Callable[[], RunConfigModel], live_config: Callable[[], ProcessingLiveConfig],
|
||||
publish_live: Callable[[], None], capture_guard: Callable[[], str | None],
|
||||
refresh_radar_limits: Callable[[], bool], status: Callable[[str], None],
|
||||
history_changed: Callable[[], None], poll_ui: Callable[[], None],
|
||||
show_exception: Callable[[str, Exception], None], deferred_close: Callable[[], object],
|
||||
metrics_every: int | None = None,
|
||||
event_scope: Callable[[], AbstractContextManager[object]] = nullcontext,
|
||||
) -> None:
|
||||
self._project_root, self._store, self._config_writer = project_root, store, config_writer
|
||||
self.history, self.plots, self.recording = history, plots, recording
|
||||
self._build_config, self._live_config, self.publish_live = build_config, live_config, publish_live
|
||||
self._capture_guard, self._refresh_radar_limits = capture_guard, refresh_radar_limits
|
||||
self._status, self.history_changed, self._poll_ui = status, history_changed, poll_ui
|
||||
self._show_exception, self._deferred_close = show_exception, deferred_close
|
||||
self._event_scope = event_scope
|
||||
self.supervisor = ProcessSupervisor(project_root)
|
||||
self.metrics = PipelineMetrics(report_every=self._metrics_every() if metrics_every is None else metrics_every,
|
||||
log_sink=logger.info)
|
||||
self.timer = QTimer()
|
||||
self.timer.setInterval(50)
|
||||
self.timer.timeout.connect(self.poll)
|
||||
self.replay = BscanReplay(history, self.reprocess_history, self._warning, logger.debug)
|
||||
self._warnings: set[str] = set()
|
||||
self._raw_reader: ShmRingReader | None = None
|
||||
self._pre_reader: ShmRingReader | None = None
|
||||
self._result_reader: ShmRingReader | None = None
|
||||
self.active_config: RunConfigModel | None = None
|
||||
self.active_config_path: Path | None = None
|
||||
self.stop_generation = 0
|
||||
self.should_run = False
|
||||
self._pause: PipelinePause | None = None
|
||||
self._stopping = self._closing = self._close_deferred = self._stop_all_requested = False
|
||||
self._processor_signature: tuple[object, ...] | None = None
|
||||
self.single_capture_active = False
|
||||
self._single_capture_start_ns: int | None = None
|
||||
self._single_capture_target_key: tuple[int, int] | None = None
|
||||
self.max_pop_per_poll, self.max_pop_per_snapshot_drain = 256, 4096
|
||||
self._last_reader_error_signature: tuple[str, str] | None = None
|
||||
self._reader_error_repeat_count = self._restart_count = 0
|
||||
self._last_restart_s = 0.0
|
||||
self._ui_errors: set[tuple[str, str, str]] = set()
|
||||
|
||||
def bind_actions(self, view: PrimaryView) -> None:
|
||||
view.start.clicked.connect(lambda: self.start())
|
||||
view.single_capture.clicked.connect(lambda: self.start(single_capture=True))
|
||||
view.stop.clicked.connect(self.stop)
|
||||
view.apply_radar.clicked.connect(self.apply_radar)
|
||||
|
||||
@staticmethod
|
||||
def _metrics_every() -> int:
|
||||
raw = os.environ.get("RADAR_SYSTEM_METRICS_REPORT_EVERY", "").strip()
|
||||
try:
|
||||
value = int(raw) if raw else 50
|
||||
if value >= 1:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
logger.warning("Ignoring invalid RADAR_SYSTEM_METRICS_REPORT_EVERY=%r; using default 50.", raw)
|
||||
return 50
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.supervisor.is_running()
|
||||
|
||||
@property
|
||||
def processor_running(self) -> bool:
|
||||
return self.supervisor.is_processor_running()
|
||||
|
||||
@property
|
||||
def acquisition_healthy(self) -> bool:
|
||||
return {"acquisition", "data_preprocessor"}.issubset(self.supervisor.pids())
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
return self._pause is not None
|
||||
|
||||
@property
|
||||
def stopping(self) -> bool:
|
||||
return self._stopping
|
||||
|
||||
@property
|
||||
def closing(self) -> bool:
|
||||
return self._closing
|
||||
|
||||
@property
|
||||
def readers(self) -> Readers:
|
||||
return self._raw_reader, self._pre_reader, self._result_reader
|
||||
|
||||
def _warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
if once_key is not None:
|
||||
if once_key in self._warnings:
|
||||
return
|
||||
self._warnings.add(once_key)
|
||||
logger.warning("%s%s", text, f"\n{details}" if details else "")
|
||||
|
||||
def can_resume(self, generation: int) -> bool:
|
||||
return not self._closing and self.stop_generation == generation
|
||||
|
||||
def operation_cancelled(self) -> None:
|
||||
if not self._closing:
|
||||
logger.info("Pipeline operation cancelled by Stop")
|
||||
|
||||
def schedule_startup(self) -> None:
|
||||
if not env_enabled("RADAR_SYSTEM_AUTO_START"):
|
||||
return
|
||||
generation = self.stop_generation
|
||||
|
||||
def start() -> None:
|
||||
if not self.can_resume(generation):
|
||||
return
|
||||
started = self.start()
|
||||
if not started and env_enabled("RADAR_SYSTEM_HEADLESS") and self.can_resume(generation):
|
||||
logger.error("Headless auto-start did not bring the pipeline up")
|
||||
app = QCoreApplication.instance()
|
||||
if app is not None:
|
||||
app.exit(1)
|
||||
|
||||
def apply_then_start() -> None:
|
||||
if self.can_resume(generation):
|
||||
self.apply_radar()
|
||||
QTimer.singleShot(1000, start)
|
||||
|
||||
logger.info("Auto-start requested by launcher")
|
||||
QTimer.singleShot(500, apply_then_start if env_enabled("RADAR_SYSTEM_AUTO_APPLY_RADAR") else start)
|
||||
|
||||
def pause(self) -> PipelinePause:
|
||||
if self.paused or self._stopping or self._closing:
|
||||
raise RuntimeError("Wait for the current pipeline operation to finish")
|
||||
generation, resume = self.stop_generation, self.should_run
|
||||
|
||||
def release(requested: bool) -> None:
|
||||
if self._pause is operation:
|
||||
self._pause = None
|
||||
if requested and resume and self.can_resume(generation):
|
||||
if not self.start() and self.can_resume(generation):
|
||||
self.should_run = True
|
||||
self._last_restart_s = time.monotonic()
|
||||
self._restart_count += 1
|
||||
logger.error("Pipeline resume failed; automatic restart will retry")
|
||||
|
||||
operation = PipelinePause(lambda: self._pause is operation and self.can_resume(generation), release)
|
||||
# Ownership precedes shutdown: drain pumps Qt and may receive Stop or Close.
|
||||
self._pause = operation
|
||||
try:
|
||||
error = self.stop_pipeline(keep_processor=True)
|
||||
if error is not None:
|
||||
raise error
|
||||
if not operation.valid:
|
||||
raise InterruptedError("Pipeline operation cancelled")
|
||||
return operation
|
||||
except BaseException:
|
||||
operation.finish(resume=False)
|
||||
raise
|
||||
|
||||
def _validate_assets(self, config: RunConfigModel) -> None:
|
||||
missing = [PREPROCESS_ASSET_NAMES[key] for key in REQUIRED_PREPROCESS_ASSET_KEYS
|
||||
if not preprocess_asset_model(config, key).set_name]
|
||||
if missing:
|
||||
raise RuntimeError("Select all required preprocess sets in Preprocessing Panel before Start: " + ", ".join(missing))
|
||||
radar_key = config.radar_key()
|
||||
combos = [ComboKey(input=combo.input, output=combo.output) for combo in config.combos]
|
||||
keys = runtime_preprocess_asset_keys(config)
|
||||
logger.info("Active preprocess assets for run: %s", "; ".join(
|
||||
f"{PREPROCESS_ASSET_NAMES[key]}={preprocess_asset_model(config, key).set_name}" for key in keys))
|
||||
for key in keys:
|
||||
asset = preprocess_asset_model(config, key)
|
||||
if not self._store.has_combo_coverage(key, radar_key, asset.set_name, combos):
|
||||
raise RuntimeError(f"Selected {PREPROCESS_ASSET_NAMES[key]} set does not cover requested run combos")
|
||||
name = config.preprocess.s21.object_calibration.set_name
|
||||
if name:
|
||||
collection, metadata = self._store.load_set_with_metadata("s21_object_calibration", radar_key, name)
|
||||
through = self._store.load_set("s21_calibration", radar_key, config.preprocess.s21.calibration.set_name)
|
||||
validate_object_set(collection, metadata, config.to_dict(), radar_key, through)
|
||||
|
||||
def start(self, *, single_capture: bool = False, config_override: RunConfigModel | None = None,
|
||||
pause: PipelinePause | None = None) -> bool:
|
||||
if self._closing:
|
||||
return False
|
||||
generation = self.stop_generation
|
||||
starting = False
|
||||
try:
|
||||
if self._stopping:
|
||||
raise RuntimeError("Wait for pipeline shutdown before starting again")
|
||||
if config_override is None or pause is None:
|
||||
reason = self._capture_guard()
|
||||
if reason:
|
||||
raise RuntimeError(reason)
|
||||
if self._pause is not pause or (pause is not None and not pause.valid):
|
||||
raise RuntimeError("Wait for the current pipeline operation to finish")
|
||||
config = config_override.clone() if config_override is not None else self._build_config()
|
||||
validate_acquisition_config(config)
|
||||
if config_override is None:
|
||||
live = self._live_config()
|
||||
validate_processing_mode_constraints(live.processor_mode, config, live)
|
||||
self._validate_assets(config)
|
||||
run_signature, processor_signature = build_run_history_signature(config), build_processor_run_signature(config)
|
||||
if self.running:
|
||||
if not single_capture:
|
||||
raise RuntimeError("Pipeline is already running")
|
||||
starting = True
|
||||
logger.info("Stopping continuous pipeline before single capture")
|
||||
self.stop_pipeline(keep_processor=True)
|
||||
if not self.can_resume(generation):
|
||||
return False
|
||||
starting = True
|
||||
processor_was_running = self.processor_running
|
||||
if self._processor_requires_restart(processor_signature):
|
||||
logger.info("Restarting data_processor because stable run settings changed")
|
||||
self.stop_all()
|
||||
if not self.can_resume(generation):
|
||||
return False
|
||||
processor_was_running = False
|
||||
self._config_writer.prepare_preprocess_bundles(self._store, config.radar_key(), config)
|
||||
if not processor_was_running:
|
||||
self.reset_history()
|
||||
config.runtime.continuous = not single_capture
|
||||
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
|
||||
logger.info("Starting pipeline: mode=%s, config=%s, combos=%d, radar_key=%s",
|
||||
"single_capture" if single_capture else "continuous", config_path, len(config.combos), config.radar_key())
|
||||
if self.history.run_signature is not None and self.history.run_signature != run_signature:
|
||||
if processor_was_running:
|
||||
self.reset_history()
|
||||
self.history.apply_deletion("clear_all")
|
||||
self.publish_live()
|
||||
logger.info("History reset because run settings changed")
|
||||
self.history.run_signature = run_signature
|
||||
# Timestamp before spawn; a fast one-shot producer can publish inside start().
|
||||
started_ns = time.monotonic_ns() if single_capture else None
|
||||
self.replay.close()
|
||||
self.discard_replay()
|
||||
if not self.can_resume(generation) or (pause is not None and not pause.valid):
|
||||
return False
|
||||
self.supervisor.start(config_path, allow_clean_acquisition_exit=single_capture)
|
||||
self._open_readers(config)
|
||||
self._processor_signature = processor_signature
|
||||
self.single_capture_active, self._single_capture_start_ns = single_capture, started_ns
|
||||
self._single_capture_target_key = None
|
||||
if not single_capture:
|
||||
self.drop_pending()
|
||||
self._last_reader_error_signature, self._reader_error_repeat_count = None, 0
|
||||
self.active_config, self.active_config_path = config, config_path
|
||||
self.should_run, self._restart_count = not single_capture and pause is None, 0
|
||||
self._status("Status: single capture running" if single_capture else "Status: running")
|
||||
logger.info("Pipeline started; managed processes: %s", self.supervisor.pids())
|
||||
return self.can_resume(generation) and self.running
|
||||
except Exception as exc:
|
||||
# Refused actions must not stop another operation or an existing run.
|
||||
if starting:
|
||||
self.stop_all()
|
||||
self._show_exception("Failed to start pipeline", exc)
|
||||
return False
|
||||
|
||||
def _processor_requires_restart(self, signature: tuple[object, ...]) -> bool:
|
||||
return self.processor_running and self._processor_signature != signature
|
||||
|
||||
def apply_radar(self) -> None:
|
||||
try:
|
||||
reason = self._capture_guard()
|
||||
if reason:
|
||||
raise RuntimeError(reason)
|
||||
config = self._build_config()
|
||||
validate_acquisition_config(config)
|
||||
processor_only = self.processor_running and not self.running
|
||||
with self.pause() as operation:
|
||||
if config.radar.driver_mode == "native" and not config.radar.replay.dataset_dir:
|
||||
self._refresh_radar_limits()
|
||||
if not operation.valid:
|
||||
return
|
||||
if processor_only and self._processor_requires_restart(build_processor_run_signature(config)):
|
||||
self.stop_all()
|
||||
if not operation.valid:
|
||||
return
|
||||
self.reset_history()
|
||||
self.history.run_signature = None
|
||||
self._prepare_radar(config)
|
||||
logger.info("Radar settings applied: start=%g Hz, stop=%g Hz, points=%s, ifbw=%g Hz, power=%g dBm",
|
||||
config.radar.sweep.start_hz, config.radar.sweep.stop_hz,
|
||||
"from Kamil ADC stream" if config.is_kamil_adc else config.radar.sweep.points,
|
||||
config.radar.sweep.if_bandwidth_hz, config.radar.sweep.power_dbm)
|
||||
except InterruptedError:
|
||||
self.operation_cancelled()
|
||||
except Exception as exc:
|
||||
self._show_exception("Failed to apply radar settings", exc)
|
||||
|
||||
@staticmethod
|
||||
def _prepare_radar(config: RunConfigModel) -> None:
|
||||
if config.radar.replay.dataset_dir or config.radar.driver_mode != "native":
|
||||
logger.info("Radar pre-configuration skipped (replay or mock mode)")
|
||||
elif config.is_multi_device:
|
||||
logger.info("Multi-device raw producer will configure all LibreVNA devices")
|
||||
elif config.is_kamil_adc:
|
||||
applied = apply_kamil_adc_laser_control(config)
|
||||
logger.info("Kamil ADC laser_control %s", "applied via Apply Radar" if applied else "disabled")
|
||||
else:
|
||||
service = create_single_radar_service(config)
|
||||
try:
|
||||
service.open()
|
||||
service.configure(config.radar.sweep)
|
||||
finally:
|
||||
service.close()
|
||||
logger.info("Radar pre-configured via Python driver: model=%s", config.radar.model)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_generation += 1
|
||||
self.stop_pipeline(keep_processor=True)
|
||||
|
||||
def stop_all(self) -> None:
|
||||
self.stop_pipeline(keep_processor=False)
|
||||
|
||||
def stop_pipeline(self, *, keep_processor: bool) -> Exception | None:
|
||||
self.should_run = False
|
||||
self.replay.close()
|
||||
if self._stopping:
|
||||
self._stop_all_requested |= not keep_processor
|
||||
return
|
||||
self._stopping, self._stop_all_requested = True, not keep_processor
|
||||
failure: tuple[str, Exception] | None = None
|
||||
try:
|
||||
drain_error: Exception | None = None
|
||||
try:
|
||||
if keep_processor and self.running:
|
||||
self.supervisor.stop_acquisition()
|
||||
self.drain_rings(timeout_s=0.35, poll_s=0.02)
|
||||
self.supervisor.stop_preprocessor()
|
||||
self.drain_rings(timeout_s=0.25, poll_s=0.02)
|
||||
elif keep_processor:
|
||||
self.supervisor.stop()
|
||||
self.drain_once()
|
||||
if self._stop_all_requested:
|
||||
self.supervisor.stop_all()
|
||||
self.drain_rings(timeout_s=0.25, poll_s=0.02)
|
||||
except Exception as exc:
|
||||
drain_error = exc
|
||||
self.stop_generation += 1
|
||||
(self.supervisor.stop_all if self._stop_all_requested else self.supervisor.stop)()
|
||||
if self._stop_all_requested:
|
||||
self._processor_signature = None
|
||||
self.recording.finish(reason="pipeline pause" if self.paused else "pipeline closed" if self._closing else "pipeline stopped")
|
||||
keep_results = not self._stop_all_requested and self.processor_running
|
||||
ring_name = self._result_reader.name if self._result_reader is not None else None
|
||||
self._close_readers(keep_results=keep_results and drain_error is None)
|
||||
self.single_capture_active, self._single_capture_start_ns, self._single_capture_target_key = False, None, None
|
||||
self.history_changed()
|
||||
self._status("Status: idle")
|
||||
logger.info("Acquisition stopped (data_processor kept running)" if keep_results else "Pipeline stopped")
|
||||
if drain_error is not None:
|
||||
self._status("Status: error")
|
||||
failure = "Pipeline stopped, but final ring data could not be read", drain_error
|
||||
if keep_results and ring_name and not self._closing:
|
||||
self._result_reader = ShmRingReader(ring_name)
|
||||
except Exception as exc:
|
||||
self.stop_generation += 1
|
||||
self._status("Status: error")
|
||||
failure = "Failed to stop pipeline", exc
|
||||
finally:
|
||||
self._stopping = False
|
||||
if self._close_deferred:
|
||||
self._close_deferred = False
|
||||
self._deferred_close()
|
||||
if failure is not None:
|
||||
if self.paused or self._closing:
|
||||
logger.error(failure[0], exc_info=failure[1])
|
||||
else:
|
||||
self._show_exception(*failure)
|
||||
return failure[1]
|
||||
return None
|
||||
|
||||
def begin_close(self) -> bool:
|
||||
if not self._closing:
|
||||
self.stop_generation += 1
|
||||
self._closing = True
|
||||
self.timer.stop()
|
||||
self.replay.close()
|
||||
if self._stopping:
|
||||
self._stop_all_requested = self._close_deferred = True
|
||||
return False
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
if not self.begin_close():
|
||||
return
|
||||
if self.recording.active:
|
||||
self.stop_pipeline(keep_processor=True)
|
||||
self.stop_all()
|
||||
|
||||
def _close_readers(self, *, keep_results: bool = False) -> None:
|
||||
raw, pre, result = self.readers
|
||||
self._raw_reader = self._pre_reader = None
|
||||
if not keep_results:
|
||||
self._result_reader = None
|
||||
for reader in (raw, pre, None if keep_results else result):
|
||||
if reader is not None:
|
||||
reader.close()
|
||||
|
||||
def _open_readers(self, config: RunConfigModel) -> None:
|
||||
self._close_readers()
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name) if config.rings.raw_tap.name else None
|
||||
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name) if config.rings.preprocessed_tap.name else None
|
||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||
|
||||
def poll(self) -> None:
|
||||
if self._closing or self._stopping:
|
||||
return
|
||||
self._poll_ui()
|
||||
self._handle_process_exit_reports()
|
||||
try:
|
||||
latest = self.read_raw(), self.read_preprocessed(), self.read_results()
|
||||
except Exception as exc:
|
||||
self._handle_reader_poll_error(exc)
|
||||
return
|
||||
if any(collection is not None for collection in latest):
|
||||
self._last_reader_error_signature, self._reader_error_repeat_count = None, 0
|
||||
for action in (self.recording.poll, self.history_changed):
|
||||
try:
|
||||
action()
|
||||
except Exception as exc:
|
||||
self._report_ui_error("Runtime display/recording", exc)
|
||||
try:
|
||||
if self.single_capture_active:
|
||||
try:
|
||||
self._finish_single_capture_if_ready()
|
||||
finally:
|
||||
if self.single_capture_active:
|
||||
self._check_single_capture_deadline()
|
||||
elif latest[2] is not None:
|
||||
self._restart_count = 0
|
||||
started = time.monotonic_ns()
|
||||
self.plots.draw(latest[2])
|
||||
self.metrics.record("rendering", time.monotonic_ns() - started)
|
||||
except Exception as exc:
|
||||
self._report_ui_error("Result display", exc)
|
||||
|
||||
def _report_ui_error(self, context: str, exc: Exception) -> None:
|
||||
self._status(f"Status: {context.lower()} error (acquisition continues)")
|
||||
signature = context, type(exc).__name__, str(exc)
|
||||
if signature not in self._ui_errors:
|
||||
self._ui_errors.add(signature)
|
||||
logger.error("%s failed; acquisition continues", context, exc_info=exc)
|
||||
|
||||
def _handle_process_exit_reports(self) -> None:
|
||||
unexpected = False
|
||||
try:
|
||||
for report in self.supervisor.collect_exit_reports():
|
||||
if report.level == "INFO":
|
||||
logger.info(report.format())
|
||||
else:
|
||||
self._status("Status: error")
|
||||
logger.error(report.format())
|
||||
unexpected = True
|
||||
if not self.paused and self.should_run and (unexpected or not
|
||||
{"data_processor", "data_preprocessor", "acquisition"}.issubset(self.supervisor.pids())):
|
||||
self._recover_pipeline_after_crash()
|
||||
except Exception:
|
||||
logger.exception("Failed to collect process exit reports")
|
||||
|
||||
def _recover_pipeline_after_crash(self) -> None:
|
||||
if self.paused or self._closing:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if not self._RESTART_POLICY.should_restart_now(now_s=now, last_restart_s=self._last_restart_s,
|
||||
consecutive_failures=self._restart_count):
|
||||
return
|
||||
self._last_restart_s, self._restart_count = now, self._restart_count + 1
|
||||
config, path = self.active_config, self.active_config_path
|
||||
if config is None or path is None:
|
||||
self.should_run = False
|
||||
logger.error("Cannot auto-restart pipeline: no active run configuration recorded.")
|
||||
return
|
||||
logger.error("Pipeline process exited unexpectedly; restarting (attempt %d, next back-off %.0fs).",
|
||||
self._restart_count, self._RESTART_POLICY.backoff_for(self._restart_count))
|
||||
try:
|
||||
# No event-pumping drain in recovery: polling must not re-enter this restart.
|
||||
self.supervisor.stop_all()
|
||||
self._close_readers()
|
||||
self.supervisor.start(path, allow_clean_acquisition_exit=False)
|
||||
self._open_readers(config)
|
||||
self._processor_signature = build_processor_run_signature(config)
|
||||
self.drop_pending()
|
||||
self._status("Status: running")
|
||||
logger.info("Pipeline auto-restarted after crash.")
|
||||
except Exception:
|
||||
logger.exception("Pipeline auto-restart failed; will retry")
|
||||
|
||||
def _handle_reader_poll_error(self, exc: Exception) -> None:
|
||||
signature = type(exc).__name__, str(exc)
|
||||
self._status("Status: error")
|
||||
self._reader_error_repeat_count += 1
|
||||
if self._last_reader_error_signature != signature:
|
||||
self._last_reader_error_signature = signature
|
||||
logger.error("Reader poll failed", exc_info=exc)
|
||||
repeats = self._reader_error_repeat_count
|
||||
if repeats % self._READER_ERROR_RELOG_EVERY == 0:
|
||||
logger.error("Reader poll still failing (repeat #%d)", repeats, exc_info=exc)
|
||||
if repeats >= self._READER_ERROR_STOP_AT:
|
||||
logger.error("Stopping pipeline after %d consecutive reader poll failures", repeats)
|
||||
self._last_reader_error_signature, self._reader_error_repeat_count = None, 0
|
||||
self.stop_all()
|
||||
elif repeats == self._READER_ERROR_RECONNECT_AT:
|
||||
try:
|
||||
self._raw_reader = self._reconnect(self._raw_reader)
|
||||
self._pre_reader = self._reconnect(self._pre_reader)
|
||||
self._result_reader = self._reconnect(self._result_reader)
|
||||
logger.info("Ring readers reconnected after repeated poll failures")
|
||||
except Exception:
|
||||
logger.exception("Ring reader reconnect failed")
|
||||
|
||||
@staticmethod
|
||||
def _reconnect(reader: ShmRingReader | None) -> ShmRingReader | None:
|
||||
if reader is None:
|
||||
return None
|
||||
name = reader.name
|
||||
reader.close()
|
||||
return ShmRingReader(name)
|
||||
|
||||
def _finish_single_capture_if_ready(self) -> bool:
|
||||
if not self.single_capture_active or self._single_capture_target_key is None:
|
||||
return False
|
||||
result = index_collections(self.history.results).get(self._single_capture_target_key)
|
||||
if result is None or not self.plots.has_output(result):
|
||||
return False
|
||||
try:
|
||||
self.plots.draw(result)
|
||||
except Exception as exc:
|
||||
self._report_ui_error("Result display", exc)
|
||||
status = sar3d_status(result) if self.plots.stack.currentWidget() is self.plots.sar3d.widget else None
|
||||
self.stop()
|
||||
if status is not None and status[0] != 0:
|
||||
state = SAR3D_STATES[status[0]]
|
||||
self._status(f"Status: {state}")
|
||||
(logger.error if status[0] == 2 else logger.warning)("Single capture: SAR3D %s: %s", state, status[1])
|
||||
else:
|
||||
logger.info("Single capture completed")
|
||||
return True
|
||||
|
||||
def _check_single_capture_deadline(self) -> None:
|
||||
start = self._single_capture_start_ns
|
||||
if start is not None and time.monotonic_ns() - start > self.SINGLE_CAPTURE_TIMEOUT_S * 1e9:
|
||||
logger.error("Single capture timed out after %.0fs with no result; stopping.", self.SINGLE_CAPTURE_TIMEOUT_S)
|
||||
self.stop()
|
||||
|
||||
def read_raw(self, *, max_collections: int | None = None) -> SweepCollection | None:
|
||||
latest = None
|
||||
if self._raw_reader is not None:
|
||||
for _ in range(self.max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self.history.raw.append(collection)
|
||||
self.recording.record("raw", collection)
|
||||
self.metrics.record("acquisition", collection.capture_end_ns - collection.capture_start_ns)
|
||||
self._select_single_target(collection)
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def read_preprocessed(self, *, max_collections: int | None = None) -> SweepCollection | None:
|
||||
latest = None
|
||||
if self._pre_reader is not None:
|
||||
for _ in range(self.max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._pre_reader.pop_preprocessed_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self.history.preprocessed.append(collection)
|
||||
self.recording.record("preprocessed", collection)
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def read_results(self, *, max_collections: int | None = None) -> ResultCollection | None:
|
||||
latest = None
|
||||
if self._result_reader is not None:
|
||||
for _ in range(self.max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
break
|
||||
if self.history.is_removed(collection):
|
||||
continue
|
||||
if self._raw_reader is None:
|
||||
self._select_single_target(collection)
|
||||
self.metrics.record("processing", collection.processing_duration_ns)
|
||||
if self.history.record_result(collection):
|
||||
self.recording.record("results", collection)
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def _select_single_target(self, collection: SweepCollection | ResultCollection) -> None:
|
||||
if (self.single_capture_active and self._single_capture_target_key is None
|
||||
and self._single_capture_start_ns is not None and collection.capture_start_ns >= self._single_capture_start_ns):
|
||||
self._single_capture_target_key = collection_key(collection)
|
||||
|
||||
def pump_events(self, pause_s: float) -> None:
|
||||
app = QCoreApplication.instance()
|
||||
if app is None:
|
||||
time.sleep(pause_s)
|
||||
return
|
||||
deadline = time.monotonic() + pause_s
|
||||
while (remaining_ms := int((deadline - time.monotonic()) * 1000)) > 0:
|
||||
with self._event_scope():
|
||||
app.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, remaining_ms)
|
||||
time.sleep(min(0.002, max(0.0, deadline - time.monotonic())))
|
||||
|
||||
def drain_once(self) -> bool:
|
||||
return any(collection is not None for collection in (self.read_raw(), self.read_preprocessed(), self.read_results()))
|
||||
|
||||
def drain_rings(self, *, timeout_s: float, poll_s: float) -> None:
|
||||
deadline, stable = time.monotonic() + timeout_s, 0
|
||||
while time.monotonic() < deadline and stable < 2:
|
||||
stable = 0 if self.drain_once() else stable + 1
|
||||
self.pump_events(poll_s)
|
||||
|
||||
def drain_results(self, *, timeout_s: float, poll_s: float) -> ResultCollection | None:
|
||||
deadline, stable = time.monotonic() + timeout_s, 0
|
||||
latest_seen = None
|
||||
while self._result_reader is not None and time.monotonic() < deadline and (latest_seen is None or stable < 2):
|
||||
latest = self.read_results()
|
||||
if latest is not None:
|
||||
latest_seen, stable = latest, 0
|
||||
elif latest_seen is not None:
|
||||
stable += 1
|
||||
self.pump_events(poll_s)
|
||||
return latest_seen
|
||||
|
||||
def drain_snapshot(self) -> None:
|
||||
initial_readers = self.readers
|
||||
if all(reader is None for reader in initial_readers):
|
||||
return
|
||||
try:
|
||||
observed = [False, False, False]
|
||||
deadline = time.monotonic() + 1.2
|
||||
while time.monotonic() < deadline and not self._closing:
|
||||
latest = (self.read_raw(max_collections=self.max_pop_per_snapshot_drain),
|
||||
self.read_preprocessed(max_collections=self.max_pop_per_snapshot_drain),
|
||||
self.read_results(max_collections=self.max_pop_per_snapshot_drain))
|
||||
observed = [seen or value is not None for seen, value in zip(observed, latest)]
|
||||
if any(value is not None for value in latest):
|
||||
continue
|
||||
missing = any(reader is not None and not seen for reader, seen in zip(initial_readers, observed))
|
||||
awaiting = self._result_reader is not None and max(len(self.history.raw), len(self.history.preprocessed)) > len(self.history.results)
|
||||
if (any(observed) and missing) or awaiting:
|
||||
self.pump_events(0.01)
|
||||
else:
|
||||
break
|
||||
except Exception:
|
||||
logger.warning("Snapshot drain warning", exc_info=True)
|
||||
|
||||
def drop_pending(self, *, include_results: bool = True) -> tuple[int, int, int]:
|
||||
raw, pre, result = self.readers
|
||||
dropped = (raw.drop_all() if raw is not None else 0, pre.drop_all() if pre is not None else 0,
|
||||
result.drop_all() if include_results and result is not None else 0)
|
||||
if any(dropped):
|
||||
logger.info("Ring reset: raw=%d, preprocessed=%d, results=%d", *dropped)
|
||||
return dropped
|
||||
|
||||
def settle_deleted_results(self) -> None:
|
||||
if self.processor_running:
|
||||
self.drain_results(timeout_s=0.35, poll_s=0.01)
|
||||
if self._result_reader is not None:
|
||||
self._result_reader.drop_all()
|
||||
|
||||
def reset_history(self) -> None:
|
||||
self.history.raw.clear()
|
||||
self.history.preprocessed.clear()
|
||||
self.history.results.clear()
|
||||
self.discard_replay()
|
||||
self.plots.clear()
|
||||
self.history_changed()
|
||||
|
||||
def preprocessing_changed(self, *, restart_processor: bool = False) -> None:
|
||||
self.history.run_signature = None
|
||||
if restart_processor:
|
||||
self._processor_signature = None
|
||||
try:
|
||||
if self.processor_running:
|
||||
self.history.apply_deletion("clear_all")
|
||||
self.publish_live()
|
||||
finally:
|
||||
self.reset_history()
|
||||
|
||||
def can_reprocess_history(self, *, mode: str | None = None) -> bool:
|
||||
bscan = mode == "bscan" if mode is not None else self.plots.stack.currentWidget() is self.plots.bscan.widget
|
||||
return (not self._closing and bscan
|
||||
and bool(self.history.preprocessed) and not self.running and self.processor_running
|
||||
and self._result_reader is not None)
|
||||
|
||||
def schedule_replay(self) -> None:
|
||||
self.replay.schedule(self.can_reprocess_history())
|
||||
|
||||
def discard_replay(self) -> None:
|
||||
self.replay.discard()
|
||||
self.plots.bscan.signature = None
|
||||
|
||||
def reprocess_history(self) -> None:
|
||||
if self.replay.active or not self.can_reprocess_history():
|
||||
return
|
||||
config, reader, generation = self.active_config, self._result_reader, self.stop_generation
|
||||
polling = self.timer.isActive()
|
||||
self.timer.stop()
|
||||
try:
|
||||
if self.replay.run(window=self.plots.window_scans(), revision=self.plots.required_revision or 0,
|
||||
ring=config.rings.preprocessed if config is not None else None, reader=reader,
|
||||
pump_events=self.pump_events, continue_replay=lambda: self.can_resume(generation)
|
||||
and not self._stopping and self.can_reprocess_history() and self._result_reader is reader):
|
||||
self.plots.redraw()
|
||||
except Exception as exc:
|
||||
self._show_exception("Failed to recompute the B-scan", exc)
|
||||
finally:
|
||||
if polling and not self._closing:
|
||||
self.timer.start()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Select radar views and supply independent renderers with settings and history."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QComboBox, QPushButton, QStackedWidget, QWidget
|
||||
|
||||
from python_app.gui.controllers.renderers.sar3d import Sar3dRenderer
|
||||
from python_app.gui.controllers.history_controller import HistoryController
|
||||
from python_app.gui.controllers.renderers.bscan import BscanRenderer
|
||||
from python_app.gui.controllers.renderers.gpr import GprRenderer
|
||||
from python_app.gui.controllers.renderers.trace import TraceRenderer
|
||||
from python_app.models.dataset_model import ResultCollection, TraceData
|
||||
from python_app.models.gui_profile_schema import GuiProcessingStateModel
|
||||
from python_app.models.run_config_schema import GprModel
|
||||
|
||||
|
||||
class PlotController:
|
||||
def __init__(
|
||||
self, history: HistoryController, settings: Callable[[], GuiProcessingStateModel],
|
||||
geometry: Callable[[], GprModel], running: Callable[[], bool],
|
||||
warning: Callable[..., None],
|
||||
) -> None:
|
||||
self.history = history
|
||||
self._settings = settings
|
||||
self._geometry = geometry
|
||||
self._running = running
|
||||
self._warning = warning
|
||||
self.required_revision: int | None = None
|
||||
self.live_window = 50
|
||||
self.bscan = BscanRenderer()
|
||||
self.gpr = GprRenderer()
|
||||
self.sar3d = Sar3dRenderer()
|
||||
self.trace = TraceRenderer(warning)
|
||||
self.stack = QStackedWidget()
|
||||
for renderer in (self.bscan, self.gpr, self.sar3d, self.trace):
|
||||
self.stack.addWidget(renderer.widget)
|
||||
self.stack.setCurrentWidget(self.trace.widget)
|
||||
self._combo: QComboBox | None = None
|
||||
self._next: QPushButton | None = None
|
||||
|
||||
def bind_display_controls(self, combo: QComboBox, next_button: QPushButton) -> None:
|
||||
self._combo, self._next = combo, next_button
|
||||
combo.currentIndexChanged.connect(self.on_display_combo_changed)
|
||||
next_button.clicked.connect(self.select_next_combo)
|
||||
self.sync_display_combos()
|
||||
|
||||
@property
|
||||
def display_key(self) -> tuple[int, int] | None:
|
||||
return self._combo.currentData() if self._combo is not None else None
|
||||
|
||||
@property
|
||||
def capture_widget(self) -> QWidget | None:
|
||||
current = self.stack.currentWidget()
|
||||
return self.sar3d.grid if current is self.sar3d.widget else current
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
renderer = {"bscan": self.bscan, "gpr": self.gpr, "legacy_gpr": self.gpr, "sar3d": self.sar3d}.get(mode, self.trace)
|
||||
self.stack.setCurrentWidget(renderer.widget)
|
||||
self.sync_display_combos()
|
||||
|
||||
def select_combo(self, input_pos: int, output_pos: int) -> None:
|
||||
key = input_pos, output_pos
|
||||
keys = sorted(self.bscan.history_by_combo)
|
||||
if key not in keys or self._combo is None:
|
||||
raise ValueError(f"Unknown channel combination: {input_pos}:{output_pos}")
|
||||
self._combo.setCurrentIndex(keys.index(key))
|
||||
|
||||
def select_next_combo(self) -> None:
|
||||
combo = self._combo
|
||||
if combo is not None and combo.count():
|
||||
combo.setCurrentIndex((combo.currentIndex() + 1) % combo.count())
|
||||
|
||||
def sync_display_combos(self) -> None:
|
||||
combo, next_button = self._combo, self._next
|
||||
if combo is None or next_button is None:
|
||||
return
|
||||
keys = sorted(self.bscan.history_by_combo)
|
||||
current = combo.currentData()
|
||||
if keys != [combo.itemData(i) for i in range(combo.count())]:
|
||||
with QSignalBlocker(combo):
|
||||
combo.clear()
|
||||
for key in keys:
|
||||
combo.addItem(f"{key[0]} : {key[1]}", key)
|
||||
combo.setCurrentIndex(keys.index(current) if current in keys else 0)
|
||||
enabled = bool(keys) and self._settings().selected_mode == "bscan"
|
||||
combo.setEnabled(enabled)
|
||||
next_button.setEnabled(enabled and len(keys) > 1)
|
||||
|
||||
def window_scans(self) -> int:
|
||||
return self.live_window if self._running() else max(1, self._settings().bscan.history_window_scans)
|
||||
|
||||
def sync_bscan(self) -> None:
|
||||
results = self.history.bscan_source()
|
||||
self.bscan.sync(results, self.window_scans())
|
||||
self.sync_display_combos()
|
||||
|
||||
def draw_bscan(self) -> bool:
|
||||
self.sync_bscan()
|
||||
window, retained = self.window_scans(), len(self.history.preprocessed)
|
||||
if retained < window and self.bscan.history_by_combo:
|
||||
self._warning(
|
||||
f"B-scan is set to show {window} sweeps but only {retained} are retained; "
|
||||
"the image shows what history there is.",
|
||||
details="Capture more sweeps, or reduce 'Scans to show (stopped)'.",
|
||||
once_key=f"bscan_window_underfilled_{window}",
|
||||
)
|
||||
return self.bscan.draw(self.display_key, self._settings().bscan)
|
||||
|
||||
def on_display_combo_changed(self, *_args: object) -> None:
|
||||
if self._settings().selected_mode == "bscan":
|
||||
self.draw_bscan()
|
||||
|
||||
def draw(self, collection: ResultCollection) -> bool:
|
||||
settings = self._settings()
|
||||
mode = settings.selected_mode
|
||||
if mode in {"gpr", "legacy_gpr", "sar3d"} and self.required_revision is not None:
|
||||
if collection.settings_revision != self.required_revision:
|
||||
return False
|
||||
if mode == "bscan":
|
||||
return self.draw_bscan()
|
||||
if mode in {"gpr", "legacy_gpr"}:
|
||||
return self.gpr.draw(collection, settings.legacy_gpr if mode == "legacy_gpr" else settings.gpr, self._geometry())
|
||||
if mode == "sar3d":
|
||||
return self.sar3d.draw(collection)
|
||||
return self.trace.draw(collection, settings.pass_through)
|
||||
|
||||
def redraw(self, *_args: object) -> None:
|
||||
mode = self._settings().selected_mode
|
||||
if mode == "bscan":
|
||||
self.draw_bscan()
|
||||
elif mode == "sar3d":
|
||||
self.sar3d.clear()
|
||||
if self.history.results:
|
||||
self.draw(self.history.results[-1])
|
||||
elif not self.history.results or not self.draw(self.history.results[-1]):
|
||||
self.clear_current()
|
||||
|
||||
def draw_trace(self, trace: TraceData, title: str) -> None:
|
||||
self.trace.draw_trace(trace, title, self._settings().pass_through)
|
||||
|
||||
def clear_bscan(self) -> None:
|
||||
self.bscan.clear()
|
||||
self.sync_display_combos()
|
||||
|
||||
def clear_gpr(self) -> None:
|
||||
settings = self._settings()
|
||||
self.gpr.clear(settings.legacy_gpr if settings.selected_mode == "legacy_gpr" else settings.gpr)
|
||||
|
||||
def clear_current(self) -> None:
|
||||
mode = self._settings().selected_mode
|
||||
if mode == "bscan":
|
||||
self.clear_bscan()
|
||||
elif mode in {"gpr", "legacy_gpr"}:
|
||||
self.clear_gpr()
|
||||
elif mode == "sar3d":
|
||||
self.sar3d.clear()
|
||||
else:
|
||||
self.trace.clear()
|
||||
|
||||
def clear(self) -> None:
|
||||
self.clear_bscan()
|
||||
self.clear_gpr()
|
||||
self.sar3d.clear()
|
||||
self.trace.clear()
|
||||
|
||||
@staticmethod
|
||||
def has_output(collection: ResultCollection) -> bool:
|
||||
return bool(collection.collection_payloads) or any(p.kind == 1 for b in collection.blocks for p in b.payloads)
|
||||
@@ -0,0 +1,580 @@
|
||||
"""Select preprocessing assets and own paused hardware capture sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import nullcontext
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
from python_app.gui.controllers.object_calibration_capture import ObjectSweepCapture
|
||||
from python_app.gui.controllers.object_calibration_controller import ObjectCalibrationController
|
||||
from python_app.gui.controllers.pipeline_controller import PipelineController
|
||||
from python_app.gui.controllers.plot_controller import PlotController
|
||||
from python_app.gui.controllers.sections.views import DataView, PrimaryView
|
||||
from python_app.gui.object_calibration_dialog import ObjectCalibrationDialog
|
||||
from python_app.gui.preprocess_dialog import PreprocessDialog
|
||||
from python_app.gui.runtime.pipeline_pause import PipelinePause
|
||||
from python_app.gui.trace_png_export import export_trace_png
|
||||
from python_app.models.gui_profile_schema import GuiPreprocessDialogStateModel
|
||||
from python_app.models.run_config_schema import ComboModel, GprModel, RunConfigModel
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_NAMES,
|
||||
preprocess_asset_display_name, preprocess_asset_model,
|
||||
)
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
from python_app.workflows.neutral_preprocess import build_neutral_s21_sets, supports_neutral_preprocess_sets
|
||||
from python_app.workflows.object_calibration import capture_provenance, geometry_signature
|
||||
from python_app.workflows.radar_config_variants import RadarConfigVariant, scan_radar_config_variants
|
||||
from python_app.workflows.reference_workflow import capture_reference_set
|
||||
from python_app.workflows.sequential_capture_workflow import CaptureBatch, SavedCaptureSet, SequentialCaptureSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TMP_REFERENCE_SET_NAME = "tmp_reference"
|
||||
|
||||
|
||||
class PreprocessController:
|
||||
def __init__(
|
||||
self, parent: QWidget, config: RunConfigModel, state: GuiPreprocessDialogStateModel,
|
||||
store: NpzStore, pipeline: PipelineController, plots: PlotController, *,
|
||||
build_config: Callable[[], RunConfigModel], current_radar_key: Callable[[], str],
|
||||
geometry: Callable[[], GprModel], summary: Callable[[dict[str, str]], None],
|
||||
error: Callable[[str, Exception], None],
|
||||
) -> None:
|
||||
self._parent, self._store, self._pipeline, self._plots = parent, store, pipeline, plots
|
||||
self._build_config, self._current_radar_key, self._geometry = build_config, current_radar_key, geometry
|
||||
self._summary, self._error = summary, error
|
||||
self._defaults, self._settings = config, deepcopy(state)
|
||||
self.selected_sets = {key: preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_NAMES}
|
||||
self.radar_key = config.radar_key()
|
||||
self.dialog: PreprocessDialog | None = None
|
||||
self.capture_session: SequentialCaptureSession | None = None
|
||||
self.capture_busy = False
|
||||
self._tmp_reference_busy = False
|
||||
self._capture_pause: PipelinePause | None = None
|
||||
self._radar_variants: list[RadarConfigVariant] = []
|
||||
self.object_calibration = ObjectCalibrationController(
|
||||
parent, store, build_config, self._capture_object, self._select_object, error,
|
||||
)
|
||||
|
||||
def bind_actions(self, primary: PrimaryView, data: DataView) -> None:
|
||||
primary.preprocess.clicked.connect(self.open_panel)
|
||||
data.capture_tmp.clicked.connect(self.capture_tmp_reference)
|
||||
|
||||
@property
|
||||
def object_capture(self) -> ObjectSweepCapture | None:
|
||||
return self.object_calibration.capture
|
||||
|
||||
def settings(self) -> GuiPreprocessDialogStateModel:
|
||||
if self.dialog is not None:
|
||||
self._settings = GuiPreprocessDialogStateModel(
|
||||
set_name=self.dialog.set_name(), radar_config_dir=self.dialog.radar_config_dir(),
|
||||
use_all_radar_configs=self.dialog.use_all_radar_configs(),
|
||||
median_sweep_count=self.dialog.median_sweep_count(),
|
||||
)
|
||||
return deepcopy(self._settings)
|
||||
|
||||
def apply_profile(self, config: RunConfigModel, state: GuiPreprocessDialogStateModel) -> None:
|
||||
self._defaults, self._settings = config, deepcopy(state)
|
||||
self.selected_sets.update({key: preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_NAMES})
|
||||
self.radar_key = config.radar_key()
|
||||
self._summary(self.selected_sets)
|
||||
if self.dialog is not None:
|
||||
self._apply_dialog_settings()
|
||||
|
||||
def _apply_dialog_settings(self) -> None:
|
||||
assert self.dialog is not None
|
||||
self.dialog.apply_settings(self._settings, self.selected_sets,
|
||||
neutral_visible=supports_neutral_preprocess_sets(self._defaults))
|
||||
|
||||
def ensure_dialog(self) -> PreprocessDialog:
|
||||
if self.dialog is None:
|
||||
self.dialog = PreprocessDialog(self._parent)
|
||||
self._apply_dialog_settings()
|
||||
self.dialog.refresh_requested.connect(self._refresh_requested)
|
||||
self.dialog.selection_changed.connect(self._selection_changed)
|
||||
self.dialog.radar_config_dir_changed.connect(self._refresh_requested)
|
||||
self.dialog.multi_radar_option_changed.connect(self._refresh_requested)
|
||||
self.dialog.start_sequence_requested.connect(self.start_sequence)
|
||||
self.dialog.capture_next_requested.connect(self.capture_next)
|
||||
self.dialog.capture_all_requested.connect(self.capture_all)
|
||||
self.dialog.undo_last_requested.connect(self.undo)
|
||||
self.dialog.finalize_sequence_requested.connect(self.finalize)
|
||||
self.dialog.abort_sequence_requested.connect(self.abort)
|
||||
self.dialog.create_neutral_sets_requested.connect(self.create_neutral_sets)
|
||||
self.dialog.create_object_calibration_requested.connect(
|
||||
lambda: self.object_calibration.open_creation(self.settings().set_name))
|
||||
self._update_capture_state()
|
||||
return self.dialog
|
||||
|
||||
def open_panel(self) -> None:
|
||||
try:
|
||||
dialog = self.ensure_dialog()
|
||||
self.refresh_sets()
|
||||
self._update_capture_state()
|
||||
dialog.showMaximized()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
except Exception as exc:
|
||||
self._error("Failed to open preprocessing panel", exc)
|
||||
|
||||
def busy_reason(self) -> str | None:
|
||||
if self._pipeline.stopping:
|
||||
return "Wait for pipeline shutdown to finish"
|
||||
if self.object_capture is not None:
|
||||
return "Wait for object-calibration capture to finish"
|
||||
if self.capture_session is not None:
|
||||
return "Finish the active capture sequence first"
|
||||
if self.capture_busy or self._tmp_reference_busy:
|
||||
return "Wait for preprocessing capture to finish"
|
||||
return None
|
||||
|
||||
def data_action_allowed(self, *, deletion: bool = False) -> bool:
|
||||
if self._pipeline.closing:
|
||||
return False
|
||||
if self._pipeline.stopping:
|
||||
self._error("Cannot perform data action", RuntimeError(self.busy_reason()))
|
||||
return False
|
||||
if self.object_capture is not None:
|
||||
self._error("Wait for object-calibration capture to finish", RuntimeError("Calibration capture is active"))
|
||||
return False
|
||||
if deletion and self.capture_session is not None:
|
||||
self._error("Cannot modify runtime history during active capture sequence", RuntimeError(self.capture_state_details()))
|
||||
return False
|
||||
return True
|
||||
|
||||
def _can_capture(self) -> bool:
|
||||
if self._pipeline.closing:
|
||||
return False
|
||||
reason = self.busy_reason()
|
||||
if reason:
|
||||
self._error(reason, RuntimeError(self.capture_state_details()))
|
||||
return False
|
||||
return True
|
||||
|
||||
def capture_state_details(self) -> str:
|
||||
if self.capture_session is None:
|
||||
return "Capture session: none"
|
||||
state = self.capture_session.state()
|
||||
lines = ["Capture session:", f"- kind={state.kind}", f"- progress={state.captured_count}/{state.total_count}"]
|
||||
if state.current_combo is not None:
|
||||
lines.append(f"- current_combo=input={state.current_combo.input}, output={state.current_combo.output}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def reset_selection(self) -> None:
|
||||
try:
|
||||
radar_key = self._current_radar_key()
|
||||
except Exception:
|
||||
logger.debug("Skipping preprocess-selection reset; radar key unavailable", exc_info=True)
|
||||
return
|
||||
if radar_key == self.radar_key:
|
||||
return
|
||||
previous_key, self.radar_key = self.radar_key, radar_key
|
||||
cleared = {key: value for key, value in self.selected_sets.items() if value}
|
||||
self.selected_sets.update(dict.fromkeys(PREPROCESS_ASSET_NAMES, ""))
|
||||
self._summary(self.selected_sets)
|
||||
if self.dialog is not None:
|
||||
self.dialog.set_selected_sets(self.selected_sets, emit_signal=False)
|
||||
self._refresh_requested()
|
||||
if cleared:
|
||||
logger.info("Preprocess selection reset: %s -> %s; cleared %s", previous_key, radar_key, cleared)
|
||||
|
||||
def _selection_changed(self) -> None:
|
||||
previous = dict(self.selected_sets)
|
||||
self.selected_sets.update(self.ensure_dialog().selection_snapshot())
|
||||
self._summary(self.selected_sets)
|
||||
changes = [f"{preprocess_asset_display_name(key)}: {previous.get(key) or '<not selected>'} -> "
|
||||
f"{self.selected_sets.get(key) or '<not selected>'}" for key in PREPROCESS_ASSET_NAMES
|
||||
if previous.get(key) != self.selected_sets.get(key)]
|
||||
if changes:
|
||||
logger.info("Preprocess selection changed: %s", "; ".join(changes))
|
||||
|
||||
def refresh_sets(self) -> None:
|
||||
self.radar_key = self._current_radar_key()
|
||||
dialog = self.ensure_dialog()
|
||||
self.settings()
|
||||
available = {key: self._store.list_sets(key, self.radar_key)
|
||||
for key in PREPROCESS_ASSET_NAMES}
|
||||
dialog.set_available_sets(available)
|
||||
self._refresh_variants()
|
||||
for key, names in available.items():
|
||||
selected = self.selected_sets.get(key, "")
|
||||
if selected and selected not in names:
|
||||
logger.warning("%s set %s is unavailable for radar key %s", key, selected, self.radar_key)
|
||||
dialog.set_selected_sets(self.selected_sets)
|
||||
dialog.set_neutral_sets_visible(supports_neutral_preprocess_sets(self._defaults))
|
||||
self._summary(self.selected_sets)
|
||||
logger.info("Preprocess set lists refreshed: radar_key=%s, counts=%s", self.radar_key,
|
||||
{key: len(names) for key, names in available.items()})
|
||||
|
||||
def _refresh_requested(self) -> None:
|
||||
try:
|
||||
self.refresh_sets()
|
||||
except Exception as exc:
|
||||
self._error("Failed to refresh preprocessing sets", exc)
|
||||
|
||||
def _refresh_variants(self) -> None:
|
||||
self._radar_variants, summary = scan_radar_config_variants(
|
||||
self.settings().radar_config_dir, base_config=self._build_config())
|
||||
if self.dialog is not None:
|
||||
values = asdict(summary)
|
||||
values.pop("issues")
|
||||
self.dialog.set_radar_config_summary(**values)
|
||||
if summary.issues:
|
||||
logger.warning("Some radar config JSON files were skipped:\n%s", "\n".join(summary.issues))
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
if self._tmp_reference_busy:
|
||||
logger.info("Tmp reference capture already in progress; ignoring duplicate request")
|
||||
return
|
||||
if not self._can_capture():
|
||||
return
|
||||
self._tmp_reference_busy = True
|
||||
try:
|
||||
with self._pipeline.pause():
|
||||
try:
|
||||
config = self._build_config()
|
||||
radar_key, collection = capture_reference_set(
|
||||
config, TMP_REFERENCE_SET_NAME, self._store,
|
||||
median_sweep_count=self.settings().median_sweep_count,
|
||||
)
|
||||
self.selected_sets["s21_reference"] = TMP_REFERENCE_SET_NAME
|
||||
self.radar_key = radar_key
|
||||
# Reference reload must keep the processor and its TCP locator alive.
|
||||
self._pipeline.preprocessing_changed()
|
||||
self._summary(self.selected_sets)
|
||||
if collection.traces:
|
||||
self._plots.draw_trace(collection.traces[-1], "Tmp S21 Reference last trace")
|
||||
if self.dialog is not None:
|
||||
self.refresh_sets()
|
||||
self.dialog.set_status(f"S21 Reference set saved: {TMP_REFERENCE_SET_NAME} ({len(collection.traces)} traces)")
|
||||
logger.info("Tmp S21 Reference captured: set=%s, key=%s, traces=%d; runtime history reset",
|
||||
TMP_REFERENCE_SET_NAME, radar_key, len(collection.traces))
|
||||
finally:
|
||||
self._tmp_reference_busy = False
|
||||
except InterruptedError:
|
||||
self._pipeline.operation_cancelled()
|
||||
except Exception as exc:
|
||||
self._error("Failed to capture tmp reference", exc)
|
||||
finally:
|
||||
self._tmp_reference_busy = False
|
||||
|
||||
def create_neutral_sets(self) -> None:
|
||||
if not self._can_capture():
|
||||
return
|
||||
try:
|
||||
dialog = self.ensure_dialog()
|
||||
name, config = dialog.set_name(), self._build_config()
|
||||
if not name:
|
||||
raise ValueError("Set name is required")
|
||||
if not supports_neutral_preprocess_sets(config):
|
||||
raise ValueError("Neutral S21 sets are not supported for this radar configuration")
|
||||
radar_key = config.radar_key()
|
||||
for kind in ("s21_calibration", "s21_reference"):
|
||||
if name in self._store.list_sets(kind, radar_key):
|
||||
raise FileExistsError(f"Set '{name}' already exists for {preprocess_asset_display_name(kind)}")
|
||||
with self._pipeline.pause():
|
||||
calibration, reference = build_neutral_s21_sets(config)
|
||||
self._store.save_sets(radar_key, name, {"s21_calibration": calibration, "s21_reference": reference})
|
||||
self.selected_sets.update(s21_calibration=name, s21_reference=name)
|
||||
self.radar_key = radar_key
|
||||
self._pipeline.preprocessing_changed(restart_processor=True)
|
||||
self.refresh_sets()
|
||||
dialog.set_status(f"Neutral S21 sets saved: {name} ({len(calibration.traces)} combos, "
|
||||
f"{calibration.traces[0].frequency_hz.size} points)")
|
||||
logger.info("Neutral S21 sets saved: set=%s, radar_key=%s", name, radar_key)
|
||||
except InterruptedError:
|
||||
self._pipeline.operation_cancelled()
|
||||
except Exception as exc:
|
||||
self._error("Failed to create neutral S21 sets", exc)
|
||||
|
||||
|
||||
def start_sequence(self, kind: str) -> None:
|
||||
if not self._can_capture():
|
||||
return
|
||||
pause = None
|
||||
try:
|
||||
dialog = self.ensure_dialog()
|
||||
name = dialog.set_name()
|
||||
if not name:
|
||||
raise ValueError("Set name is required")
|
||||
pause = self._pipeline.pause()
|
||||
self._capture_pause = pause
|
||||
config, settings = self._build_config(), self.settings()
|
||||
if settings.use_all_radar_configs:
|
||||
self._refresh_variants()
|
||||
if not self._radar_variants:
|
||||
raise ValueError("No valid radar config variants were found in the selected directory")
|
||||
keys = [variant.radar_key for variant in self._radar_variants]
|
||||
else:
|
||||
keys = [config.radar_key()]
|
||||
duplicates = [key for key in keys if name in self._store.list_sets(kind, key)]
|
||||
if duplicates:
|
||||
raise FileExistsError(f"Set '{name}' already exists for {kind}: {', '.join(duplicates)}")
|
||||
session = SequentialCaptureSession(
|
||||
config, kind, name, median_sweep_count=settings.median_sweep_count,
|
||||
radar_variants=self._radar_variants if settings.use_all_radar_configs else None,
|
||||
)
|
||||
session.open()
|
||||
self.capture_session = session
|
||||
dialog.clear_capture_log()
|
||||
dialog.reset_preview()
|
||||
dialog.set_status(f"{preprocess_asset_display_name(kind)} sequence started")
|
||||
self._plots.trace.clear()
|
||||
self._update_capture_state()
|
||||
logger.info("%s sequence started: set=%s, combos=%d, radar_variants=%d, median_sweeps=%d",
|
||||
kind, name, session.state().total_count, len(keys), settings.median_sweep_count)
|
||||
except InterruptedError:
|
||||
self._pipeline.operation_cancelled()
|
||||
self._release_pause()
|
||||
except Exception as exc:
|
||||
if pause is not None:
|
||||
self._cleanup_session()
|
||||
if pause is not None:
|
||||
self._release_pause()
|
||||
self._error(f"Failed to start {kind} sequence", exc)
|
||||
|
||||
def _begin_capture(self) -> bool:
|
||||
if self._pipeline.closing or self.capture_busy:
|
||||
logger.info("Preprocess combo capture already in progress; ignoring duplicate request")
|
||||
return False
|
||||
self.capture_busy = True
|
||||
self._update_capture_state()
|
||||
return True
|
||||
|
||||
def capture_next(self) -> None:
|
||||
self._capture_remaining(all_remaining=False)
|
||||
|
||||
def capture_all(self) -> None:
|
||||
self._capture_remaining(all_remaining=True)
|
||||
|
||||
def _capture_remaining(self, *, all_remaining: bool) -> None:
|
||||
session = self.capture_session
|
||||
if session is None or session.is_complete():
|
||||
self._error("Cannot capture preprocess data", RuntimeError("No incomplete capture sequence"))
|
||||
return
|
||||
if not self._begin_capture():
|
||||
return
|
||||
sequence = self._pipeline.stop_generation
|
||||
if all_remaining:
|
||||
self.ensure_dialog().set_status(f"{preprocess_asset_display_name(session.kind)} batch capture started")
|
||||
try:
|
||||
while self.capture_session is session and self._pipeline.can_resume(sequence):
|
||||
try:
|
||||
captured = session.capture_current_combo()
|
||||
except Exception as exc:
|
||||
state = session.state()
|
||||
self._error("Failed to capture combo; previous captures kept, retry when ready", exc)
|
||||
self.ensure_dialog().set_status(
|
||||
f"{preprocess_asset_display_name(session.kind)} capture failed: "
|
||||
f"{state.captured_count}/{state.total_count} kept, ready to retry")
|
||||
return
|
||||
self._record_capture(session, captured)
|
||||
if not all_remaining or session.is_complete():
|
||||
break
|
||||
self._pipeline.pump_events(0.01)
|
||||
if all_remaining and self.capture_session is session and not session.is_complete():
|
||||
self.ensure_dialog().set_status(
|
||||
f"{preprocess_asset_display_name(session.kind)} batch capture paused; previous captures kept")
|
||||
finally:
|
||||
self.capture_busy = False
|
||||
# Queued clicks must reach disabled buttons before they are re-enabled.
|
||||
QTimer.singleShot(0, self._update_capture_state)
|
||||
|
||||
def _record_capture(self, session: SequentialCaptureSession, captured: CaptureBatch) -> None:
|
||||
dialog, state = self.ensure_dialog(), session.state()
|
||||
display = preprocess_asset_display_name(session.kind)
|
||||
trace = captured.display_trace
|
||||
details = f" | radar_configs={len(captured.traces)}" if state.variant_count > 1 else ""
|
||||
dialog.append_capture_log_entry(kind=display, captured_count=state.captured_count, total_count=state.total_count,
|
||||
input_pos=trace.combo.input, output_pos=trace.combo.output, extra_details=details)
|
||||
dialog.draw_last_trace(trace, title=f"{display} captured")
|
||||
self._plots.draw_trace(trace, f"{display} last trace")
|
||||
logger.info("%s capture: %d/%d | input=%d output=%d%s", display, state.captured_count,
|
||||
state.total_count, trace.combo.input, trace.combo.output, details)
|
||||
self._update_capture_state()
|
||||
if session.is_complete():
|
||||
dialog.set_status(f"{display} sequence complete. Review captures or save the set.")
|
||||
|
||||
def undo(self) -> None:
|
||||
session = self.capture_session
|
||||
if session is None:
|
||||
self._error("No active capture sequence", RuntimeError(self.capture_state_details()))
|
||||
return
|
||||
try:
|
||||
session.undo_last_capture()
|
||||
dialog, state = self.ensure_dialog(), session.state()
|
||||
display = preprocess_asset_display_name(session.kind)
|
||||
captures = session.captured_batches()
|
||||
extra = f" | radar_configs={state.variant_count}" if state.variant_count > 1 else ""
|
||||
dialog.set_capture_log_entries([
|
||||
f"{display}: {index}/{state.total_count} | input={item.combo.input} output={item.combo.output}{extra}"
|
||||
for index, item in enumerate(captures, start=1)])
|
||||
trace = session.last_captured_trace()
|
||||
if trace is None:
|
||||
dialog.reset_preview()
|
||||
self._plots.trace.clear()
|
||||
else:
|
||||
dialog.draw_last_trace(trace, title=f"{display} last trace")
|
||||
self._plots.draw_trace(trace, f"{display} last trace")
|
||||
dialog.set_status(f"{display} last capture removed. Ready to recapture.")
|
||||
self._update_capture_state()
|
||||
logger.info("%s undo last capture; remaining=%d/%d", display, state.captured_count, state.total_count)
|
||||
except Exception as exc:
|
||||
self._error("Failed to undo last preprocess capture", exc)
|
||||
|
||||
def finalize(self) -> None:
|
||||
session = self.capture_session
|
||||
if session is None or not session.is_complete():
|
||||
self._error("Capture sequence is not complete", RuntimeError(self.capture_state_details()))
|
||||
return
|
||||
try:
|
||||
try:
|
||||
dialog = self.ensure_dialog()
|
||||
kind, name = session.kind, session.set_name
|
||||
saved = session.finalize(self._store)
|
||||
status = (f"{len(saved)} radar variants" if len(saved) > 1
|
||||
else f"{len(saved[0].collection.traces)} traces")
|
||||
if self.settings().use_all_radar_configs:
|
||||
self._export_previews(session, saved)
|
||||
self._cleanup_session()
|
||||
self.selected_sets[kind] = name
|
||||
self._summary(self.selected_sets)
|
||||
dialog.set_status(f"{preprocess_asset_display_name(kind)} set saved: {name} ({status})")
|
||||
logger.info("%s sequence saved: set=%s (%s)", kind, name, status)
|
||||
self.refresh_sets()
|
||||
finally:
|
||||
if self.capture_session is None:
|
||||
self._release_pause()
|
||||
except Exception as exc:
|
||||
self._error("Failed to save preprocess set", exc)
|
||||
|
||||
def _export_previews(self, session: SequentialCaptureSession, saved: list[SavedCaptureSet]) -> None:
|
||||
for item in saved:
|
||||
for trace in item.collection.traces:
|
||||
try:
|
||||
path = self._store.preview_png_dir(session.kind, session.set_name, item.radar_key) / f"i{trace.combo.input}_o{trace.combo.output}.png"
|
||||
export_trace_png(trace, path, title=f"{preprocess_asset_display_name(session.kind)} | {item.display_name} | "
|
||||
f"input={trace.combo.input} output={trace.combo.output}")
|
||||
except Exception:
|
||||
# Data are already saved; rendering failure must not abort completion.
|
||||
logger.warning("Failed to save preview PNG for %s", item.display_name, exc_info=True)
|
||||
|
||||
def abort(self, *, resume_pipeline: bool = True) -> None:
|
||||
if self.capture_session is None:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
kind = self.capture_session.kind
|
||||
self._cleanup_session()
|
||||
self.ensure_dialog().set_status(f"{preprocess_asset_display_name(kind)} sequence aborted")
|
||||
logger.info("%s sequence aborted", kind)
|
||||
finally:
|
||||
self._release_pause(resume=resume_pipeline)
|
||||
except Exception as exc:
|
||||
self._error("Failed to abort capture sequence", exc)
|
||||
|
||||
def _cleanup_session(self) -> None:
|
||||
session, self.capture_session = self.capture_session, None
|
||||
try:
|
||||
if session is not None:
|
||||
session.close()
|
||||
except Exception:
|
||||
self._release_pause(resume=False)
|
||||
raise
|
||||
finally:
|
||||
self._update_capture_state()
|
||||
|
||||
def _release_pause(self, *, resume: bool = True) -> None:
|
||||
pause, self._capture_pause = self._capture_pause, None
|
||||
if pause is not None:
|
||||
pause.finish(resume=resume)
|
||||
|
||||
def _update_capture_state(self) -> None:
|
||||
# A queued post-capture callback can outlive the window and its Qt children.
|
||||
if self._pipeline.closing or self.dialog is None:
|
||||
return
|
||||
state = self.capture_session.state() if self.capture_session is not None else None
|
||||
combo = state.current_combo if state else None
|
||||
self.dialog.set_capture_state(
|
||||
kind=state.kind if state else None, captured_count=state.captured_count if state else 0,
|
||||
total_count=state.total_count if state else 0, next_input=combo.input if combo else None,
|
||||
next_output=combo.output if combo else None, can_undo=state.can_undo if state else False,
|
||||
can_finalize=state.is_complete if state else False,
|
||||
can_capture_all=bool(state and state.supports_batch_capture and not state.is_complete and combo),
|
||||
variant_count=state.variant_count if state else 1, actions_enabled=not self.capture_busy,
|
||||
)
|
||||
|
||||
def _capture_object(self, dialog: ObjectCalibrationDialog) -> ObjectSweepCapture:
|
||||
reason = self.busy_reason()
|
||||
if reason or self._pipeline.closing or self._pipeline.stopping:
|
||||
raise RuntimeError(reason or "Pipeline is stopping")
|
||||
if self._pipeline.single_capture_active or self._pipeline.recording.active:
|
||||
raise RuntimeError("Finish the single capture or disk recording before taking calibration data")
|
||||
config = self._build_config()
|
||||
if not config.rings.preprocessed_tap.name:
|
||||
raise ValueError("Object calibration requires rings.preprocessed_tap; enable it in the run profile")
|
||||
if config.radar.replay.dataset_dir.strip():
|
||||
raise ValueError("Object-calibration capture requires live acquisition; disable archive replay")
|
||||
config.preprocess.s21.object_calibration.set_name = ""
|
||||
config.preprocess.s21.object_calibration.bundle_path = ""
|
||||
tx, rx = sorted({entry.output_pos for entry in config.gpr.tx_geometry}), sorted({entry.input_pos for entry in config.gpr.rx_geometry})
|
||||
if not tx or not rx:
|
||||
raise ValueError("Configure Tx/Rx antenna positions before taking calibration data")
|
||||
config.combos = [ComboModel(input=i, output=o) for o in tx for i in rx]
|
||||
radar_key = config.radar_key()
|
||||
through = self._store.load_set("s21_calibration", radar_key, config.preprocess.s21.calibration.set_name)
|
||||
provenance = capture_provenance(config.to_dict(), radar_key, through)
|
||||
return ObjectSweepCapture(self._pipeline, dialog, config, self.settings().median_sweep_count, provenance)
|
||||
|
||||
def _select_object(self, name: str) -> None:
|
||||
self.selected_sets["s21_object_calibration"] = name
|
||||
self.ensure_dialog().set_set_name(name)
|
||||
self._summary(self.selected_sets)
|
||||
self._refresh_requested()
|
||||
logger.info("Selected object calibration: %s", name)
|
||||
|
||||
def invalidate_object_calibration_after_geometry_change(self) -> None:
|
||||
selected = self.selected_sets.get("s21_object_calibration", "")
|
||||
active_config = self._pipeline.active_config if self._pipeline.running else None
|
||||
active = active_config.preprocess.s21.object_calibration.set_name if active_config else ""
|
||||
if not selected and not active:
|
||||
return
|
||||
geometry = geometry_signature({"gpr": asdict(self._geometry())})
|
||||
selections = [(selected, self.radar_key)]
|
||||
if active_config is not None:
|
||||
selections.append((active, active_config.radar_key()))
|
||||
invalid: set[str] = set()
|
||||
for name, radar_key in dict.fromkeys(selections):
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
_, metadata = self._store.load_set_with_metadata("s21_object_calibration", radar_key, name)
|
||||
if metadata["provenance"]["geometry"] != geometry:
|
||||
raise ValueError("Antenna geometry differs from the captured calibration")
|
||||
except Exception as exc:
|
||||
invalid.add(name)
|
||||
logger.warning("Object calibration %s disabled for changed antenna geometry: %s", name, exc)
|
||||
if not invalid:
|
||||
return
|
||||
try:
|
||||
with self._pipeline.pause() if active in invalid else nullcontext():
|
||||
if selected in invalid:
|
||||
self.selected_sets["s21_object_calibration"] = ""
|
||||
self._summary(self.selected_sets)
|
||||
if self.dialog is not None:
|
||||
self.dialog.set_selected_sets(self.selected_sets, emit_signal=False)
|
||||
except InterruptedError:
|
||||
self._pipeline.operation_cancelled()
|
||||
except Exception as exc:
|
||||
self._error("Failed to update object calibration after geometry change", exc)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.object_calibration.shutdown()
|
||||
self.abort(resume_pipeline=False)
|
||||
if self.dialog is not None:
|
||||
self.dialog.close()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""GUI profile persistence, startup selection and application to concrete editors."""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import QFileDialog, QWidget
|
||||
|
||||
from python_app.gui.controllers.config_controller import ConfigController
|
||||
from python_app.gui.controllers.sections.views import PrimaryView
|
||||
from python_app.file_io import atomic_write_json
|
||||
from python_app.gui.controllers.live_settings_controller import LiveSettingsController
|
||||
from python_app.gui.controllers.log_controller import LogController
|
||||
from python_app.gui.controllers.pipeline_controller import PipelineController
|
||||
from python_app.gui.controllers.preprocess_controller import PreprocessController
|
||||
from python_app.gui.controllers.radar_settings_controller import RadarSettingsController
|
||||
from python_app.gui.settings_defaults import cpp_bscan_replay_window_for_config, default_gui_state_for_config
|
||||
from python_app.models.gui_profile_schema import GuiProfileModel, GuiStateModel
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.orchestration.gui_session_state import GuiSessionState, GuiSessionStateStore
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProfileState:
|
||||
config: RunConfigModel
|
||||
gui: GuiStateModel
|
||||
path: Path
|
||||
session: GuiSessionStateStore
|
||||
|
||||
|
||||
def _remember(state: ProfileState, path: Path, store: GuiSessionStateStore, log: LogController) -> None:
|
||||
state.path = path.expanduser().resolve(strict=False)
|
||||
try:
|
||||
store.write(GuiSessionState(last_profile_path=str(state.path)))
|
||||
except Exception as exc:
|
||||
log.exception("Failed to update GUI session-state with the active config profile path", exc, level="WARN")
|
||||
|
||||
|
||||
def load_startup(project_root: Path, log: LogController) -> ProfileState:
|
||||
store = GuiSessionStateStore(project_root / "python_app/runtime/gui_session_state.json")
|
||||
root_path = (project_root / "run_config.json").resolve(strict=False)
|
||||
requested = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip()
|
||||
if not requested:
|
||||
try:
|
||||
requested = store.load().last_profile_path.strip()
|
||||
except Exception as exc:
|
||||
log.warning("Failed to read GUI session-state; using root run_config.json.", details=log.exception_details(exc))
|
||||
path = Path(requested).expanduser() if requested else root_path
|
||||
if not path.is_absolute():
|
||||
path = project_root / path
|
||||
path = path.resolve(strict=False)
|
||||
try:
|
||||
profile = GuiProfileModel.load_from_path(path)
|
||||
except Exception as exc:
|
||||
if path == root_path:
|
||||
raise
|
||||
log.warning("Failed to load the last selected config profile; falling back to root run_config.json.",
|
||||
details=log.exception_details(exc))
|
||||
profile, path = GuiProfileModel.load_from_path(root_path), root_path
|
||||
config = profile.run_config.clone()
|
||||
log.set_level(config.logging.level)
|
||||
gui = profile.gui or default_gui_state_for_config(config, project_root)
|
||||
if profile.gui is None and path != root_path:
|
||||
log.info(f"Loaded legacy run config without GUI defaults: {path}")
|
||||
state = ProfileState(config, gui, path, store)
|
||||
_remember(state, path, store, log)
|
||||
return state
|
||||
|
||||
|
||||
class ProfileController:
|
||||
def __init__(
|
||||
self, parent: QWidget, project_root: Path, state: ProfileState, config: ConfigController,
|
||||
preprocess: PreprocessController, pipeline: PipelineController, radar: RadarSettingsController,
|
||||
live: LiveSettingsController, log: LogController,
|
||||
) -> None:
|
||||
self.state = state
|
||||
self._parent, self._project_root, self._config = parent, project_root, config
|
||||
self._preprocess, self._pipeline, self._radar, self._live, self._log = preprocess, pipeline, radar, live, log
|
||||
self._session = state.session
|
||||
|
||||
@staticmethod
|
||||
def write_profile(
|
||||
path: Path, profile: GuiProfileModel, active: RunConfigModel | None = None,
|
||||
*, allow_overwrite: bool = True, dataset: bool = False,
|
||||
) -> GuiProfileModel:
|
||||
"""Atomically save settings or a dataset profile with its acquisition provenance."""
|
||||
if not allow_overwrite and path.exists():
|
||||
raise FileExistsError(f"Config profile output already exists: {path}")
|
||||
if dataset and active is not None:
|
||||
profile = profile.clone()
|
||||
profile.run_config.radar = deepcopy(active.radar)
|
||||
profile.run_config.input_switch = deepcopy(active.input_switch)
|
||||
profile.run_config.output_switch = deepcopy(active.output_switch)
|
||||
profile.run_config.combos = deepcopy(active.combos)
|
||||
profile.run_config.preprocess = deepcopy(active.preprocess)
|
||||
profile.run_config.gpr.tx_geometry = deepcopy(active.gpr.tx_geometry)
|
||||
profile.run_config.gpr.rx_geometry = deepcopy(active.gpr.rx_geometry)
|
||||
atomic_write_json(path, profile.to_dict(), durable=True)
|
||||
return profile
|
||||
|
||||
def bind_actions(self, actions: PrimaryView) -> None:
|
||||
actions.load.clicked.connect(self.load_dialog)
|
||||
actions.save.clicked.connect(self.save_dialog)
|
||||
|
||||
def build_config(self) -> RunConfigModel:
|
||||
return self._config.build_config(self.state.config, self._preprocess.selected_sets,
|
||||
self._live.writer.path, self._log.level.currentText().lower())
|
||||
|
||||
def build_gui_state(self) -> GuiStateModel:
|
||||
return self._config.build_gui_state(self.state.config, self._preprocess.settings())
|
||||
|
||||
def build_profile(self) -> GuiProfileModel:
|
||||
return GuiProfileModel(run_config=self.build_config(), gui=self.build_gui_state())
|
||||
|
||||
def radar_key(self) -> str:
|
||||
return self._config.radar_key(self.state.config)
|
||||
|
||||
def write(self, path: Path, *, allow_overwrite: bool = True, dataset: bool = False) -> GuiProfileModel:
|
||||
return self.write_profile(path, self.build_profile(), self._pipeline.active_config,
|
||||
allow_overwrite=allow_overwrite, dataset=dataset)
|
||||
|
||||
def save_dialog(self) -> None:
|
||||
try:
|
||||
selected, _ = QFileDialog.getSaveFileName(self._parent, "Save Config Profile", str(self.state.path),
|
||||
"JSON Files (*.json);;All Files (*)")
|
||||
if not selected:
|
||||
return
|
||||
path = Path(selected).expanduser().resolve(strict=False)
|
||||
if not path.suffix:
|
||||
path = path.with_suffix(".json")
|
||||
profile = self.write(path)
|
||||
self.state.config = profile.run_config.clone()
|
||||
self.state.gui = profile.gui or default_gui_state_for_config(profile.run_config, self._project_root)
|
||||
_remember(self.state, path, self._session, self._log)
|
||||
config, sweep = profile.run_config, profile.run_config.radar.sweep
|
||||
points = "from Kamil ADC stream" if config.is_kamil_adc else str(sweep.points)
|
||||
self._log.info(
|
||||
f"Config profile saved: path={path}, combos={len(config.combos)}, "
|
||||
f"sweep={sweep.start_hz:g}..{sweep.stop_hz:g} Hz, points={points}, "
|
||||
f"ifbw={sweep.if_bandwidth_hz:g} Hz, power={sweep.power_dbm:g} dBm, "
|
||||
f"processing_mode={self.state.gui.processing.selected_mode}"
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log.show_exception("Failed to save config profile", exc)
|
||||
|
||||
def _ready_to_load(self) -> bool:
|
||||
if self._pipeline.closing:
|
||||
return False
|
||||
reason = self._preprocess.busy_reason()
|
||||
if reason:
|
||||
self._log.show_error(reason, details=self._preprocess.capture_state_details())
|
||||
return False
|
||||
if self._pipeline.running or self._pipeline.should_run or self._pipeline.paused:
|
||||
self._log.show_error("Stop all pipeline processes before loading a config profile")
|
||||
return False
|
||||
return True
|
||||
|
||||
def load(self, path: Path) -> None:
|
||||
if not self._ready_to_load():
|
||||
return
|
||||
try:
|
||||
path = path.expanduser().resolve(strict=False)
|
||||
profile = GuiProfileModel.load_from_path(path)
|
||||
self.apply(profile, path)
|
||||
kind = "legacy run config" if profile.gui is None else "full GUI profile"
|
||||
self._log.info(f"Config profile loaded: path={path}, kind={kind}, combos={len(self.state.config.combos)}, "
|
||||
f"processing_mode={self._live.active_mode}")
|
||||
except Exception as exc:
|
||||
self._log.show_exception("Failed to load config profile", exc)
|
||||
|
||||
def load_dialog(self) -> None:
|
||||
if not self._ready_to_load():
|
||||
return
|
||||
selected, _ = QFileDialog.getOpenFileName(self._parent, "Load Config Profile", str(self.state.path),
|
||||
"JSON Files (*.json);;All Files (*)")
|
||||
if selected:
|
||||
self.load(Path(selected))
|
||||
|
||||
def apply(self, profile: GuiProfileModel, path: Path) -> None:
|
||||
self._log.debug(f"Applying loaded profile: path={path}, has_gui_state={profile.gui is not None}.")
|
||||
config = profile.run_config.clone()
|
||||
gui = profile.gui or default_gui_state_for_config(config, self._project_root)
|
||||
self.state.config, self.state.gui = config, gui
|
||||
self._config.apply_profile_widgets(config, gui)
|
||||
self._radar.sync_profile(gui.switches.combo_mode)
|
||||
self._live.sync_controls()
|
||||
self._log.set_level(self._log.level.currentText())
|
||||
self._preprocess.apply_profile(config, gui.preprocess_dialog)
|
||||
self._pipeline.plots.live_window = cpp_bscan_replay_window_for_config(config)
|
||||
self._pipeline.plots.clear_bscan()
|
||||
self._live.set_mode_page(gui.processing.selected_mode)
|
||||
self._radar.initial_limits()
|
||||
if self._preprocess.dialog is not None:
|
||||
self._preprocess.refresh_sets()
|
||||
self._live.mode_changed(gui.processing.selected_mode)
|
||||
self._pipeline.history_changed()
|
||||
_remember(self.state, path, self._session, self._log)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Radar-specific editor constraints and device limits."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWidgets import QCheckBox, QComboBox, QLineEdit, QPlainTextEdit
|
||||
|
||||
from python_app.gui.controllers.log_controller import LogController
|
||||
from python_app.gui.controllers.sections.views import RadarView, SwitchView, ProcessingView, connect_setting
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
class RadarSettingsController:
|
||||
def __init__(
|
||||
self, radar: RadarView, switches: SwitchView, processing: ProcessingView,
|
||||
defaults: Callable[[], RunConfigModel], log: LogController, *,
|
||||
reset_preprocess: Callable[[], None], live_changed: Callable[[], None],
|
||||
) -> None:
|
||||
self._radar, self._switches, self._processing = radar, switches, processing
|
||||
self._defaults, self._log = defaults, log
|
||||
self._reset_preprocess, self._live_changed = reset_preprocess, live_changed
|
||||
self.limits: dict[str, float | int] | None = None
|
||||
switches.select_text.clicked.connect(lambda: self.set_combo_mode("text"))
|
||||
switches.select_single.clicked.connect(lambda: self.set_combo_mode("single"))
|
||||
for widget in radar.sweep_edits:
|
||||
widget.editingFinished.connect(self.sweep_changed)
|
||||
for name, widget in radar.fields.items():
|
||||
if name.startswith(("_adc_", "_optical_")) and isinstance(widget, (QLineEdit, QPlainTextEdit, QComboBox, QCheckBox)):
|
||||
connect_setting(widget, self._optical_changed if widget in (radar.optical_enabled, radar.optical_mode)
|
||||
else self._reset_preprocess)
|
||||
|
||||
def set_combo_mode(self, mode: str) -> None:
|
||||
config = self._defaults()
|
||||
matrix = config.is_multi_device
|
||||
text_selected = matrix or mode != "single"
|
||||
for button, selected in ((self._switches.select_text, text_selected),
|
||||
(self._switches.select_single, not text_selected)):
|
||||
button.setChecked(selected)
|
||||
button.setEnabled(not matrix)
|
||||
combos = self._switches.combos
|
||||
if matrix:
|
||||
combos.setText(",".join(f"{combo.input}:{combo.output}" for combo in config.build_runtime_combos()))
|
||||
combos.setEnabled(text_selected and not matrix)
|
||||
for widget in (self._switches.single_input, self._switches.single_output):
|
||||
widget.setEnabled(not text_selected and not matrix)
|
||||
|
||||
def sync_profile(self, combo_mode: str) -> None:
|
||||
self.set_combo_mode(combo_mode)
|
||||
adc = self._defaults().is_kamil_adc
|
||||
self._radar.vna_panel.setVisible(not adc)
|
||||
self._radar.adc_panel.setVisible(adc)
|
||||
self.sync_adc_controls()
|
||||
|
||||
def sync_adc_controls(self) -> None:
|
||||
enabled = self._radar.optical_enabled.isChecked()
|
||||
mode = self._radar.optical_mode.currentText()
|
||||
for active, widgets in ((enabled, self._radar.optical_common),
|
||||
(enabled and mode == "manual", self._radar.optical_manual),
|
||||
(enabled and mode == "variation", self._radar.optical_variation)):
|
||||
for widget in widgets:
|
||||
widget.setEnabled(active)
|
||||
self._radar.manual_panel.setVisible(mode == "manual")
|
||||
self._radar.variation_panel.setVisible(mode == "variation")
|
||||
|
||||
def _optical_changed(self) -> None:
|
||||
self.sync_adc_controls()
|
||||
self._reset_preprocess()
|
||||
|
||||
def initial_limits(self) -> None:
|
||||
mode = self._defaults().radar.driver_mode
|
||||
if mode == "native":
|
||||
self._log.debug("Querying radar limits from device (native driver mode).")
|
||||
self.refresh_limits()
|
||||
else:
|
||||
self._log.debug(f"Skipping device radar-limit query (driver mode={mode}).")
|
||||
self.apply_limits(None)
|
||||
|
||||
def refresh_limits(self) -> bool:
|
||||
config = self._defaults()
|
||||
if config.is_kamil_adc:
|
||||
return self.apply_limits(None)
|
||||
service = (LibreVnaService(serial=config.radar.serial or None) if config.is_multi_device
|
||||
else create_single_radar_service(config))
|
||||
return self.apply_limits(service.read_device_limits())
|
||||
|
||||
def apply_limits(self, limits: dict[str, float | int] | None) -> bool:
|
||||
previous, self.limits = self.limits, limits
|
||||
labels = ("Start Hz", "Stop Hz", "Points", "IF BW Hz", "Stimulus Power dBm")
|
||||
edits = self._radar.sweep_edits
|
||||
hint = self._radar.limits_hint
|
||||
if limits is None:
|
||||
for widget, label in zip(self._radar.sweep_labels, labels, strict=True):
|
||||
widget.setText(label)
|
||||
hint.setText("Device limits are not available.")
|
||||
edits[-1].setToolTip("Stimulus power configured in the active profile.")
|
||||
return False
|
||||
|
||||
frequency = (float(limits["min_frequency_hz"]), float(limits["max_frequency_hz"]))
|
||||
ifbw = (float(limits["min_ifbw_hz"]), float(limits["max_ifbw_hz"]))
|
||||
power = (float(limits["min_power_dbm"]), float(limits["max_power_dbm"]))
|
||||
max_points = int(limits["max_points"])
|
||||
ranges = (frequency, frequency, (1, max_points), ifbw, power)
|
||||
before = [edit.text().strip() for edit in edits]
|
||||
for index, (widget, label, edit, bounds) in enumerate(zip(self._radar.sweep_labels, labels, edits, ranges, strict=True)):
|
||||
minimum, maximum = bounds
|
||||
widget.setText(f"{label} ({minimum:g}..{maximum:g})")
|
||||
self._clamp(edit, minimum, maximum, integer=index == 2)
|
||||
if float(edits[0].text()) > float(edits[1].text()):
|
||||
edits[1].setText(edits[0].text())
|
||||
edits[-1].setToolTip(f"Device range: {power[0]:g}..{power[1]:g} dBm")
|
||||
summary = (f"freq={frequency[0]:g}..{frequency[1]:g} Hz, points=1..{max_points}, "
|
||||
f"ifbw={ifbw[0]:g}..{ifbw[1]:g} Hz, power={power[0]:g}..{power[1]:g} dBm")
|
||||
hint.setText(f"Limits: {summary}")
|
||||
if previous != limits:
|
||||
self._log.info(f"Applied radar device limits: {summary}")
|
||||
after = [edit.text().strip() for edit in edits]
|
||||
adjustments = [f"{label}: {old or '<empty>'} -> {new}"
|
||||
for label, old, new in zip(labels, before, after, strict=True) if old != new]
|
||||
if adjustments:
|
||||
self._log.warning("Radar fields were adjusted to satisfy device limits.", details="\n".join(adjustments))
|
||||
self.sync_frequency_limits()
|
||||
return before != after
|
||||
|
||||
@staticmethod
|
||||
def _clamp(widget: QLineEdit, minimum: float, maximum: float, *, integer: bool = False) -> None:
|
||||
try:
|
||||
value = float(widget.text().strip())
|
||||
if integer:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
value = minimum
|
||||
value = min(max(value, minimum), maximum)
|
||||
widget.setText(str(int(value)) if integer else f"{value:g}")
|
||||
|
||||
def sync_frequency_limits(self) -> bool:
|
||||
try:
|
||||
bounds = sorted(float(widget.text()) / 1e6 for widget in self._radar.sweep_edits[:2])
|
||||
except ValueError:
|
||||
return False
|
||||
changed = False
|
||||
adjustments: list[str] = []
|
||||
for index, widget in enumerate(self._processing.frequency_controls):
|
||||
before = widget.value()
|
||||
changed |= (widget.minimum(), widget.maximum()) != tuple(bounds)
|
||||
with QSignalBlocker(widget):
|
||||
widget.setRange(*bounds)
|
||||
if before != widget.value():
|
||||
adjustments.append(f"Frequency control {index + 1}: {before:g} -> {widget.value():g}")
|
||||
if adjustments:
|
||||
self._log.warning("Processing frequency limits were clamped to the active radar sweep.",
|
||||
details="\n".join(adjustments))
|
||||
return changed or bool(adjustments)
|
||||
|
||||
def sweep_changed(self) -> None:
|
||||
self._reset_preprocess()
|
||||
if self.sync_frequency_limits():
|
||||
self._live_changed()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Bounded recording of independent pipeline stages on a non-daemon writer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import Protocol
|
||||
|
||||
from python_app.models.gui_profile_schema import ProfileWriter
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_RECORDING_CHUNK_SIZE = 200
|
||||
_RECORDING_QUEUE_CHUNKS = 8
|
||||
_Chunk = tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection], dict[int, dict]]
|
||||
|
||||
|
||||
class SnapshotWriter(Protocol):
|
||||
@property
|
||||
def directory(self) -> Path: ...
|
||||
|
||||
def append(self, raw: list[SweepCollection], preprocessed: list[SweepCollection],
|
||||
results: list[ResultCollection], *, processing_settings: dict[int, dict] | None = None) -> None: ...
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Session:
|
||||
writer: SnapshotWriter
|
||||
starting: bool
|
||||
since_ns: int = field(default_factory=time.monotonic_ns)
|
||||
queue: queue.Queue[_Chunk] = field(default_factory=lambda: queue.Queue(maxsize=_RECORDING_QUEUE_CHUNKS))
|
||||
finished: threading.Event = field(default_factory=threading.Event)
|
||||
raw: list[SweepCollection] = field(default_factory=list)
|
||||
preprocessed: list[SweepCollection] = field(default_factory=list)
|
||||
results: list[ResultCollection] = field(default_factory=list)
|
||||
tail: _Chunk | None = None
|
||||
thread: threading.Thread | None = None
|
||||
error: str | None = None
|
||||
capacity_exceeded: bool = False
|
||||
received: int = 0
|
||||
enqueued: int = 0
|
||||
stop_reason: str = ""
|
||||
|
||||
|
||||
def _write_chunks(session: _Session) -> None:
|
||||
"""Own disk I/O only; no callbacks or GUI objects cross the worker boundary."""
|
||||
while True:
|
||||
try:
|
||||
chunk = session.queue.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
if not session.finished.is_set():
|
||||
continue
|
||||
try: # A final enqueue may have raced get() before finished.set().
|
||||
chunk = session.queue.get_nowait()
|
||||
except queue.Empty:
|
||||
if session.tail is None:
|
||||
return
|
||||
chunk, session.tail = session.tail, None
|
||||
try:
|
||||
raw, preprocessed, results, settings = chunk
|
||||
session.writer.append(raw, preprocessed, results, processing_settings=settings)
|
||||
except Exception as exc: # noqa: BLE001 - report on the GUI thread
|
||||
logger.exception("Disk recording write failed")
|
||||
session.error = f"{type(exc).__name__}: {exc}"
|
||||
session.finished.set()
|
||||
session.tail = None
|
||||
while True:
|
||||
try:
|
||||
session.queue.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
|
||||
|
||||
class RecordingController:
|
||||
def __init__(self, store: NpzStore, processing_settings: Mapping[int, dict],
|
||||
write_profile: ProfileWriter, log: Callable[[str], None], error: Callable[..., None]) -> None:
|
||||
self._store, self._settings = store, processing_settings
|
||||
self._write_profile, self._log, self._error = write_profile, log, error
|
||||
self._session: _Session | None = None
|
||||
self._target = 0
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._session is not None
|
||||
|
||||
def start(self, root: Path, name: str, prefix: str, target: int, *,
|
||||
running: bool, start_acquisition: Callable[[], bool]) -> None:
|
||||
if self.active:
|
||||
self._error("A recording is already in progress; wait for it to finish before starting another",
|
||||
details=f"progress={self.status()['collected']}/{self._target}")
|
||||
return
|
||||
if target <= 0:
|
||||
self._error("Recording count must be greater than zero")
|
||||
return
|
||||
destination = self._store.snapshot_directory(root, name, name_prefix=prefix)
|
||||
if destination.exists():
|
||||
self._error("Recording destination already exists; choose a new name or path",
|
||||
details=f"destination={destination}")
|
||||
return
|
||||
|
||||
writer: SnapshotWriter | None = None
|
||||
try:
|
||||
writer = self._store.create_snapshot_stream(root, name, name_prefix=prefix)
|
||||
self._write_profile(writer.directory / "config_profile.json", allow_overwrite=False, dataset=running)
|
||||
session = _Session(writer, starting=not running)
|
||||
session.thread = threading.Thread(target=_write_chunks, args=(session,), name="disk-recorder", daemon=False)
|
||||
self._session, self._target = session, target
|
||||
session.thread.start()
|
||||
except Exception as exc: # noqa: BLE001 - includes thread resource exhaustion
|
||||
self._session, self._target = None, 0
|
||||
if writer is not None:
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(writer.directory)
|
||||
self._error(f"Failed to start disk recording: {exc}", details=traceback.format_exc())
|
||||
return
|
||||
|
||||
start_error: Exception | None = None
|
||||
try:
|
||||
started = running or start_acquisition()
|
||||
except Exception as exc: # noqa: BLE001 - retain anything accepted during startup
|
||||
started, start_error = False, exc
|
||||
finally:
|
||||
session.starting = False
|
||||
if not started:
|
||||
self.finish()
|
||||
if not session.received:
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(writer.directory)
|
||||
if start_error is not None:
|
||||
self._error(f"Failed to start acquisition for disk recording: {start_error}",
|
||||
details="".join(traceback.format_exception(start_error)))
|
||||
else:
|
||||
self._log("Disk recording canceled because acquisition did not start")
|
||||
return
|
||||
if not running:
|
||||
try:
|
||||
self._write_profile(writer.directory / "config_profile.json", dataset=True)
|
||||
except Exception as exc: # noqa: BLE001 - keep accepted data and the initial profile
|
||||
self.finish()
|
||||
self._error(f"Failed to update disk recording config profile: {exc}", details=traceback.format_exc())
|
||||
return
|
||||
self._log(f"Disk recording armed: streaming the next {target} measurement(s) to {writer.directory}")
|
||||
self.poll()
|
||||
|
||||
def record(self, stage: str, collection: SweepCollection | ResultCollection) -> None:
|
||||
session = self._session
|
||||
if session is None or session.finished.is_set() or collection.capture_start_ns < session.since_ns:
|
||||
return
|
||||
if stage == "raw" and isinstance(collection, SweepCollection):
|
||||
session.raw.append(collection)
|
||||
elif stage == "preprocessed" and isinstance(collection, SweepCollection):
|
||||
session.preprocessed.append(collection)
|
||||
elif stage == "results" and isinstance(collection, ResultCollection):
|
||||
session.results.append(collection)
|
||||
else:
|
||||
raise ValueError(f"Invalid collection for recording stage: {stage}")
|
||||
session.received += 1
|
||||
if max(len(session.raw), len(session.preprocessed), len(session.results)) >= _RECORDING_CHUNK_SIZE:
|
||||
if not self._flush(session):
|
||||
return
|
||||
if session.enqueued + len(session.results) >= self._target and self._flush(session):
|
||||
session.finished.set()
|
||||
|
||||
def _flush(self, session: _Session) -> bool:
|
||||
if not (session.raw or session.preprocessed or session.results):
|
||||
return True
|
||||
if session.error is not None or session.thread is None or not session.thread.is_alive():
|
||||
session.error = session.error or "Disk writer stopped unexpectedly"
|
||||
session.finished.set()
|
||||
self.poll()
|
||||
return False
|
||||
chunk = (session.raw, session.preprocessed, session.results,
|
||||
{result.settings_revision: self._settings[result.settings_revision]
|
||||
for result in session.results if result.settings_revision in self._settings})
|
||||
try:
|
||||
session.queue.put_nowait(chunk)
|
||||
except queue.Full:
|
||||
session.tail, session.capacity_exceeded = chunk, True
|
||||
session.finished.set()
|
||||
self._log("Disk writer cannot keep up; finishing accepted data and stopping recording")
|
||||
session.enqueued += len(session.results)
|
||||
session.raw, session.preprocessed, session.results = [], [], []
|
||||
return True
|
||||
|
||||
def poll(self) -> None:
|
||||
session = self._session
|
||||
if session is None or session.starting:
|
||||
return
|
||||
if session.error is not None:
|
||||
self._session = None
|
||||
self._error("Disk recording failed and was stopped",
|
||||
details=f"dataset={session.writer.directory}\nerror={session.error}")
|
||||
elif session.finished.is_set() and session.thread is not None and not session.thread.is_alive():
|
||||
self._session = None
|
||||
if session.enqueued >= self._target:
|
||||
self._log(f"Disk recording complete: {session.enqueued} measurement(s) written to {session.writer.directory}")
|
||||
else:
|
||||
reason = session.stop_reason or "writer queue full"
|
||||
self._log(f"Disk recording stopped: {session.enqueued} of {self._target} measurement(s); "
|
||||
f"reason: {reason}. Accepted data saved in {session.writer.directory}")
|
||||
if session.capacity_exceeded:
|
||||
self._error("Disk recording stopped because the writer could not keep up",
|
||||
details=f"Accepted data saved in {session.writer.directory}; results={session.enqueued}")
|
||||
|
||||
def finish(self, *, reason: str = "pipeline stopped") -> None:
|
||||
"""Stop/Close share a nonblocking flush; startup's technical Stop is deferred."""
|
||||
session = self._session
|
||||
if session is None or session.starting:
|
||||
return
|
||||
if not session.finished.is_set():
|
||||
session.stop_reason = reason
|
||||
if self._flush(session):
|
||||
session.finished.set()
|
||||
if session.enqueued < self._target:
|
||||
self._log(f"Disk recording stopped: {session.enqueued} of {self._target} measurement(s); "
|
||||
f"reason: {reason}. Finishing accepted data.")
|
||||
self.poll()
|
||||
if self.active:
|
||||
self._log("Disk recording is finishing queued writes; the dataset is not complete yet")
|
||||
|
||||
def status(self) -> dict[str, int | bool]:
|
||||
session = self._session
|
||||
return {"active": self.active, "collected": session.enqueued + len(session.results) if session else 0,
|
||||
"target": self._target}
|
||||
@@ -0,0 +1 @@
|
||||
"""Independent renderers for processed radar results."""
|
||||
@@ -0,0 +1,162 @@
|
||||
"""B-scan rendering and cache helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
from PyQt6.QtCore import QRectF
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.models.collection_identity import collection_key
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.models.gui_profile_schema import GuiBscanStateModel
|
||||
from python_app.gui.controllers.renderers.common import build_lut, create_plot
|
||||
|
||||
|
||||
def _result_tail(
|
||||
*,
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
) -> list[ResultCollection]:
|
||||
"""Choose by acquisition order, keeping the newest result of each exact sweep."""
|
||||
unique_reversed_tail: list[ResultCollection] = []
|
||||
seen_keys: set[tuple[int, int]] = set()
|
||||
for collection in reversed(result_history[-history_limit:]):
|
||||
key = collection_key(collection)
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
unique_reversed_tail.append(collection)
|
||||
|
||||
unique_reversed_tail.reverse()
|
||||
return unique_reversed_tail
|
||||
|
||||
|
||||
def apply_mean_ascan_subtraction(
|
||||
history: deque[np.ndarray],
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> np.ndarray:
|
||||
"""Subtract mean A-scan across sweeps when enabled."""
|
||||
sweeps = np.vstack(history).astype(np.float32, copy=False)
|
||||
if not enabled:
|
||||
return sweeps
|
||||
mean_trace = np.mean(sweeps, axis=0, dtype=np.float32)
|
||||
return sweeps - mean_trace
|
||||
|
||||
|
||||
def rebuild_bscan_history_from_results(
|
||||
result_history: list[ResultCollection],
|
||||
history_limit: int,
|
||||
) -> tuple[dict[tuple[int, int], deque[np.ndarray]], dict[tuple[int, int], np.ndarray]]:
|
||||
"""Group compatible range profiles; a changed depth axis starts a new image."""
|
||||
history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||
depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||
|
||||
result_tail = _result_tail(
|
||||
result_history=result_history,
|
||||
history_limit=history_limit,
|
||||
)
|
||||
|
||||
for collection in result_tail:
|
||||
for block in collection.blocks:
|
||||
key = (block.combo.input, block.combo.output)
|
||||
for payload in block.payloads:
|
||||
if payload.kind != 1 or payload.processing_name != "bscan":
|
||||
continue
|
||||
if payload.frequency_hz.size == 0 or payload.trace.size == 0:
|
||||
continue
|
||||
if payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
|
||||
depth_axis = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
amplitudes = np.asarray(np.real(payload.trace), dtype=np.float32)
|
||||
history = history_by_combo.get(key)
|
||||
stored_axis = depth_axis_by_combo.get(key)
|
||||
if (
|
||||
history is None
|
||||
or stored_axis is None
|
||||
or stored_axis.shape != depth_axis.shape
|
||||
or not np.allclose(stored_axis, depth_axis, rtol=1e-4, atol=1e-6)
|
||||
):
|
||||
history = deque(maxlen=history_limit)
|
||||
history_by_combo[key] = history
|
||||
depth_axis_by_combo[key] = depth_axis.copy()
|
||||
|
||||
history.append(amplitudes.copy())
|
||||
|
||||
return history_by_combo, depth_axis_by_combo
|
||||
|
||||
|
||||
def bscan_lookup_table(axis_mode: str) -> np.ndarray:
|
||||
"""Build B-scan colormap table for selected axis mode."""
|
||||
if axis_mode == "abs":
|
||||
return build_lut(["#440154", "#31688e", "#35b779", "#fde725"])
|
||||
return build_lut(["#2166ac", "#67a9cf", "#f7f7f7", "#ef8a62", "#b2182b"])
|
||||
|
||||
|
||||
def bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
|
||||
"""Compute image levels for B-scan data based on axis mode."""
|
||||
min_value = float(np.min(sweeps))
|
||||
max_value = float(np.max(sweeps))
|
||||
if axis_mode == "abs":
|
||||
if max_value <= min_value:
|
||||
return min_value, min_value + 1e-6
|
||||
return min_value, max_value
|
||||
|
||||
max_abs = max(abs(min_value), abs(max_value), 1e-6)
|
||||
return -max_abs, max_abs
|
||||
|
||||
|
||||
class BscanRenderer:
|
||||
"""Own the heatmap and its per-combination arrays; input history stays external."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.widget = create_plot()
|
||||
self.widget.setLabel("bottom", "Sweep #")
|
||||
self.widget.setLabel("left", "Range", units="m")
|
||||
item = self.widget.getPlotItem()
|
||||
assert item is not None
|
||||
item.getViewBox().invertY(False)
|
||||
item.getViewBox().enableAutoRange(x=False, y=False)
|
||||
self.image = pg.ImageItem(axisOrder="row-major")
|
||||
item.addItem(self.image)
|
||||
self.history_by_combo: dict[tuple[int, int], deque[np.ndarray]] = {}
|
||||
self.depth_axis_by_combo: dict[tuple[int, int], np.ndarray] = {}
|
||||
self.signature: tuple[object, ...] | None = None
|
||||
|
||||
def sync(self, results: list[ResultCollection], window: int) -> bool:
|
||||
# Completion time distinguishes recomputed payloads for the same acquisition.
|
||||
signature = (window, tuple((collection_key(c), c.monotonic_ns, c.settings_revision) for c in results[-window:]))
|
||||
if signature == self.signature:
|
||||
return False
|
||||
self.history_by_combo, self.depth_axis_by_combo = rebuild_bscan_history_from_results(results, window)
|
||||
self.signature = signature
|
||||
return True
|
||||
|
||||
def draw(self, key: tuple[int, int] | None, settings: GuiBscanStateModel) -> bool:
|
||||
history = self.history_by_combo.get(key) if key is not None else None
|
||||
depth = self.depth_axis_by_combo.get(key) if key is not None else None
|
||||
if not history or depth is None:
|
||||
self.image.clear()
|
||||
return False
|
||||
sweeps = apply_mean_ascan_subtraction(history, enabled=settings.subtract_mean_ascan)
|
||||
depth_min, depth_max = float(np.min(depth)), float(np.max(depth))
|
||||
count = sweeps.shape[0]
|
||||
self.image.setImage(sweeps.T, autoLevels=False)
|
||||
self.image.setRect(QRectF(.5, depth_min, float(count), max(depth_max - depth_min, 1e-6)))
|
||||
self.image.setLookupTable(bscan_lookup_table(settings.axis))
|
||||
self.image.setLevels(bscan_levels(sweeps, settings.axis))
|
||||
item = self.widget.getPlotItem()
|
||||
assert item is not None
|
||||
item.getViewBox().setRange(xRange=(.5, .5 + count), yRange=(depth_min, depth_max), padding=.02)
|
||||
assert key is not None
|
||||
self.widget.setTitle(f"B-scan S21 in{key[0]}/out{key[1]} | sweeps={count}")
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
self.history_by_combo.clear()
|
||||
self.depth_axis_by_combo.clear()
|
||||
self.signature = None
|
||||
self.image.clear()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Plot construction and color tables shared by radar views."""
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
|
||||
def create_plot(title: str = "") -> pg.PlotWidget:
|
||||
plot = pg.PlotWidget(background="#0f141c", enableMenu=False, title=title)
|
||||
plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
return plot
|
||||
|
||||
|
||||
def build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
|
||||
"""Interpolate hex color stops into 8-bit RGB LUT array."""
|
||||
stop_positions = np.linspace(0.0, 1.0, num=len(stops), dtype=np.float32)
|
||||
sample_positions = np.linspace(0.0, 1.0, num=size, dtype=np.float32)
|
||||
stop_colors = np.asarray([pg.mkColor(value).getRgb()[:3] for value in stops], dtype=np.float32)
|
||||
|
||||
lut = np.empty((size, 3), dtype=np.uint8)
|
||||
for channel in range(3):
|
||||
lut[:, channel] = np.interp(sample_positions, stop_positions, stop_colors[:, channel]).astype(np.uint8)
|
||||
return lut
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""GPR heatmaps and object overlays with renderer-owned graphics and geometry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QRectF
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from python_app.gui.controllers.renderers.common import build_lut, create_plot
|
||||
from python_app.gui.result_payloads import (
|
||||
collection_payload_by_name, collection_payloads_by_prefix,
|
||||
filter_object_rows, gpr_object_rows,
|
||||
)
|
||||
from python_app.models.run_config_validation import parse_position_csv
|
||||
from python_app.models.dataset_model import ResultCollection, ResultPayload
|
||||
from python_app.models.gui_profile_schema import GuiGprStateModel, GuiLegacyGprStateModel
|
||||
from python_app.models.run_config_schema import GprModel
|
||||
|
||||
GprSettings = GuiGprStateModel | GuiLegacyGprStateModel
|
||||
|
||||
|
||||
def normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
|
||||
"""Order display bounds and expand an empty interval around its center."""
|
||||
lower, upper = sorted((float(start), float(stop)))
|
||||
if upper - lower >= minimum_span:
|
||||
return lower, upper
|
||||
center = 0.5 * (lower + upper)
|
||||
return center - 0.5 * minimum_span, center + 0.5 * minimum_span
|
||||
|
||||
|
||||
def display_y_min(z_min: float, z_max: float) -> float:
|
||||
"""Leave room for antenna markers when the surface is visible."""
|
||||
if z_min > 0.0:
|
||||
return z_min
|
||||
return z_min - max(max(z_max - z_min, 1e-6) * 0.03, 0.06)
|
||||
|
||||
|
||||
def object_label_candidates(
|
||||
x_m: float, z_m: float, *, x_span: float, z_span: float,
|
||||
) -> list[tuple[float, float, tuple[float, float]]]:
|
||||
"""Candidate label placements, ordered by proximity to the object."""
|
||||
dx, dz = max(x_span * 0.015, 0.02), max(z_span * 0.02, 0.02)
|
||||
return [
|
||||
(x_m + dx, z_m - dz, (0.0, 1.0)),
|
||||
(x_m + dx, z_m + dz, (0.0, 0.0)),
|
||||
(x_m - dx, z_m - dz, (1.0, 1.0)),
|
||||
(x_m - dx, z_m + dz, (1.0, 0.0)),
|
||||
(x_m, z_m - dz * 1.35, (0.5, 1.0)),
|
||||
(x_m, z_m + dz * 1.35, (0.5, 0.0)),
|
||||
(x_m + dx * 2.2, z_m - dz * 1.5, (0.0, 1.0)),
|
||||
(x_m + dx * 2.2, z_m + dz * 1.5, (0.0, 0.0)),
|
||||
(x_m - dx * 2.2, z_m - dz * 1.5, (1.0, 1.0)),
|
||||
(x_m - dx * 2.2, z_m + dz * 1.5, (1.0, 0.0)),
|
||||
]
|
||||
|
||||
|
||||
def _title(settings: GprSettings, *, objects_only: bool = False) -> str:
|
||||
title = f"Legacy GPR {settings.mode}" if isinstance(settings, GuiLegacyGprStateModel) else "GPR coherent BP"
|
||||
return f"{title} Objects Only" if objects_only else title
|
||||
|
||||
|
||||
class GprRenderer:
|
||||
"""Draw one collection from explicit display settings and antenna geometry."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.widget = create_plot("GPR coherent BP")
|
||||
self.widget.setBackground("#0f141c")
|
||||
plot = self.widget.getPlotItem()
|
||||
assert plot is not None
|
||||
self._plot = plot
|
||||
self._view = plot.getViewBox()
|
||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
self._configure_axes()
|
||||
self._lookup_table = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
|
||||
self._image = pg.ImageItem(axisOrder="row-major")
|
||||
self._tx = pg.ScatterPlotItem()
|
||||
self._rx = pg.ScatterPlotItem()
|
||||
self._points = pg.ScatterPlotItem()
|
||||
self._region_centers = pg.ScatterPlotItem()
|
||||
self._markers = (self._tx, self._rx, self._points, self._region_centers)
|
||||
for item, z_value in zip((self._image, *self._markers), (0, 20, 20, 30, 30)):
|
||||
item.setZValue(z_value)
|
||||
item.hide()
|
||||
self._plot.addItem(item)
|
||||
self._labels: list[pg.TextItem] = []
|
||||
self._masks: list[pg.ImageItem] = []
|
||||
self._contours: list[pg.IsocurveItem] = []
|
||||
self._geometry_signature: tuple[object, ...] | None = None
|
||||
self._selected_geometry = (np.zeros(0, dtype=np.float32), np.zeros(0, dtype=np.float32))
|
||||
|
||||
def _configure_axes(self) -> None:
|
||||
plot = self._plot
|
||||
plot.showAxis("left", show=True)
|
||||
plot.showAxis("bottom", show=True)
|
||||
plot.setClipToView(True)
|
||||
plot.setLabel("bottom", "X", units="m")
|
||||
plot.setLabel("left", "Range", units="m")
|
||||
plot.setMenuEnabled(False)
|
||||
plot.hideButtons()
|
||||
view = self._view
|
||||
view.invertY(False)
|
||||
view.enableAutoRange(x=False, y=False)
|
||||
view.setMouseEnabled(x=False, y=False)
|
||||
view.setMenuEnabled(False)
|
||||
|
||||
def _clear_annotations(self) -> None:
|
||||
for item in (*self._labels, *self._masks):
|
||||
self._plot.removeItem(item)
|
||||
self._labels.clear()
|
||||
self._masks.clear()
|
||||
self._contours.clear()
|
||||
|
||||
def clear(self, settings: GprSettings) -> None:
|
||||
"""Clear displayed data while retaining reusable graphics items."""
|
||||
self._configure_axes()
|
||||
self._clear_annotations()
|
||||
self._image.hide()
|
||||
for marker in self._markers:
|
||||
marker.setData(x=[], y=[])
|
||||
marker.hide()
|
||||
self._plot.setTitle(_title(settings))
|
||||
|
||||
def _geometry(self, settings: GprSettings, geometry: GprModel) -> tuple[np.ndarray, np.ndarray]:
|
||||
inputs = tuple(parse_position_csv(settings.input_positions))
|
||||
outputs = tuple(parse_position_csv(settings.output_positions))
|
||||
signature = (
|
||||
tuple((entry.output_pos, entry.x_m, entry.y_m, entry.z_m) for entry in geometry.tx_geometry),
|
||||
tuple((entry.input_pos, entry.x_m, entry.y_m, entry.z_m) for entry in geometry.rx_geometry),
|
||||
inputs, outputs,
|
||||
)
|
||||
if signature != self._geometry_signature:
|
||||
self._selected_geometry = (
|
||||
np.asarray([
|
||||
entry.x_m for entry in sorted(geometry.tx_geometry, key=lambda entry: entry.output_pos)
|
||||
if not outputs or entry.output_pos in outputs
|
||||
], dtype=np.float32),
|
||||
np.asarray([
|
||||
entry.x_m for entry in sorted(geometry.rx_geometry, key=lambda entry: entry.input_pos)
|
||||
if not inputs or entry.input_pos in inputs
|
||||
], dtype=np.float32),
|
||||
)
|
||||
self._geometry_signature = signature
|
||||
return self._selected_geometry
|
||||
|
||||
def _draw_geometry(self, settings: GprSettings, geometry: GprModel) -> None:
|
||||
tx, rx = self._geometry(settings, geometry)
|
||||
for marker, xs, symbol, brush, pen in (
|
||||
(self._tx, tx, "t", "#ff595e", "#ffca3a"),
|
||||
(self._rx, rx, "t1", "#4cc9f0", "#e0fbfc"),
|
||||
):
|
||||
marker.setData(
|
||||
x=xs, y=np.zeros_like(xs), symbol=symbol, size=13,
|
||||
brush=pg.mkBrush(brush), pen=pg.mkPen(pen, width=1.0),
|
||||
)
|
||||
marker.setVisible(xs.size > 0)
|
||||
|
||||
def _label(self, text: str, x: float, y: float, color: str) -> pg.TextItem:
|
||||
label = pg.TextItem(text=text, color=color, anchor=(0.0, 1.0))
|
||||
label.setZValue(40)
|
||||
label.setPos(x, y)
|
||||
self._plot.addItem(label)
|
||||
self._labels.append(label)
|
||||
return label
|
||||
|
||||
def _draw_regions(self, collection: ResultCollection, rect: QRectF) -> None:
|
||||
centers_payload = collection_payload_by_name(collection, "gpr_region_centers", kind=4)
|
||||
if centers_payload is not None and np.asarray(centers_payload.table).size > 0:
|
||||
centers = np.asarray(centers_payload.table, dtype=np.float32)
|
||||
self._region_centers.setData(
|
||||
x=centers[:, 0], y=centers[:, 1], symbol="o", size=10,
|
||||
brush=pg.mkBrush("#80ed99"), pen=pg.mkPen("#081c15", width=1.1),
|
||||
)
|
||||
self._region_centers.show()
|
||||
for row in centers:
|
||||
self._label(f"{float(row[2]):.0f}", float(row[0]), float(row[1]), "#d8f3dc")
|
||||
for payload in collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
|
||||
mask = np.asarray(payload.image, dtype=np.float32)
|
||||
if mask.ndim != 2 or mask.size == 0:
|
||||
continue
|
||||
mask_image = pg.ImageItem(axisOrder="row-major")
|
||||
mask_image.setZValue(5)
|
||||
mask_image.setImage(mask, autoLevels=False)
|
||||
mask_image.setRect(rect)
|
||||
mask_image.setOpacity(0.0)
|
||||
self._plot.addItem(mask_image)
|
||||
contour = pg.IsocurveItem(data=mask)
|
||||
contour.setLevel(0.5)
|
||||
contour.setPen("#4cc9f0", width=1.3)
|
||||
contour.setParentItem(mask_image)
|
||||
self._masks.append(mask_image)
|
||||
self._contours.append(contour)
|
||||
|
||||
@staticmethod
|
||||
def _place_object_label(
|
||||
label: pg.TextItem, x: float, z: float, x_span: float, z_span: float, occupied: list[QRectF],
|
||||
) -> None:
|
||||
for label_x, label_z, anchor in object_label_candidates(x, z, x_span=x_span, z_span=z_span):
|
||||
label.setAnchor(anchor)
|
||||
label.setPos(label_x, label_z)
|
||||
rect = label.sceneBoundingRect().adjusted(-4.0, -4.0, 4.0, 4.0)
|
||||
if not any(rect.intersects(other) for other in occupied):
|
||||
occupied.append(rect)
|
||||
return
|
||||
occupied.append(label.sceneBoundingRect().adjusted(-4.0, -4.0, 4.0, 4.0))
|
||||
|
||||
def _draw_points(
|
||||
self, rows: np.ndarray, settings: GprSettings, objects_only: bool, x_span: float, z_span: float,
|
||||
) -> None:
|
||||
if rows.size == 0:
|
||||
return
|
||||
self._points.setData(
|
||||
x=rows[:, 0], y=rows[:, 1], symbol="o" if objects_only else "d",
|
||||
size=18 if objects_only else 11,
|
||||
brush=pg.mkBrush("#ff4d4f" if objects_only else "#ffffff"),
|
||||
pen=pg.mkPen("#ff4d4f" if objects_only else "#111111", width=1.6 if objects_only else 1.1),
|
||||
)
|
||||
self._points.show()
|
||||
occupied: list[QRectF] = []
|
||||
for x_value, z_value, score in rows:
|
||||
x, z = float(x_value), float(z_value)
|
||||
text = f"{float(score):.0f}" if isinstance(settings, GuiLegacyGprStateModel) else f"{float(score):.2f}"
|
||||
if objects_only:
|
||||
text += f" | x={x:.1f} | z={z:.1f}"
|
||||
label = self._label(text, x, z, "#ffd6d9" if objects_only else "#ffffff")
|
||||
if objects_only:
|
||||
self._place_object_label(label, x, z, x_span, z_span, occupied)
|
||||
|
||||
def draw(self, collection: ResultCollection, settings: GprSettings, geometry: GprModel) -> bool:
|
||||
"""Render a heatmap or object-only view, returning whether data is present."""
|
||||
objects_only = settings.render_mode == "objects_only"
|
||||
accumulator = collection_payload_by_name(collection, "gpr_accumulator", kind=3)
|
||||
x_min, x_max = normalized_display_range(settings.visible_x_min_m, settings.visible_x_max_m)
|
||||
z_min, z_max = normalized_display_range(settings.visible_z_min_m, settings.visible_z_max_m)
|
||||
rows = gpr_object_rows(collection)
|
||||
# Coherent BP rows are finalized in C++; only legacy GPR uses GUI filtering.
|
||||
if isinstance(settings, GuiLegacyGprStateModel):
|
||||
rows = filter_object_rows(
|
||||
rows, min_score=float(settings.min_visible_pair_count),
|
||||
x_bounds=(x_min, x_max), z_bounds=(z_min, z_max),
|
||||
)
|
||||
if objects_only:
|
||||
if accumulator is None and rows.size == 0:
|
||||
self.clear(settings)
|
||||
return False
|
||||
elif accumulator is None or not self._valid_accumulator(accumulator):
|
||||
self.clear(settings)
|
||||
return False
|
||||
|
||||
self.widget.setUpdatesEnabled(False)
|
||||
try:
|
||||
self._clear_annotations()
|
||||
self._image.hide()
|
||||
for marker in (self._points, self._region_centers):
|
||||
marker.setData(x=[], y=[])
|
||||
marker.hide()
|
||||
self._view.invertY(False)
|
||||
self._draw_geometry(settings, geometry)
|
||||
rect: QRectF | None = None
|
||||
if not objects_only and accumulator is not None:
|
||||
image = np.asarray(accumulator.image, dtype=np.float32)
|
||||
xs = np.asarray(accumulator.image_x_axis, dtype=np.float32)
|
||||
ys = np.asarray(accumulator.image_y_axis, dtype=np.float32)
|
||||
rect = QRectF(
|
||||
float(xs[0]), float(ys[0]),
|
||||
max(float(xs[-1]) - float(xs[0]), 1e-6),
|
||||
max(float(ys[-1]) - float(ys[0]), 1e-6),
|
||||
)
|
||||
self._image.setImage(image, autoLevels=False)
|
||||
self._image.setRect(rect)
|
||||
self._image.setLookupTable(self._lookup_table)
|
||||
self._image.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
|
||||
self._image.show()
|
||||
self._view.setXRange(x_min, x_max, padding=0.0)
|
||||
self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0)
|
||||
points = collection_payload_by_name(collection, "gpr_points", kind=4)
|
||||
if points is None or np.asarray(points.table).size == 0:
|
||||
rows = np.zeros((0, 3), dtype=np.float32)
|
||||
self._draw_points(rows, settings, objects_only, x_max - x_min, z_max - z_min)
|
||||
if rect is not None:
|
||||
self._draw_regions(collection, rect)
|
||||
if objects_only:
|
||||
self._view.setXRange(x_min, x_max, padding=0.0)
|
||||
self._view.setYRange(display_y_min(z_min, z_max), z_max, padding=0.0)
|
||||
self._plot.setTitle(_title(settings, objects_only=objects_only))
|
||||
finally:
|
||||
self.widget.setUpdatesEnabled(True)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _valid_accumulator(payload: ResultPayload) -> bool:
|
||||
image = np.asarray(payload.image)
|
||||
return image.ndim == 2 and image.size > 0 and np.size(payload.image_x_axis) > 0 and np.size(payload.image_y_axis) > 0
|
||||
+93
-62
@@ -1,4 +1,4 @@
|
||||
"""3D SAR views of the volume published by the sar3d processor.
|
||||
"""Independent 3D SAR views of the volume published by the sar3d processor.
|
||||
|
||||
Scene axes: X across, Y height above the ground (Y = 0 is the ground), Z along
|
||||
the ground away from the radar. Payloads share normalization by the maximum
|
||||
@@ -21,13 +21,18 @@ import itertools
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtCore import QRectF, Qt
|
||||
from PyQt6.QtWidgets import QGridLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import build_lut
|
||||
from python_app.models.dataset_model import ResultCollection
|
||||
from python_app.gui.controllers.renderers.common import build_lut, create_plot
|
||||
from python_app.gui.result_payloads import collection_payload_by_name
|
||||
from python_app.models.dataset_model import ResultCollection, ResultPayload
|
||||
|
||||
# Colours start at this fraction of the normalization reference; weaker voxels stay transparent,
|
||||
# like those under the threshold of the notebook's point cloud.
|
||||
DISPLAY_THRESHOLD = 0.75
|
||||
# like those under the notebook's point-cloud threshold (SAR_CLOUD_THRESHOLD).
|
||||
DISPLAY_THRESHOLD = 0.70
|
||||
|
||||
# Status codes published by the sar3d processor in its "sar3d_status" table.
|
||||
SAR3D_STATES = {0: "ready", 1: "no signal", 2: "error", 3: "warning"}
|
||||
|
||||
# payload -> (title, x label, y label, scene axes (0 = X, 1 = Y, 2 = Z) along the plot axes)
|
||||
SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = {
|
||||
@@ -44,19 +49,17 @@ _AXIS_EDGES = {"X": (0, 1), "Y": (1, 3), "Z": (1, 5)}
|
||||
|
||||
|
||||
def sar3d_status(collection: ResultCollection) -> tuple[int, str] | None:
|
||||
"""Decode processor status without letting malformed telemetry break a Qt slot."""
|
||||
payload = next((item for item in collection.collection_payloads
|
||||
if item.processing_name == "sar3d_status" and item.kind == 4), None)
|
||||
"""Status code (a SAR3D_STATES key) and message, one UTF-8 byte per table column."""
|
||||
payload = collection_payload_by_name(collection, "sar3d_status", kind=4)
|
||||
if payload is None:
|
||||
return None
|
||||
table = payload.table
|
||||
if table.ndim != 2 or table.shape[0] != 1 or table.shape[1] == 0:
|
||||
return 2, "Invalid SAR3D status payload"
|
||||
row = table[0]
|
||||
if (not np.isfinite(row).all() or not np.equal(row, np.floor(row)).all()
|
||||
or row[0] not in (0, 1, 2, 3) or np.any((row[1:] < 0) | (row[1:] > 255))):
|
||||
return 2, "Invalid SAR3D status payload"
|
||||
return int(row[0]), bytes(row[1:].astype(np.uint8)).decode("utf-8", errors="replace")
|
||||
if payload.table.ndim != 2 or not payload.table.size or not np.isfinite(payload.table).all():
|
||||
return 2, "Invalid SAR3D status table"
|
||||
row = payload.table[0]
|
||||
code = int(row[0])
|
||||
if code not in SAR3D_STATES or code != row[0]:
|
||||
return 2, f"Unknown SAR3D status code: {row[0]}"
|
||||
return code, bytes(row[1:].astype(np.uint8)).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF:
|
||||
@@ -66,28 +69,50 @@ def _image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF:
|
||||
return QRectF(x_axis[0] - dx / 2, y_axis[0] - dy / 2, x_axis[-1] - x_axis[0] + dx, y_axis[-1] - y_axis[0] + dy)
|
||||
|
||||
|
||||
class AppWindowSar3dPlotMixin:
|
||||
class Sar3dRenderer:
|
||||
"""Draws the 3D SAR views."""
|
||||
|
||||
def _init_sar3d_views(self) -> None:
|
||||
"""Create the persistent items of the four 3D SAR plots."""
|
||||
def __init__(self) -> None:
|
||||
self.widget = QWidget()
|
||||
layout = QVBoxLayout(self.widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.status_label.setWordWrap(True)
|
||||
layout.addWidget(self.status_label)
|
||||
self.grid = QWidget()
|
||||
grid_layout = QGridLayout(self.grid)
|
||||
grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||
grid_layout.setSpacing(6)
|
||||
layout.addWidget(self.grid, stretch=1)
|
||||
self.plots: dict[str, pg.PlotWidget] = {}
|
||||
for index, (name, (title, *_rest)) in enumerate(SAR3D_VIEWS.items()):
|
||||
plot = create_plot(title)
|
||||
grid_layout.addWidget(plot, index // 2, index % 2)
|
||||
self.plots[name] = plot
|
||||
lut = build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"]) # the GPR palette
|
||||
lut = np.column_stack([lut, np.full(len(lut), 255, dtype=np.uint8)])
|
||||
lut[0, 3] = 0 # below DISPLAY_THRESHOLD
|
||||
self._sar3d_images = {}
|
||||
self._sar3d_peaks = {}
|
||||
self.images: dict[str, pg.ImageItem] = {}
|
||||
self.peaks: dict[str, pg.ScatterPlotItem] = {}
|
||||
for name, (_title, x_label, y_label, _axes) in SAR3D_VIEWS.items():
|
||||
plot = self._sar3d_plots[name]
|
||||
self._disable_gpr_plot_interaction(plot, plot.getViewBox())
|
||||
plot.getViewBox().enableAutoRange(x=False, y=False)
|
||||
plot = self.plots[name]
|
||||
item = plot.getPlotItem()
|
||||
assert item is not None
|
||||
view = item.getViewBox()
|
||||
view.setMouseEnabled(x=False, y=False)
|
||||
view.setMenuEnabled(False)
|
||||
view.enableAutoRange(x=False, y=False)
|
||||
item.hideButtons()
|
||||
image = pg.ImageItem(axisOrder="row-major", lut=lut)
|
||||
plot.addItem(image)
|
||||
# Hollow, so the response at the maximum stays visible.
|
||||
peak = pg.ScatterPlotItem(symbol="star", size=18, pen=pg.mkPen("w", width=1.5), brush=None)
|
||||
peak.setZValue(3)
|
||||
plot.addItem(peak)
|
||||
self._sar3d_images[name] = image
|
||||
self._sar3d_peaks[name] = peak
|
||||
self.images[name] = image
|
||||
self.peaks[name] = peak
|
||||
if x_label:
|
||||
plot.showGrid(x=True, y=True, alpha=0.2)
|
||||
plot.setLabel("bottom", x_label, units="m")
|
||||
@@ -97,66 +122,72 @@ class AppWindowSar3dPlotMixin:
|
||||
ground.setZValue(2)
|
||||
plot.addItem(ground)
|
||||
|
||||
oblique = self._sar3d_plots["sar3d_oblique"]
|
||||
oblique = self.plots["sar3d_oblique"]
|
||||
oblique.hideAxis("bottom")
|
||||
oblique.hideAxis("left")
|
||||
oblique.setAspectLocked(True)
|
||||
# The notebook scene's ground, under the image so it shows through weak voxels.
|
||||
self._sar3d_ground = pg.PlotCurveItem(
|
||||
self.ground = pg.PlotCurveItem(
|
||||
pen=pg.mkPen(115, 105, 88), brush=pg.mkBrush(115, 105, 88, 110), fillLevel="enclosed"
|
||||
)
|
||||
self._sar3d_ground.setZValue(-1)
|
||||
oblique.addItem(self._sar3d_ground)
|
||||
self._sar3d_box = pg.PlotCurveItem(pen=pg.mkPen(190, 190, 190, 170), connect="pairs")
|
||||
self._sar3d_box.setZValue(2)
|
||||
oblique.addItem(self._sar3d_box)
|
||||
self._sar3d_axis_labels = {}
|
||||
self.ground.setZValue(-1)
|
||||
oblique.addItem(self.ground)
|
||||
self.box = pg.PlotCurveItem(pen=pg.mkPen(190, 190, 190, 170), connect="pairs")
|
||||
self.box.setZValue(2)
|
||||
oblique.addItem(self.box)
|
||||
self.axis_labels: dict[str, pg.TextItem] = {}
|
||||
for axis_name in _AXIS_EDGES:
|
||||
label = pg.TextItem(axis_name, color=(190, 190, 190), anchor=(0.5, 0.5))
|
||||
oblique.addItem(label)
|
||||
self._sar3d_axis_labels[axis_name] = label
|
||||
self.axis_labels[axis_name] = label
|
||||
self.clear()
|
||||
|
||||
def _clear_sar3d_plot(self, message: str = "SAR3D: waiting for measurements") -> None:
|
||||
def clear(self, message: str = "SAR3D: waiting for measurements") -> None:
|
||||
"""Clear all four 3D SAR views."""
|
||||
for name, (title, *_rest) in SAR3D_VIEWS.items():
|
||||
self._sar3d_images[name].hide()
|
||||
self._sar3d_peaks[name].setData([], [])
|
||||
self._sar3d_plots[name].setTitle(title)
|
||||
self._sar3d_box.setData([], [])
|
||||
self._sar3d_ground.setData([], [])
|
||||
for label in self._sar3d_axis_labels.values():
|
||||
self.images[name].hide()
|
||||
self.peaks[name].setData([], [])
|
||||
self.plots[name].setTitle(title)
|
||||
self.box.setData([], [])
|
||||
self.ground.setData([], [])
|
||||
for label in self.axis_labels.values():
|
||||
label.hide()
|
||||
self._sar3d_status_label.setText(message)
|
||||
self.status_label.setText(message)
|
||||
|
||||
def _draw_sar3d_projections(self, collection: ResultCollection) -> bool:
|
||||
def draw(self, collection: ResultCollection) -> bool:
|
||||
"""Draw four maximum projections of the volume; return success."""
|
||||
status = sar3d_status(collection)
|
||||
if status is not None and status[0] in (1, 2, 3):
|
||||
state = {1: "no signal", 2: "error", 3: "warning"}[status[0]]
|
||||
self._clear_sar3d_plot(f"SAR3D: {state} — {status[1]}")
|
||||
if status is not None and status[0] != 0:
|
||||
self.clear(f"SAR3D: {SAR3D_STATES[status[0]]} — {status[1]}")
|
||||
return False
|
||||
payloads = {name: self._collection_payload_by_name(collection, name, kind=3) for name in SAR3D_VIEWS}
|
||||
basis = self._collection_payload_by_name(collection, "sar3d_oblique_basis", kind=4)
|
||||
if basis is None or any(payload is None or payload.image.size == 0 for payload in payloads.values()):
|
||||
self._clear_sar3d_plot("SAR3D: waiting for projection data")
|
||||
payloads: dict[str, ResultPayload] = {}
|
||||
for name in SAR3D_VIEWS:
|
||||
payload = collection_payload_by_name(collection, name, kind=3)
|
||||
if payload is None or not payload.image.size:
|
||||
self.clear("SAR3D: waiting for projection data")
|
||||
return False
|
||||
payloads[name] = payload
|
||||
basis = collection_payload_by_name(collection, "sar3d_oblique_basis", kind=4)
|
||||
if basis is None:
|
||||
self.clear("SAR3D: waiting for projection data")
|
||||
return False
|
||||
peak = self._collection_payload_by_name(collection, "sar3d_peak", kind=4)
|
||||
peak = collection_payload_by_name(collection, "sar3d_peak", kind=4)
|
||||
level, *peak_xyz = peak.table[0] if peak is not None else (None,)
|
||||
|
||||
for name, payload in payloads.items():
|
||||
title, _x_label, _y_label, axes = SAR3D_VIEWS[name]
|
||||
rect = _image_rect(payload.image_x_axis, payload.image_y_axis)
|
||||
image = self._sar3d_images[name]
|
||||
image = self.images[name]
|
||||
image.setImage(payload.image, autoLevels=False, levels=(DISPLAY_THRESHOLD, 1.0))
|
||||
image.setRect(rect)
|
||||
image.show()
|
||||
plot = self._sar3d_plots[name]
|
||||
plot = self.plots[name]
|
||||
plot.setRange(rect, padding=0.0)
|
||||
if peak_xyz:
|
||||
position = basis.table @ np.asarray(peak_xyz) if axes is None else [peak_xyz[axis] for axis in axes]
|
||||
self._sar3d_peaks[name].setData([position[0]], [position[1]])
|
||||
self.peaks[name].setData([position[0]], [position[1]])
|
||||
else:
|
||||
self._sar3d_peaks[name].setData([], [])
|
||||
self.peaks[name].setData([], [])
|
||||
plot.setTitle(title)
|
||||
|
||||
# Scene box and ground on the oblique view, from the extents of the other views.
|
||||
@@ -169,19 +200,19 @@ class AppWindowSar3dPlotMixin:
|
||||
corners = np.array([[bounds[axis][(index >> axis) & 1] for axis in range(3)] for index in range(8)])
|
||||
projected = corners @ basis.table.T
|
||||
box = projected[np.ravel(_BOX_EDGES)]
|
||||
self._sar3d_box.setData(box[:, 0], box[:, 1])
|
||||
self.box.setData(box[:, 0], box[:, 1])
|
||||
centre = projected.mean(axis=0)
|
||||
for axis_name, (start, end) in _AXIS_EDGES.items():
|
||||
middle = (projected[start] + projected[end]) / 2
|
||||
self._sar3d_axis_labels[axis_name].setPos(*(middle + 0.08 * (middle - centre))) # just outside
|
||||
self._sar3d_axis_labels[axis_name].show()
|
||||
self.axis_labels[axis_name].setPos(*(middle + 0.08 * (middle - centre))) # just outside
|
||||
self.axis_labels[axis_name].show()
|
||||
(x0, x1), _, (z0, z1) = bounds
|
||||
ground = (np.array([[x0, 0.0, z0], [x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]]) @ basis.table.T).T
|
||||
self._sar3d_ground.setData(ground[0], ground[1])
|
||||
self.ground.setData(ground[0], ground[1])
|
||||
if level is not None:
|
||||
x, y, z = peak_xyz
|
||||
self._sar3d_plots["sar3d_oblique"].setTitle(
|
||||
self.plots["sar3d_oblique"].setTitle(
|
||||
f"3D projection | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
|
||||
)
|
||||
self._sar3d_status_label.setText(f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready")
|
||||
self.status_label.setText(f"SAR3D: ready — {status[1]}" if status else "SAR3D: ready")
|
||||
return True
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Magnitude and phase rendering with owned plots, curves and legends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from python_app.gui.controllers.renderers.common import create_plot
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, TraceData
|
||||
from python_app.models.gui_profile_schema import GuiPassThroughStateModel
|
||||
from python_app.models.run_config_validation import parse_combos_from_text
|
||||
|
||||
|
||||
class WarningLogger(Protocol):
|
||||
def __call__(self, text: str, *, details: str | None = None,
|
||||
once_key: str | None = None) -> None: ...
|
||||
|
||||
|
||||
CurveKey = tuple[ComboKey, int, str]
|
||||
Series = tuple[CurveKey, np.ndarray, np.ndarray, str]
|
||||
_PALETTE = ("#4cc9f0", "#f72585", "#b8f2e6", "#ffd166",
|
||||
"#90be6d", "#ff595e", "#6a4c93", "#1982c4")
|
||||
|
||||
|
||||
class _TracePanel:
|
||||
"""One persistent curve surface and its combo legend."""
|
||||
|
||||
def __init__(self, label: str, units: str) -> None:
|
||||
self.plot = create_plot("Pass-Through S21")
|
||||
item = self.plot.getPlotItem()
|
||||
assert item is not None
|
||||
self.item = item
|
||||
self.view = item.getViewBox()
|
||||
self.item.showGrid(x=True, y=True, alpha=0.2)
|
||||
self.item.setLabel("left", label, units=units)
|
||||
self.item.setLabel("bottom", "Frequency", units="Hz")
|
||||
self.item.setDownsampling(mode="peak")
|
||||
self.item.setClipToView(True)
|
||||
self.curves: dict[CurveKey, pg.PlotCurveItem] = {}
|
||||
self.legend: pg.LegendItem | None = None
|
||||
self._legend_sources: dict[ComboKey, pg.PlotCurveItem] = {}
|
||||
|
||||
def clear(self) -> None:
|
||||
self.item.clear()
|
||||
self.curves.clear()
|
||||
self.sync_legend({})
|
||||
|
||||
def update_curve(self, key: CurveKey, x: np.ndarray, y: np.ndarray,
|
||||
color: str, *, width: float, dashed: bool = False) -> pg.PlotCurveItem:
|
||||
pen = pg.mkPen(color, width=width,
|
||||
style=Qt.PenStyle.DashLine if dashed else Qt.PenStyle.SolidLine)
|
||||
curve = self.curves.get(key)
|
||||
if curve is None:
|
||||
curve = pg.PlotCurveItem(pen=pen, antialias=False)
|
||||
self.curves[key] = curve
|
||||
self.item.addItem(curve)
|
||||
else:
|
||||
curve.setPen(pen)
|
||||
curve.setData(x, y)
|
||||
return curve
|
||||
|
||||
def retain_curves(self, active_keys: set[CurveKey]) -> None:
|
||||
for key in self.curves.keys() - active_keys:
|
||||
self.item.removeItem(self.curves.pop(key))
|
||||
|
||||
def sync_legend(self, sources: dict[ComboKey, pg.PlotCurveItem]) -> None:
|
||||
if self._legend_sources == sources:
|
||||
return
|
||||
if self.legend is None and sources:
|
||||
self.legend = self.item.addLegend(offset=(8, 8))
|
||||
if self.legend is not None:
|
||||
self.legend.clear()
|
||||
for combo in sorted(sources, key=lambda key: (key.input, key.output)):
|
||||
self.legend.addItem(sources[combo], f"in{combo.input}/out{combo.output}")
|
||||
self.legend.setVisible(bool(sources))
|
||||
self._legend_sources = sources
|
||||
|
||||
|
||||
class TraceRenderer:
|
||||
"""Render result traces or a captured trace using explicit display settings."""
|
||||
|
||||
def __init__(self, log_warning: WarningLogger, max_points: int = 800) -> None:
|
||||
self._log_warning = log_warning
|
||||
self.max_points = max_points
|
||||
self.widget = QWidget()
|
||||
layout = QVBoxLayout(self.widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
self.magnitude = _TracePanel("Magnitude", "dB")
|
||||
self.phase = _TracePanel("Phase", "deg")
|
||||
self.magnitude.item.showAxis("bottom", show=False)
|
||||
for panel in (self.magnitude, self.phase):
|
||||
layout.addWidget(panel.plot, stretch=1)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.magnitude.clear()
|
||||
self.phase.clear()
|
||||
|
||||
def draw(self, collection: ResultCollection, settings: GuiPassThroughStateModel) -> bool:
|
||||
combo_filter = self._combo_filter(settings.combo_filter)
|
||||
colors: dict[ComboKey, str] = {}
|
||||
series: list[Series] = []
|
||||
for block in collection.blocks:
|
||||
if combo_filter is not None and block.combo not in combo_filter:
|
||||
continue
|
||||
color = colors.setdefault(block.combo, _PALETTE[len(colors) % len(_PALETTE)])
|
||||
for index, payload in enumerate(block.payloads):
|
||||
if payload.kind != 1 or not payload.trace.size or payload.frequency_hz.size != payload.trace.size:
|
||||
continue
|
||||
series.append(((block.combo, index, payload.processing_name),
|
||||
payload.frequency_hz, payload.trace, color))
|
||||
return self._render(series, settings, "Pass-Through S21")
|
||||
|
||||
def draw_trace(self, trace: TraceData, title: str, settings: GuiPassThroughStateModel) -> None:
|
||||
self.clear()
|
||||
self._render([((trace.combo, 0, "__single_trace__"), trace.frequency_hz,
|
||||
trace.s21, "#ffd166")], settings, title, single=True)
|
||||
|
||||
def _combo_filter(self, text: str) -> set[ComboKey] | None:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return {ComboKey(combo.input, combo.output) for combo in parse_combos_from_text(text)}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning("Invalid pass-through switch-combo filter.",
|
||||
details=f"{exc}\nExpected format: input:output,input:output",
|
||||
once_key=f"pass_through_combo_filter_invalid_{text}")
|
||||
return None
|
||||
|
||||
def _render(self, series: list[Series], settings: GuiPassThroughStateModel,
|
||||
title: str, *, single: bool = False) -> bool:
|
||||
mag, phase = self.magnitude, self.phase
|
||||
mag.plot.setVisible(settings.show_magnitude)
|
||||
phase.plot.setVisible(settings.show_phase)
|
||||
if not settings.show_magnitude and not settings.show_phase:
|
||||
self.clear()
|
||||
return False
|
||||
mag.item.showAxis("bottom", show=not settings.show_phase)
|
||||
active: set[CurveKey] = set()
|
||||
mag_sources: dict[ComboKey, pg.PlotCurveItem] = {}
|
||||
phase_sources: dict[ComboKey, pg.PlotCurveItem] = {}
|
||||
x_min, x_max = np.inf, -np.inf
|
||||
for key, frequency, samples, color in series:
|
||||
if not samples.size or frequency.size != samples.size:
|
||||
continue
|
||||
active.add(key)
|
||||
x_min, x_max = min(x_min, float(np.min(frequency))), max(x_max, float(np.max(frequency)))
|
||||
step = max(1, int(np.ceil(samples.size / self.max_points))) if self.max_points > 0 else 1
|
||||
if settings.show_magnitude:
|
||||
magnitude = 20.0 * np.log10(np.maximum(np.abs(samples[::step]), 1e-12))
|
||||
curve = mag.update_curve(key, frequency[::step], magnitude, color,
|
||||
width=1.8 if single else 1.4)
|
||||
mag_sources.setdefault(key[0], curve)
|
||||
if settings.show_phase:
|
||||
angles = np.angle(samples)
|
||||
if settings.unwrap_phase:
|
||||
angles = np.unwrap(angles)
|
||||
curve = phase.update_curve(key, frequency[::step], np.degrees(angles[::step]),
|
||||
"#80ed99" if single else color,
|
||||
width=1.4 if single else 1.2, dashed=single)
|
||||
phase_sources.setdefault(key[0], curve)
|
||||
|
||||
for panel, visible, sources in ((mag, settings.show_magnitude, mag_sources),
|
||||
(phase, settings.show_phase, phase_sources)):
|
||||
panel.retain_curves(active if visible else set())
|
||||
panel.sync_legend(sources if not single else {})
|
||||
if visible:
|
||||
panel.item.setTitle(title)
|
||||
panel.view.invertY(False)
|
||||
if active and np.isfinite(x_min) and np.isfinite(x_max):
|
||||
panel.view.setXRange(x_min, x_max, padding=0.02)
|
||||
if settings.show_magnitude:
|
||||
mag.view.enableAutoRange(y=not settings.fixed_y_enabled)
|
||||
if settings.fixed_y_enabled:
|
||||
mag.view.setYRange(min(settings.y_min_db, settings.y_max_db),
|
||||
max(settings.y_min_db, settings.y_max_db), padding=0.0)
|
||||
if settings.show_phase:
|
||||
phase.view.enableAutoRange(y=settings.unwrap_phase)
|
||||
if not settings.unwrap_phase:
|
||||
phase.view.setYRange(-180.0, 180.0, padding=0.02)
|
||||
return bool(active)
|
||||
@@ -1,17 +1 @@
|
||||
"""Composable UI section builders used by AppWindow UI mixin."""
|
||||
|
||||
from python_app.gui.controllers.sections.data_actions_section import build_data_actions_group
|
||||
from python_app.gui.controllers.sections.primary_actions_section import build_primary_actions_group
|
||||
from python_app.gui.controllers.sections.preprocess_summary_section import build_preprocess_summary_group
|
||||
from python_app.gui.controllers.sections.processing_section import build_processing_group
|
||||
from python_app.gui.controllers.sections.radar_section import build_radar_group
|
||||
from python_app.gui.controllers.sections.switch_section import build_switch_group
|
||||
|
||||
__all__ = [
|
||||
"build_data_actions_group",
|
||||
"build_primary_actions_group",
|
||||
"build_preprocess_summary_group",
|
||||
"build_processing_group",
|
||||
"build_radar_group",
|
||||
"build_switch_group",
|
||||
]
|
||||
"""Typed configuration sections."""
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Builder for snapshot and storage actions section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
@@ -15,39 +13,39 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
|
||||
|
||||
def build_data_actions_group(owner) -> QGroupBox:
|
||||
from python_app.gui.controllers.sections.views import DataView, SectionView
|
||||
from python_app.models.gui_profile_schema import GuiStateModel
|
||||
|
||||
|
||||
def build_data_actions_group(gui: GuiStateModel) -> DataView:
|
||||
"""Create snapshot save controls section."""
|
||||
group = QGroupBox("Data Actions")
|
||||
view = SectionView(QGroupBox("Data Actions"))
|
||||
group = view.group
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
data_defaults = owner._gui_defaults.data_actions
|
||||
data_defaults = gui.data_actions
|
||||
|
||||
save_button = QPushButton("Save Last 1000")
|
||||
save_button.setToolTip("Save up to 1000 most recent measurements with all available data stages.")
|
||||
save_button.clicked.connect(owner._save_snapshot)
|
||||
save_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
save_vna_json_button = QPushButton("Save S21 JSON")
|
||||
save_vna_json_button.setToolTip(
|
||||
"Export one S21 VNA-history JSON per combo from the preprocessed stage (S11 is not included)."
|
||||
"Export one S21 VNA-history JSON per combo from the preprocessed stage."
|
||||
)
|
||||
save_vna_json_button.clicked.connect(owner._save_vna_history_json)
|
||||
save_vna_json_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
remove_last_button = QPushButton("Remove Last Measurement")
|
||||
remove_last_button.clicked.connect(owner._remove_last_runtime_history)
|
||||
remove_last_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
capture_tmp_reference_button = QPushButton("Capture Tmp Reference")
|
||||
capture_tmp_reference_button.clicked.connect(owner._capture_tmp_reference)
|
||||
capture_tmp_reference_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
record_button = QPushButton("Start + Record to Disk")
|
||||
record_button.setToolTip(
|
||||
"Start the run (if stopped) and save the next N measurements to the path/name below."
|
||||
)
|
||||
record_button.clicked.connect(owner._start_run_with_recording)
|
||||
record_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
owner._record_count = QSpinBox()
|
||||
owner._record_count.setMinimum(1)
|
||||
owner._record_count.setMaximum(1_000_000)
|
||||
owner._record_count.setValue(int(data_defaults.record_count))
|
||||
record_count = view.add("_record_count", QSpinBox())
|
||||
record_count.setMinimum(1)
|
||||
record_count.setMaximum(1_000_000)
|
||||
record_count.setValue(int(data_defaults.record_count))
|
||||
|
||||
button_grid = QGridLayout()
|
||||
button_grid.setHorizontalSpacing(8)
|
||||
@@ -64,24 +62,26 @@ def build_data_actions_group(owner) -> QGroupBox:
|
||||
record_count_row = QHBoxLayout()
|
||||
record_count_row.setSpacing(8)
|
||||
record_count_row.addWidget(QLabel("Number of Sweeps to Record"))
|
||||
record_count_row.addWidget(owner._record_count)
|
||||
record_count_row.addWidget(record_count)
|
||||
record_count_row.addStretch(1)
|
||||
layout.addLayout(record_count_row)
|
||||
|
||||
path_row = QHBoxLayout()
|
||||
path_row.setSpacing(8)
|
||||
owner._save_path_input = QLineEdit(str(data_defaults.save_path))
|
||||
save_path_input = view.add("_save_path_input", QLineEdit(str(data_defaults.save_path)))
|
||||
browse_button = QPushButton("Browse")
|
||||
browse_button.clicked.connect(owner._browse_save_path)
|
||||
path_row.addWidget(QLabel("Path"))
|
||||
path_row.addWidget(owner._save_path_input, stretch=1)
|
||||
path_row.addWidget(save_path_input, stretch=1)
|
||||
path_row.addWidget(browse_button)
|
||||
layout.addLayout(path_row)
|
||||
|
||||
name_row = QHBoxLayout()
|
||||
name_row.setSpacing(8)
|
||||
owner._save_name_input = QLineEdit(str(data_defaults.save_name))
|
||||
save_name_input = view.add("_save_name_input", QLineEdit(str(data_defaults.save_name)))
|
||||
name_row.addWidget(QLabel("Name"))
|
||||
name_row.addWidget(owner._save_name_input, stretch=1)
|
||||
name_row.addWidget(save_name_input, stretch=1)
|
||||
layout.addLayout(name_row)
|
||||
return group
|
||||
return DataView(group=group, fields=view.fields, save=save_button, save_json=save_vna_json_button,
|
||||
remove_last=remove_last_button, capture_tmp=capture_tmp_reference_button,
|
||||
record=record_button, browse=browse_button, save_path=save_path_input,
|
||||
save_name=save_name_input, record_count=record_count)
|
||||
|
||||
@@ -1,60 +1,33 @@
|
||||
"""Shared layout helpers for section builders."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""Shared two-column form layout for settings sections."""
|
||||
|
||||
from typing import TypeAlias
|
||||
|
||||
from PyQt6.QtWidgets import QFormLayout, QHBoxLayout, QWidget
|
||||
|
||||
|
||||
FormLabel: TypeAlias = str | QWidget
|
||||
FormRow: TypeAlias = QWidget | tuple[FormLabel, QWidget]
|
||||
|
||||
|
||||
def build_two_column_form_widget(
|
||||
parent: QWidget,
|
||||
rows: list[FormRow],
|
||||
*,
|
||||
split_index: int | None = None,
|
||||
parent: QWidget, rows: list[FormRow], *, split_index: int | None = None,
|
||||
) -> QWidget:
|
||||
"""Build one widget containing two side-by-side form columns."""
|
||||
panel = QWidget(parent)
|
||||
panel_layout = QHBoxLayout(panel)
|
||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||||
panel_layout.setSpacing(12)
|
||||
|
||||
left_column = QWidget(panel)
|
||||
left_form = _create_form_layout(left_column)
|
||||
right_column = QWidget(panel)
|
||||
right_form = _create_form_layout(right_column)
|
||||
|
||||
panel_layout.addWidget(left_column, stretch=1)
|
||||
panel_layout.addWidget(right_column, stretch=1)
|
||||
|
||||
actual_split_index = (len(rows) + 1) // 2 if split_index is None else split_index
|
||||
actual_split_index = max(0, min(actual_split_index, len(rows)))
|
||||
|
||||
for row in rows[:actual_split_index]:
|
||||
_add_form_row(left_form, row)
|
||||
for row in rows[actual_split_index:]:
|
||||
_add_form_row(right_form, row)
|
||||
layout = QHBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
split = (len(rows) + 1) // 2 if split_index is None else max(0, min(split_index, len(rows)))
|
||||
for column_rows in (rows[:split], rows[split:]):
|
||||
column = QWidget(panel)
|
||||
form = QFormLayout(column)
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
form.setHorizontalSpacing(10)
|
||||
form.setVerticalSpacing(6)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
for row in column_rows:
|
||||
if isinstance(row, tuple):
|
||||
label, widget = row
|
||||
form.addRow(label, widget)
|
||||
else:
|
||||
form.addRow(row)
|
||||
layout.addWidget(column, stretch=1)
|
||||
return panel
|
||||
|
||||
|
||||
def _create_form_layout(parent: QWidget) -> QFormLayout:
|
||||
"""Create a compact growing form layout for one column."""
|
||||
form = QFormLayout(parent)
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
form.setHorizontalSpacing(10)
|
||||
form.setVerticalSpacing(6)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
return form
|
||||
|
||||
|
||||
def _add_form_row(form: QFormLayout, row: FormRow) -> None:
|
||||
"""Append one form row, with or without an explicit label."""
|
||||
if isinstance(row, tuple):
|
||||
label, field = row
|
||||
form.addRow(label, field)
|
||||
return
|
||||
form.addRow(row)
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"""Builder for preprocess set summary section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import QGroupBox, QLabel, QVBoxLayout
|
||||
|
||||
from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget
|
||||
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_display_name
|
||||
from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget
|
||||
from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_NAMES, preprocess_asset_display_name
|
||||
|
||||
from python_app.gui.controllers.sections.views import PreprocessView
|
||||
|
||||
|
||||
def build_preprocess_summary_group(owner) -> QGroupBox:
|
||||
def build_preprocess_summary_group() -> PreprocessView:
|
||||
"""Create selected preprocess-set summary section."""
|
||||
group = QGroupBox("Selected Preprocess Sets")
|
||||
view = PreprocessView(group=QGroupBox("Selected Preprocess Sets"), labels={})
|
||||
group = view.group
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setSpacing(8)
|
||||
|
||||
owner._selected_preprocess_labels = {}
|
||||
rows = []
|
||||
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
|
||||
rows: list[FormRow] = []
|
||||
for key in PREPROCESS_ASSET_NAMES:
|
||||
label = QLabel("<not selected>")
|
||||
owner._selected_preprocess_labels[key] = label
|
||||
view.labels[key] = label
|
||||
rows.append((preprocess_asset_display_name(key), label))
|
||||
|
||||
layout.addWidget(build_two_column_form_widget(group, rows, split_index=(len(rows) + 1) // 2))
|
||||
return group
|
||||
return view
|
||||
|
||||
@@ -1,57 +1,23 @@
|
||||
"""Builder for pinned primary action controls."""
|
||||
|
||||
from __future__ import annotations
|
||||
"""Pinned primary actions; controllers connect their own buttons."""
|
||||
|
||||
from PyQt6.QtWidgets import QGridLayout, QGroupBox, QPushButton, QSizePolicy
|
||||
|
||||
|
||||
def _expanding_button(label: str) -> QPushButton:
|
||||
"""Create horizontally expanding action button."""
|
||||
button = QPushButton(label)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
return button
|
||||
from python_app.gui.controllers.sections.views import PrimaryView
|
||||
|
||||
|
||||
def build_primary_actions_group(owner) -> QGroupBox:
|
||||
"""Create pinned action block combining pipeline and hardware actions."""
|
||||
group = QGroupBox("Actions")
|
||||
layout = QGridLayout(group)
|
||||
def build_primary_actions_group() -> PrimaryView:
|
||||
view = PrimaryView(group=QGroupBox("Actions"), start=QPushButton("Start"),
|
||||
single_capture=QPushButton("Single Capture"), stop=QPushButton("Stop"),
|
||||
apply_radar=QPushButton("Apply Radar"), load=QPushButton("Load Config"),
|
||||
save=QPushButton("Save Config"), preprocess=QPushButton("Preprocessing"),
|
||||
clear=QPushButton("Clear Runtime History"))
|
||||
layout = QGridLayout(view.group)
|
||||
layout.setHorizontalSpacing(8)
|
||||
layout.setVerticalSpacing(8)
|
||||
|
||||
start_button = _expanding_button("Start")
|
||||
start_button.clicked.connect(owner._start_run)
|
||||
layout.addWidget(start_button, 0, 0)
|
||||
|
||||
single_button = _expanding_button("Single Capture")
|
||||
single_button.clicked.connect(owner._start_single_capture)
|
||||
layout.addWidget(single_button, 0, 1)
|
||||
|
||||
stop_button = _expanding_button("Stop")
|
||||
stop_button.clicked.connect(owner._stop_run)
|
||||
layout.addWidget(stop_button, 0, 2)
|
||||
|
||||
apply_radar_button = _expanding_button("Apply Radar")
|
||||
apply_radar_button.clicked.connect(owner._apply_radar_settings)
|
||||
layout.addWidget(apply_radar_button, 1, 0)
|
||||
|
||||
load_config_button = _expanding_button("Load Config")
|
||||
load_config_button.clicked.connect(owner._load_config_from_dialog)
|
||||
layout.addWidget(load_config_button, 1, 1)
|
||||
|
||||
save_config_button = _expanding_button("Save Config")
|
||||
save_config_button.clicked.connect(owner._save_current_config)
|
||||
layout.addWidget(save_config_button, 1, 2)
|
||||
|
||||
preprocess_button = _expanding_button("Preprocessing")
|
||||
preprocess_button.clicked.connect(owner._open_preprocess_panel)
|
||||
layout.addWidget(preprocess_button, 2, 0)
|
||||
|
||||
clear_history_button = _expanding_button("Clear Runtime History")
|
||||
clear_history_button.clicked.connect(owner._clear_all_runtime_history)
|
||||
layout.addWidget(clear_history_button, 2, 1, 1, 2)
|
||||
|
||||
layout.setColumnStretch(0, 1)
|
||||
layout.setColumnStretch(1, 1)
|
||||
layout.setColumnStretch(2, 1)
|
||||
return group
|
||||
for index, button in enumerate((view.start, view.single_capture, view.stop, view.apply_radar,
|
||||
view.load, view.save, view.preprocess, view.clear)):
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
layout.addWidget(button, index // 3, index % 3, 1, 2 if button is view.clear else 1)
|
||||
for column in range(3):
|
||||
layout.setColumnStretch(column, 1)
|
||||
return view
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
"""Builder for radar settings section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
@@ -16,103 +14,109 @@ from PyQt6.QtWidgets import (
|
||||
)
|
||||
|
||||
from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget
|
||||
from python_app.gui.controllers.sections.views import RadarView, SectionView, write_setting
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
def build_radar_group(owner) -> QGroupBox:
|
||||
def build_radar_group(config: RunConfigModel) -> RadarView:
|
||||
"""Create radar settings controls and labels."""
|
||||
group = QGroupBox("Radar")
|
||||
view = SectionView(QGroupBox("Radar"))
|
||||
group = view.group
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setContentsMargins(10, 10, 10, 10)
|
||||
layout.setSpacing(8)
|
||||
defaults = owner._defaults_config.radar
|
||||
defaults = config.radar
|
||||
|
||||
owner._vna_radar_settings_panel = QWidget(group)
|
||||
vna_layout = QVBoxLayout(owner._vna_radar_settings_panel)
|
||||
vna_radar_settings_panel = QWidget(group)
|
||||
vna_layout = QVBoxLayout(vna_radar_settings_panel)
|
||||
vna_layout.setContentsMargins(0, 0, 0, 0)
|
||||
vna_layout.setSpacing(8)
|
||||
|
||||
owner._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}")
|
||||
owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}")
|
||||
owner._points_input = QLineEdit(str(defaults.sweep.points))
|
||||
owner._ifbw_input = QLineEdit(f"{defaults.sweep.if_bandwidth_hz:g}")
|
||||
owner._power_input = QLineEdit(f"{defaults.sweep.power_dbm:g}")
|
||||
owner._power_input.setToolTip("Stimulus power configured in the active profile.")
|
||||
owner._start_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
owner._stop_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
owner._points_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
owner._ifbw_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
owner._power_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
|
||||
start_hz_input = view.add("_start_hz_input", QLineEdit(f"{defaults.sweep.start_hz:g}"))
|
||||
stop_hz_input = view.add("_stop_hz_input", QLineEdit(f"{defaults.sweep.stop_hz:g}"))
|
||||
points_input = view.add("_points_input", QLineEdit(str(defaults.sweep.points)))
|
||||
ifbw_input = view.add("_ifbw_input", QLineEdit(f"{defaults.sweep.if_bandwidth_hz:g}"))
|
||||
power_input = view.add("_power_input", QLineEdit(f"{defaults.sweep.power_dbm:g}"))
|
||||
power_input.setToolTip("Stimulus power configured in the active profile.")
|
||||
|
||||
owner._radar_start_label = QLabel("Start Hz")
|
||||
owner._radar_stop_label = QLabel("Stop Hz")
|
||||
owner._radar_points_label = QLabel("Points")
|
||||
owner._radar_ifbw_label = QLabel("IF BW Hz")
|
||||
owner._radar_power_label = QLabel("Stimulus Power dBm")
|
||||
radar_start_label = QLabel("Start Hz")
|
||||
radar_stop_label = QLabel("Stop Hz")
|
||||
radar_points_label = QLabel("Points")
|
||||
radar_ifbw_label = QLabel("IF BW Hz")
|
||||
radar_power_label = QLabel("Stimulus Power dBm")
|
||||
|
||||
owner._radar_limits_hint = QLabel("Device limits are not available.")
|
||||
owner._radar_limits_hint.setObjectName("hintLabel")
|
||||
radar_limits_hint = QLabel("Device limits are not available.")
|
||||
radar_limits_hint.setObjectName("hintLabel")
|
||||
|
||||
vna_layout.addWidget(
|
||||
build_two_column_form_widget(
|
||||
group,
|
||||
[
|
||||
(owner._radar_start_label, owner._start_hz_input),
|
||||
(owner._radar_stop_label, owner._stop_hz_input),
|
||||
(owner._radar_points_label, owner._points_input),
|
||||
(owner._radar_ifbw_label, owner._ifbw_input),
|
||||
(owner._radar_power_label, owner._power_input),
|
||||
(radar_start_label, start_hz_input),
|
||||
(radar_stop_label, stop_hz_input),
|
||||
(radar_points_label, points_input),
|
||||
(radar_ifbw_label, ifbw_input),
|
||||
(radar_power_label, power_input),
|
||||
],
|
||||
)
|
||||
)
|
||||
vna_layout.addWidget(owner._radar_limits_hint)
|
||||
vna_layout.addWidget(radar_limits_hint)
|
||||
|
||||
owner._adc_radar_settings_panel = _build_adc_settings_panel(owner, group)
|
||||
(adc_radar_settings_panel, optical_enabled, optical_mode, optical_common, optical_manual,
|
||||
optical_variation, manual_panel, variation_panel) = _build_adc_settings_panel(view, config)
|
||||
|
||||
layout.addWidget(owner._vna_radar_settings_panel)
|
||||
layout.addWidget(owner._adc_radar_settings_panel)
|
||||
owner._set_radar_settings_mode(owner._defaults_config.is_kamil_adc)
|
||||
return group
|
||||
layout.addWidget(vna_radar_settings_panel)
|
||||
layout.addWidget(adc_radar_settings_panel)
|
||||
vna_radar_settings_panel.setVisible(not config.is_kamil_adc)
|
||||
adc_radar_settings_panel.setVisible(config.is_kamil_adc)
|
||||
return RadarView(group=group, fields=view.fields,
|
||||
sweep_edits=(start_hz_input, stop_hz_input, points_input, ifbw_input, power_input),
|
||||
sweep_labels=(radar_start_label, radar_stop_label, radar_points_label, radar_ifbw_label, radar_power_label),
|
||||
limits_hint=radar_limits_hint, vna_panel=vna_radar_settings_panel, adc_panel=adc_radar_settings_panel,
|
||||
optical_enabled=optical_enabled, optical_mode=optical_mode, optical_common=optical_common,
|
||||
optical_manual=optical_manual, optical_variation=optical_variation,
|
||||
manual_panel=manual_panel, variation_panel=variation_panel)
|
||||
|
||||
|
||||
def _build_adc_settings_panel(owner, parent: QWidget) -> QWidget:
|
||||
def _build_adc_settings_panel(view: SectionView, config: RunConfigModel):
|
||||
"""Create controls for the external ADC collector and optical board."""
|
||||
panel = QWidget(parent)
|
||||
panel = QWidget(view.group)
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
adc = owner._defaults_config.radar.kamil_adc
|
||||
optical = owner._defaults_config.radar.laser_control
|
||||
adc = config.radar.kamil_adc
|
||||
optical = config.radar.laser_control
|
||||
manual = optical.manual
|
||||
variation = optical.variation
|
||||
|
||||
owner._adc_project_dir_input = QLineEdit(adc.project_dir)
|
||||
owner._adc_executable_path_input = QLineEdit(adc.executable_path)
|
||||
owner._adc_tty_path_input = QLineEdit(adc.tty_path)
|
||||
owner._adc_args_input = _plain_text("\n".join(adc.args))
|
||||
owner._adc_env_input = _plain_text(json.dumps(adc.env, indent=2, sort_keys=True) if adc.env else "{}")
|
||||
owner._adc_startup_timeout_s_input = QLineEdit(f"{adc.startup_timeout_s:g}")
|
||||
owner._adc_sweep_timeout_s_input = QLineEdit(f"{adc.sweep_timeout_s:g}")
|
||||
owner._adc_stop_timeout_s_input = QLineEdit(f"{adc.stop_timeout_s:g}")
|
||||
adc_project_dir_input = view.add("_adc_project_dir_input", QLineEdit(adc.project_dir))
|
||||
adc_executable_path_input = view.add("_adc_executable_path_input", QLineEdit(adc.executable_path))
|
||||
adc_tty_path_input = view.add("_adc_tty_path_input", QLineEdit(adc.tty_path))
|
||||
adc_args_input = view.add("_adc_args_input", _plain_text("\n".join(adc.args)))
|
||||
adc_env_input = view.add("_adc_env_input", _plain_text(json.dumps(adc.env, indent=2, sort_keys=True) if adc.env else "{}"))
|
||||
adc_startup_timeout_s_input = view.add("_adc_startup_timeout_s_input", QLineEdit(f"{adc.startup_timeout_s:g}"))
|
||||
adc_sweep_timeout_s_input = view.add("_adc_sweep_timeout_s_input", QLineEdit(f"{adc.sweep_timeout_s:g}"))
|
||||
adc_stop_timeout_s_input = view.add("_adc_stop_timeout_s_input", QLineEdit(f"{adc.stop_timeout_s:g}"))
|
||||
|
||||
owner._optical_enabled_checkbox = QCheckBox()
|
||||
owner._optical_enabled_checkbox.setChecked(bool(optical.enabled))
|
||||
owner._optical_port_input = QLineEdit(optical.port)
|
||||
owner._optical_mode_combo = QComboBox()
|
||||
owner._optical_mode_combo.addItems(["manual", "variation"])
|
||||
owner._set_combo_current_text(owner._optical_mode_combo, optical.mode)
|
||||
owner._optical_pi_coeff1_p_input = QLineEdit(str(optical.pi_coeff1_p))
|
||||
owner._optical_pi_coeff1_i_input = QLineEdit(str(optical.pi_coeff1_i))
|
||||
owner._optical_pi_coeff2_p_input = QLineEdit(str(optical.pi_coeff2_p))
|
||||
owner._optical_pi_coeff2_i_input = QLineEdit(str(optical.pi_coeff2_i))
|
||||
optical_enabled_checkbox = view.add("_optical_enabled_checkbox", QCheckBox())
|
||||
optical_enabled_checkbox.setChecked(bool(optical.enabled))
|
||||
optical_port_input = view.add("_optical_port_input", QLineEdit(optical.port))
|
||||
optical_mode_combo = view.add("_optical_mode_combo", QComboBox())
|
||||
optical_mode_combo.addItems(["manual", "variation"])
|
||||
write_setting(optical_mode_combo, optical.mode)
|
||||
optical_pi_coeff1_p_input = view.add("_optical_pi_coeff1_p_input", QLineEdit(str(optical.pi_coeff1_p)))
|
||||
optical_pi_coeff1_i_input = view.add("_optical_pi_coeff1_i_input", QLineEdit(str(optical.pi_coeff1_i)))
|
||||
optical_pi_coeff2_p_input = view.add("_optical_pi_coeff2_p_input", QLineEdit(str(optical.pi_coeff2_p)))
|
||||
optical_pi_coeff2_i_input = view.add("_optical_pi_coeff2_i_input", QLineEdit(str(optical.pi_coeff2_i)))
|
||||
|
||||
owner._optical_manual_temp1_input = QLineEdit(f"{manual.temp1:g}")
|
||||
owner._optical_manual_temp2_input = QLineEdit(f"{manual.temp2:g}")
|
||||
owner._optical_manual_current1_input = QLineEdit(f"{manual.current1:g}")
|
||||
owner._optical_manual_current2_input = QLineEdit(f"{manual.current2:g}")
|
||||
optical_manual_temp1_input = view.add("_optical_manual_temp1_input", QLineEdit(f"{manual.temp1:g}"))
|
||||
optical_manual_temp2_input = view.add("_optical_manual_temp2_input", QLineEdit(f"{manual.temp2:g}"))
|
||||
optical_manual_current1_input = view.add("_optical_manual_current1_input", QLineEdit(f"{manual.current1:g}"))
|
||||
optical_manual_current2_input = view.add("_optical_manual_current2_input", QLineEdit(f"{manual.current2:g}"))
|
||||
|
||||
owner._optical_variation_type_combo = QComboBox()
|
||||
owner._optical_variation_type_combo.addItems(
|
||||
optical_variation_type_combo = view.add("_optical_variation_type_combo", QComboBox())
|
||||
optical_variation_type_combo.addItems(
|
||||
[
|
||||
"CHANGE_CURRENT_LD1",
|
||||
"CHANGE_CURRENT_LD2",
|
||||
@@ -120,118 +124,80 @@ def _build_adc_settings_panel(owner, parent: QWidget) -> QWidget:
|
||||
"CHANGE_TEMPERATURE_LD2",
|
||||
]
|
||||
)
|
||||
owner._set_combo_current_text(owner._optical_variation_type_combo, variation.variation_type)
|
||||
owner._optical_static_temp1_input = QLineEdit(f"{variation.static_temp1:g}")
|
||||
owner._optical_static_temp2_input = QLineEdit(f"{variation.static_temp2:g}")
|
||||
owner._optical_static_current1_input = QLineEdit(f"{variation.static_current1:g}")
|
||||
owner._optical_static_current2_input = QLineEdit(f"{variation.static_current2:g}")
|
||||
owner._optical_min_value_input = QLineEdit(f"{variation.min_value:g}")
|
||||
owner._optical_max_value_input = QLineEdit(f"{variation.max_value:g}")
|
||||
owner._optical_step_input = QLineEdit(f"{variation.step:g}")
|
||||
owner._optical_time_step_input = QLineEdit(str(variation.time_step))
|
||||
owner._optical_delay_time_input = QLineEdit(str(variation.delay_time))
|
||||
write_setting(optical_variation_type_combo, variation.variation_type)
|
||||
optical_static_temp1_input = view.add("_optical_static_temp1_input", QLineEdit(f"{variation.static_temp1:g}"))
|
||||
optical_static_temp2_input = view.add("_optical_static_temp2_input", QLineEdit(f"{variation.static_temp2:g}"))
|
||||
optical_static_current1_input = view.add("_optical_static_current1_input", QLineEdit(f"{variation.static_current1:g}"))
|
||||
optical_static_current2_input = view.add("_optical_static_current2_input", QLineEdit(f"{variation.static_current2:g}"))
|
||||
optical_min_value_input = view.add("_optical_min_value_input", QLineEdit(f"{variation.min_value:g}"))
|
||||
optical_max_value_input = view.add("_optical_max_value_input", QLineEdit(f"{variation.max_value:g}"))
|
||||
optical_step_input = view.add("_optical_step_input", QLineEdit(f"{variation.step:g}"))
|
||||
optical_time_step_input = view.add("_optical_time_step_input", QLineEdit(str(variation.time_step)))
|
||||
optical_delay_time_input = view.add("_optical_delay_time_input", QLineEdit(str(variation.delay_time)))
|
||||
|
||||
layout.addWidget(
|
||||
build_two_column_form_widget(
|
||||
panel,
|
||||
[
|
||||
("Project dir", owner._adc_project_dir_input),
|
||||
("Executable path", owner._adc_executable_path_input),
|
||||
("TTY path", owner._adc_tty_path_input),
|
||||
("Arguments", owner._adc_args_input),
|
||||
("Environment JSON", owner._adc_env_input),
|
||||
("Startup timeout s", owner._adc_startup_timeout_s_input),
|
||||
("Read timeout s", owner._adc_sweep_timeout_s_input),
|
||||
("Stop timeout s", owner._adc_stop_timeout_s_input),
|
||||
("Board enabled", owner._optical_enabled_checkbox),
|
||||
("Board port", owner._optical_port_input),
|
||||
("Board mode", owner._optical_mode_combo),
|
||||
("PI 1 P", owner._optical_pi_coeff1_p_input),
|
||||
("PI 1 I", owner._optical_pi_coeff1_i_input),
|
||||
("PI 2 P", owner._optical_pi_coeff2_p_input),
|
||||
("PI 2 I", owner._optical_pi_coeff2_i_input),
|
||||
("Project dir", adc_project_dir_input),
|
||||
("Executable path", adc_executable_path_input),
|
||||
("TTY path", adc_tty_path_input),
|
||||
("Arguments", adc_args_input),
|
||||
("Environment JSON", adc_env_input),
|
||||
("Startup timeout s", adc_startup_timeout_s_input),
|
||||
("Read timeout s", adc_sweep_timeout_s_input),
|
||||
("Stop timeout s", adc_stop_timeout_s_input),
|
||||
("Board enabled", optical_enabled_checkbox),
|
||||
("Board port", optical_port_input),
|
||||
("Board mode", optical_mode_combo),
|
||||
("PI 1 P", optical_pi_coeff1_p_input),
|
||||
("PI 1 I", optical_pi_coeff1_i_input),
|
||||
("PI 2 P", optical_pi_coeff2_p_input),
|
||||
("PI 2 I", optical_pi_coeff2_i_input),
|
||||
],
|
||||
split_index=8,
|
||||
)
|
||||
)
|
||||
|
||||
owner._optical_manual_panel = build_two_column_form_widget(
|
||||
optical_manual_panel = build_two_column_form_widget(
|
||||
panel,
|
||||
[
|
||||
("Temperature 1", owner._optical_manual_temp1_input),
|
||||
("Temperature 2", owner._optical_manual_temp2_input),
|
||||
("Current 1", owner._optical_manual_current1_input),
|
||||
("Current 2", owner._optical_manual_current2_input),
|
||||
("Temperature 1", optical_manual_temp1_input),
|
||||
("Temperature 2", optical_manual_temp2_input),
|
||||
("Current 1", optical_manual_current1_input),
|
||||
("Current 2", optical_manual_current2_input),
|
||||
],
|
||||
)
|
||||
owner._optical_variation_panel = build_two_column_form_widget(
|
||||
optical_variation_panel = build_two_column_form_widget(
|
||||
panel,
|
||||
[
|
||||
("Variation type", owner._optical_variation_type_combo),
|
||||
("Static temp 1", owner._optical_static_temp1_input),
|
||||
("Static temp 2", owner._optical_static_temp2_input),
|
||||
("Static current 1", owner._optical_static_current1_input),
|
||||
("Static current 2", owner._optical_static_current2_input),
|
||||
("Min value", owner._optical_min_value_input),
|
||||
("Max value", owner._optical_max_value_input),
|
||||
("Step", owner._optical_step_input),
|
||||
("Time step", owner._optical_time_step_input),
|
||||
("Delay time", owner._optical_delay_time_input),
|
||||
("Variation type", optical_variation_type_combo),
|
||||
("Static temp 1", optical_static_temp1_input),
|
||||
("Static temp 2", optical_static_temp2_input),
|
||||
("Static current 1", optical_static_current1_input),
|
||||
("Static current 2", optical_static_current2_input),
|
||||
("Min value", optical_min_value_input),
|
||||
("Max value", optical_max_value_input),
|
||||
("Step", optical_step_input),
|
||||
("Time step", optical_time_step_input),
|
||||
("Delay time", optical_delay_time_input),
|
||||
],
|
||||
split_index=5,
|
||||
)
|
||||
layout.addWidget(owner._optical_manual_panel)
|
||||
layout.addWidget(owner._optical_variation_panel)
|
||||
layout.addWidget(optical_manual_panel)
|
||||
layout.addWidget(optical_variation_panel)
|
||||
|
||||
_connect_adc_settings(owner)
|
||||
owner._sync_adc_settings_controls()
|
||||
return panel
|
||||
return (panel, optical_enabled_checkbox, optical_mode_combo,
|
||||
(optical_port_input, optical_mode_combo, optical_pi_coeff1_p_input, optical_pi_coeff1_i_input,
|
||||
optical_pi_coeff2_p_input, optical_pi_coeff2_i_input),
|
||||
(optical_manual_temp1_input, optical_manual_temp2_input, optical_manual_current1_input, optical_manual_current2_input),
|
||||
(optical_variation_type_combo, optical_static_temp1_input, optical_static_temp2_input,
|
||||
optical_static_current1_input, optical_static_current2_input, optical_min_value_input,
|
||||
optical_max_value_input, optical_step_input, optical_time_step_input, optical_delay_time_input),
|
||||
optical_manual_panel, optical_variation_panel)
|
||||
|
||||
|
||||
def _plain_text(text: str) -> QPlainTextEdit:
|
||||
widget = QPlainTextEdit(text)
|
||||
widget.setMaximumHeight(76)
|
||||
return widget
|
||||
|
||||
|
||||
def _connect_adc_settings(owner) -> None:
|
||||
text_widgets = [
|
||||
owner._adc_project_dir_input,
|
||||
owner._adc_executable_path_input,
|
||||
owner._adc_tty_path_input,
|
||||
owner._adc_startup_timeout_s_input,
|
||||
owner._adc_sweep_timeout_s_input,
|
||||
owner._adc_stop_timeout_s_input,
|
||||
owner._optical_port_input,
|
||||
owner._optical_pi_coeff1_p_input,
|
||||
owner._optical_pi_coeff1_i_input,
|
||||
owner._optical_pi_coeff2_p_input,
|
||||
owner._optical_pi_coeff2_i_input,
|
||||
owner._optical_manual_temp1_input,
|
||||
owner._optical_manual_temp2_input,
|
||||
owner._optical_manual_current1_input,
|
||||
owner._optical_manual_current2_input,
|
||||
owner._optical_static_temp1_input,
|
||||
owner._optical_static_temp2_input,
|
||||
owner._optical_static_current1_input,
|
||||
owner._optical_static_current2_input,
|
||||
owner._optical_min_value_input,
|
||||
owner._optical_max_value_input,
|
||||
owner._optical_step_input,
|
||||
owner._optical_time_step_input,
|
||||
owner._optical_delay_time_input,
|
||||
]
|
||||
for widget in text_widgets:
|
||||
widget.editingFinished.connect(owner._reset_preprocess_selection_after_radar_key_change)
|
||||
owner._adc_args_input.textChanged.connect(owner._reset_preprocess_selection_after_radar_key_change)
|
||||
owner._adc_env_input.textChanged.connect(owner._reset_preprocess_selection_after_radar_key_change)
|
||||
owner._optical_variation_type_combo.currentTextChanged.connect(
|
||||
owner._reset_preprocess_selection_after_radar_key_change
|
||||
)
|
||||
|
||||
def sync_and_reset() -> None:
|
||||
owner._sync_adc_settings_controls()
|
||||
owner._reset_preprocess_selection_after_radar_key_change()
|
||||
|
||||
owner._optical_enabled_checkbox.toggled.connect(sync_and_reset)
|
||||
owner._optical_mode_combo.currentTextChanged.connect(sync_and_reset)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Builder for switch timing and combo-selection settings section."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
@@ -13,64 +12,71 @@ from PyQt6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
def build_switch_group(owner) -> QGroupBox:
|
||||
from python_app.gui.controllers.sections.views import SwitchView, SectionView
|
||||
from python_app.models.gui_profile_schema import GuiStateModel
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
def build_switch_group(config: RunConfigModel, gui: GuiStateModel) -> SwitchView:
|
||||
"""Create switch timing field and two explicit combo-selection modes."""
|
||||
group = QGroupBox("Switches")
|
||||
view = SectionView(QGroupBox("Switches"))
|
||||
group = view.group
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
switch_defaults = owner._gui_defaults.switches
|
||||
switch_defaults = gui.switches
|
||||
|
||||
global_form = QFormLayout()
|
||||
global_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||
owner._settling_ms = QLineEdit(str(owner._defaults_config.runtime.settling_ms))
|
||||
global_form.addRow("Settling ms", owner._settling_ms)
|
||||
settling_ms = view.add("_settling_ms", QLineEdit(str(config.runtime.settling_ms)))
|
||||
global_form.addRow("Settling ms", settling_ms)
|
||||
layout.addLayout(global_form)
|
||||
|
||||
owner._combos_text = QLineEdit(str(switch_defaults.combos_text))
|
||||
owner._combos_text.setPlaceholderText("empty = full matrix, or input:output,input:output")
|
||||
owner._run_combos_select_button = QPushButton("Select")
|
||||
owner._run_combos_select_button.setCheckable(True)
|
||||
combos_text = view.add("_combos_text", QLineEdit(str(switch_defaults.combos_text)))
|
||||
combos_text.setPlaceholderText("empty = full matrix, or input:output,input:output")
|
||||
run_combos_select_button = QPushButton("Select")
|
||||
run_combos_select_button.setCheckable(True)
|
||||
|
||||
combos_row = QHBoxLayout()
|
||||
combos_row.setSpacing(8)
|
||||
combos_row.addWidget(QLabel("Run combos"))
|
||||
combos_row.addWidget(owner._combos_text, stretch=1)
|
||||
combos_row.addWidget(owner._run_combos_select_button)
|
||||
combos_row.addWidget(combos_text, stretch=1)
|
||||
combos_row.addWidget(run_combos_select_button)
|
||||
layout.addLayout(combos_row)
|
||||
|
||||
owner._single_combo_output = QLineEdit(str(switch_defaults.single_output))
|
||||
owner._single_combo_output.setPlaceholderText("0")
|
||||
owner._single_combo_input = QLineEdit(str(switch_defaults.single_input))
|
||||
owner._single_combo_input.setPlaceholderText("0")
|
||||
owner._single_combo_select_button = QPushButton("Select")
|
||||
owner._single_combo_select_button.setCheckable(True)
|
||||
owner._next_combo_button = QPushButton("Next combination")
|
||||
owner._next_combo_button.setToolTip("Advance through input/output pairs and redraw the selected B-scan.")
|
||||
single_combo_output = view.add("_single_combo_output", QLineEdit(str(switch_defaults.single_output)))
|
||||
single_combo_output.setPlaceholderText("0")
|
||||
single_combo_input = view.add("_single_combo_input", QLineEdit(str(switch_defaults.single_input)))
|
||||
single_combo_input.setPlaceholderText("0")
|
||||
single_combo_select_button = QPushButton("Select")
|
||||
single_combo_select_button.setCheckable(True)
|
||||
|
||||
single_row = QHBoxLayout()
|
||||
single_row.setSpacing(8)
|
||||
single_row.addWidget(QLabel("Single combo"))
|
||||
single_row.addWidget(QLabel("Input"))
|
||||
single_row.addWidget(owner._single_combo_input)
|
||||
single_row.addWidget(single_combo_input)
|
||||
single_row.addWidget(QLabel("Output"))
|
||||
single_row.addWidget(owner._single_combo_output)
|
||||
single_row.addWidget(owner._single_combo_select_button)
|
||||
single_row.addWidget(owner._next_combo_button)
|
||||
single_row.addWidget(single_combo_output)
|
||||
single_row.addWidget(single_combo_select_button)
|
||||
layout.addLayout(single_row)
|
||||
|
||||
owner._combo_select_group = QButtonGroup(group)
|
||||
owner._combo_select_group.setExclusive(True)
|
||||
owner._combo_select_group.addButton(owner._run_combos_select_button)
|
||||
owner._combo_select_group.addButton(owner._single_combo_select_button)
|
||||
combo_select_group = QButtonGroup(group)
|
||||
combo_select_group.setExclusive(True)
|
||||
combo_select_group.addButton(run_combos_select_button)
|
||||
combo_select_group.addButton(single_combo_select_button)
|
||||
|
||||
owner._run_combos_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("text"))
|
||||
owner._single_combo_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("single"))
|
||||
owner._next_combo_button.clicked.connect(owner._select_next_combo)
|
||||
# The live processing config has no combo: picking another one only changes which
|
||||
# B-scan is shown (and, outside matrix mode, the combo of the next run).
|
||||
owner._single_combo_output.editingFinished.connect(owner._on_bscan_display_combo_changed)
|
||||
owner._single_combo_input.editingFinished.connect(owner._on_bscan_display_combo_changed)
|
||||
owner._set_combo_selection_mode(str(switch_defaults.combo_mode))
|
||||
display_row = QHBoxLayout()
|
||||
display_row.addWidget(QLabel("B-scan pair"))
|
||||
display_combo = view.add("_display_combo", QComboBox())
|
||||
display_combo.setEnabled(False)
|
||||
display_row.addWidget(display_combo, stretch=1)
|
||||
next_combo_button = QPushButton("Next combination")
|
||||
next_combo_button.setEnabled(False)
|
||||
display_row.addWidget(next_combo_button)
|
||||
layout.addLayout(display_row)
|
||||
|
||||
return group
|
||||
return SwitchView(group=group, fields=view.fields, select_text=run_combos_select_button,
|
||||
select_single=single_combo_select_button, combos=combos_text,
|
||||
single_input=single_combo_input, single_output=single_combo_output,
|
||||
display_combo=display_combo, next_combo=next_combo_button)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Typed section widgets and the scalar Qt boundary shared by GUI settings."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TypeAlias, TypeVar
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDoubleSpinBox, QGroupBox, QLineEdit, QPlainTextEdit,
|
||||
QSpinBox, QWidget, QLabel, QPushButton, QStackedWidget,
|
||||
)
|
||||
|
||||
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
|
||||
from python_app.models.setting_fields import Scalar
|
||||
|
||||
ScalarWidget: TypeAlias = QLineEdit | QPlainTextEdit | QComboBox | QCheckBox | QSpinBox | QDoubleSpinBox
|
||||
SettingWidget: TypeAlias = ScalarWidget | AntennaGeometryEditor
|
||||
_Widget = TypeVar("_Widget", bound=SettingWidget)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SectionView:
|
||||
group: QGroupBox
|
||||
fields: dict[str, SettingWidget] = field(default_factory=dict)
|
||||
|
||||
def add(self, name: str, widget: _Widget) -> _Widget:
|
||||
self.fields[name] = widget
|
||||
return widget
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PreprocessView(SectionView):
|
||||
labels: dict[str, QLabel]
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class PrimaryView(SectionView):
|
||||
start: QPushButton
|
||||
single_capture: QPushButton
|
||||
stop: QPushButton
|
||||
apply_radar: QPushButton
|
||||
load: QPushButton
|
||||
save: QPushButton
|
||||
preprocess: QPushButton
|
||||
clear: QPushButton
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DataView(SectionView):
|
||||
save: QPushButton
|
||||
save_json: QPushButton
|
||||
remove_last: QPushButton
|
||||
capture_tmp: QPushButton
|
||||
record: QPushButton
|
||||
browse: QPushButton
|
||||
save_path: QLineEdit
|
||||
save_name: QLineEdit
|
||||
record_count: QSpinBox
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class SwitchView(SectionView):
|
||||
select_text: QPushButton
|
||||
select_single: QPushButton
|
||||
combos: QLineEdit
|
||||
single_input: QLineEdit
|
||||
single_output: QLineEdit
|
||||
display_combo: QComboBox
|
||||
next_combo: QPushButton
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class ProcessingView(SectionView):
|
||||
mode: QComboBox
|
||||
pages: QStackedWidget
|
||||
geometry_page: QWidget
|
||||
geometries: tuple[AntennaGeometryEditor, AntennaGeometryEditor]
|
||||
fixed_y: QCheckBox
|
||||
y_bounds: tuple[QDoubleSpinBox, QDoubleSpinBox]
|
||||
speed_controls: tuple[tuple[QCheckBox, QDoubleSpinBox], ...]
|
||||
trace_controls: tuple[ScalarWidget, ...]
|
||||
visual_controls: tuple[ScalarWidget, ...]
|
||||
locator_controls: tuple[ScalarWidget, ...]
|
||||
frequency_controls: tuple[QDoubleSpinBox, ...]
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class RadarView(SectionView):
|
||||
sweep_edits: tuple[QLineEdit, ...]
|
||||
sweep_labels: tuple[QLabel, ...]
|
||||
limits_hint: QLabel
|
||||
vna_panel: QWidget
|
||||
adc_panel: QWidget
|
||||
optical_enabled: QCheckBox
|
||||
optical_mode: QComboBox
|
||||
optical_common: tuple[ScalarWidget, ...]
|
||||
optical_manual: tuple[ScalarWidget, ...]
|
||||
optical_variation: tuple[ScalarWidget, ...]
|
||||
manual_panel: QWidget
|
||||
variation_panel: QWidget
|
||||
|
||||
|
||||
def read_setting(widget: ScalarWidget) -> Scalar:
|
||||
if isinstance(widget, QCheckBox):
|
||||
return widget.isChecked()
|
||||
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
return widget.value()
|
||||
if isinstance(widget, QComboBox):
|
||||
return widget.currentText()
|
||||
if isinstance(widget, QPlainTextEdit):
|
||||
return widget.toPlainText()
|
||||
return widget.text()
|
||||
|
||||
|
||||
def write_setting(widget: ScalarWidget, value: Scalar) -> None:
|
||||
if isinstance(widget, QCheckBox):
|
||||
widget.setChecked(bool(value))
|
||||
elif isinstance(widget, QSpinBox):
|
||||
widget.setValue(int(value))
|
||||
elif isinstance(widget, QDoubleSpinBox):
|
||||
widget.setValue(float(value))
|
||||
elif isinstance(widget, QComboBox):
|
||||
text = str(value)
|
||||
index = widget.findText(text)
|
||||
if index < 0:
|
||||
widget.addItem(text)
|
||||
index = widget.count() - 1
|
||||
widget.setCurrentIndex(index)
|
||||
elif isinstance(widget, QPlainTextEdit):
|
||||
widget.setPlainText(str(value))
|
||||
else:
|
||||
widget.setText(str(value))
|
||||
|
||||
|
||||
def connect_setting(widget: ScalarWidget, callback: Callable[..., object]) -> None:
|
||||
if isinstance(widget, QCheckBox):
|
||||
signal = widget.toggled
|
||||
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
|
||||
signal = widget.valueChanged
|
||||
elif isinstance(widget, QComboBox):
|
||||
signal = widget.currentTextChanged
|
||||
elif isinstance(widget, QPlainTextEdit):
|
||||
signal = widget.textChanged
|
||||
else:
|
||||
signal = widget.editingFinished
|
||||
signal.connect(callback)
|
||||
|
||||
|
||||
def double_spin(minimum: float, maximum: float, decimals: int, step: float) -> QDoubleSpinBox:
|
||||
widget = QDoubleSpinBox()
|
||||
widget.setDecimals(decimals)
|
||||
widget.setRange(minimum, maximum)
|
||||
widget.setSingleStep(step)
|
||||
return widget
|
||||
|
||||
|
||||
def integer_spin(minimum: int, maximum: int) -> QSpinBox:
|
||||
widget = QSpinBox()
|
||||
widget.setRange(minimum, maximum)
|
||||
return widget
|
||||
|
||||
|
||||
def choice(options: list[str]) -> QComboBox:
|
||||
widget = QComboBox()
|
||||
widget.addItems(options)
|
||||
return widget
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Manual dataset export and paused deletion of retained acquisitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.models.gui_profile_schema import ProfileWriter
|
||||
from python_app.gui.controllers.pipeline_controller import PipelineController
|
||||
from python_app.gui.controllers.sections.views import PrimaryView, DataView
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
from python_app.storage.npz.store import NpzStore
|
||||
|
||||
|
||||
class SnapshotController:
|
||||
"""Save stage histories and hold deletion ownership through final redraw."""
|
||||
|
||||
def __init__(
|
||||
self, pipeline: PipelineController, store: NpzStore, write_profile: ProfileWriter,
|
||||
*, destination: Callable[[RunConfigModel], tuple[Path, str, str]],
|
||||
build_config: Callable[[], RunConfigModel], processing_settings: dict[int, dict],
|
||||
allowed: Callable[[bool], bool], log: Callable[[str], None],
|
||||
error: Callable[[str, Exception], None],
|
||||
) -> None:
|
||||
self.pipeline, self.store = pipeline, store
|
||||
self._write_profile, self._log, self._error = write_profile, log, error
|
||||
self._destination, self._build_config = destination, build_config
|
||||
self._settings, self._allowed = processing_settings, allowed
|
||||
|
||||
def bind_actions(self, primary: PrimaryView, data: DataView) -> None:
|
||||
primary.clear.clicked.connect(lambda: self.delete_history(remove_last=False))
|
||||
data.remove_last.clicked.connect(lambda: self.delete_history(remove_last=True))
|
||||
data.save.clicked.connect(lambda: self.save())
|
||||
data.save_json.clicked.connect(lambda: self.save(json_export=True))
|
||||
data.record.clicked.connect(lambda: self.start_recording(data.record_count.value()))
|
||||
|
||||
def start_recording(self, count: int) -> None:
|
||||
if not self._allowed(False):
|
||||
return
|
||||
try:
|
||||
config = self.pipeline.active_config if self.pipeline.running else None
|
||||
self.pipeline.recording.start(
|
||||
*self._destination(config or self._build_config()), count,
|
||||
running=self.pipeline.running, start_acquisition=self.pipeline.start,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._error("Failed to start disk recording", exc)
|
||||
|
||||
def save(self, *, json_export: bool = False) -> None:
|
||||
if not self._allowed(False):
|
||||
return
|
||||
action = "VNA history JSON" if json_export else "snapshot"
|
||||
try:
|
||||
active = self.pipeline.active_config
|
||||
root, name, prefix = self._destination(active or self._build_config())
|
||||
self.pipeline.drain_snapshot()
|
||||
if self.pipeline.closing or self.pipeline.active_config is not active:
|
||||
return
|
||||
raw = list(self.pipeline.history.raw)
|
||||
preprocessed = list(self.pipeline.history.preprocessed)
|
||||
results = list(self.pipeline.history.results)
|
||||
if not (raw or preprocessed or results):
|
||||
raise ValueError("No runtime data is available for save")
|
||||
if json_export:
|
||||
paths = self.store.save_runtime_vna_history_json_batch(
|
||||
root, name, raw, preprocessed, results,
|
||||
primary_stage="preprocessed", name_prefix=prefix,
|
||||
)
|
||||
directory = paths[0].parent
|
||||
profile_path = directory / "vna_config_profile.json"
|
||||
else:
|
||||
directory = self.store.save_runtime_snapshot_numpy(
|
||||
root, name, raw, preprocessed, results,
|
||||
name_prefix=prefix, processing_settings=self._settings,
|
||||
)
|
||||
profile_path = directory / "config_profile.json"
|
||||
try:
|
||||
self._write_profile(profile_path, allow_overwrite=json_export, dataset=True)
|
||||
except Exception as exc:
|
||||
self._error(
|
||||
f"{action} data was saved in {directory}, but the adjacent config profile "
|
||||
f"could not be written: {profile_path}", exc,
|
||||
)
|
||||
return
|
||||
self._log(
|
||||
f"Saved {action}: {directory} "
|
||||
f"(raw={len(raw)}, preprocessed={len(preprocessed)}, results={len(results)})"
|
||||
)
|
||||
except Exception as exc:
|
||||
self._error(f"Failed to save {action}", exc)
|
||||
|
||||
def delete_history(self, *, remove_last: bool) -> None:
|
||||
if not self._allowed(True):
|
||||
return
|
||||
action = "remove last runtime measurement" if remove_last else "clear runtime history"
|
||||
try:
|
||||
with self.pipeline.pause() as operation:
|
||||
if not operation.valid:
|
||||
return
|
||||
self.pipeline.discard_replay()
|
||||
dropped_raw, dropped_pre, dropped_results = self.pipeline.drop_pending()
|
||||
self.pipeline.history.delete(remove_last=remove_last)
|
||||
try:
|
||||
self.pipeline.publish_live()
|
||||
finally:
|
||||
self.pipeline.plots.clear()
|
||||
self.pipeline.settle_deleted_results()
|
||||
if not operation.valid:
|
||||
return
|
||||
self.pipeline.history_changed()
|
||||
self.pipeline.plots.redraw()
|
||||
self._log(
|
||||
f"{action}: dropped raw={dropped_raw}, "
|
||||
f"preprocessed={dropped_pre}, results={dropped_results}"
|
||||
)
|
||||
except InterruptedError:
|
||||
self.pipeline.operation_cancelled()
|
||||
except Exception as exc:
|
||||
self._error(f"Failed to {action}", exc)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Serve GUI snapshots and marshal browser controls onto the Qt thread."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||
from PyQt6.QtWidgets import QLineEdit, QSpinBox, QWidget
|
||||
|
||||
from python_app.gui.settings_defaults import env_enabled
|
||||
from python_app.gui.controllers.config_controller import web_apply_field_names
|
||||
from python_app.webui.controller import WebActionError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from python_app.gui.controllers.live_settings_controller import LiveSettingsController
|
||||
from python_app.gui.controllers.log_controller import LogController
|
||||
from python_app.gui.controllers.pipeline_controller import PipelineController
|
||||
from python_app.gui.controllers.preprocess_controller import PreprocessController
|
||||
from python_app.gui.controllers.profile_controller import ProfileController
|
||||
from python_app.gui.controllers.snapshot_controller import SnapshotController
|
||||
from python_app.webui.server import WebUiServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_WEB_ACTION_TIMEOUT_S = 30.0
|
||||
_GRAB_INTERVAL_S = 0.2
|
||||
_LIVE_FIELD_NAMES = web_apply_field_names()
|
||||
|
||||
|
||||
class _WebActionCall:
|
||||
"""A timeout can cancel queued work; running work must never be retried blindly."""
|
||||
|
||||
def __init__(self, action: Callable[[], object]) -> None:
|
||||
self.action = action
|
||||
self.done = threading.Event()
|
||||
self.error: str | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._started = False
|
||||
|
||||
def begin(self) -> bool:
|
||||
with self._lock:
|
||||
if self.done.is_set() or self._started:
|
||||
return False
|
||||
self._started = True
|
||||
return True
|
||||
|
||||
def finish(self, error: str | None = None) -> None:
|
||||
with self._lock:
|
||||
if not self.done.is_set():
|
||||
self.error = error
|
||||
self.done.set()
|
||||
|
||||
def cancel(self, error: str, *, queued_only: bool = True) -> bool:
|
||||
with self._lock:
|
||||
if self.done.is_set():
|
||||
return True
|
||||
if queued_only and self._started:
|
||||
return False
|
||||
self.error = error
|
||||
self.done.set()
|
||||
return True
|
||||
|
||||
|
||||
def web_ui_port() -> int:
|
||||
try:
|
||||
port = int(os.environ.get("RADAR_SYSTEM_WEBUI_PORT", "8080"))
|
||||
return port if 1 <= port <= 65535 else 8080
|
||||
except ValueError:
|
||||
return 8080
|
||||
|
||||
|
||||
class WebBridge(QObject):
|
||||
"""Own web lifecycle, queued actions, immutable snapshots and change-gated PNGs."""
|
||||
|
||||
_action_requested = pyqtSignal(object)
|
||||
_streaming_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(
|
||||
self, parent: QWidget, run_configs_dir: Path, pipeline: PipelineController,
|
||||
preprocess: PreprocessController, snapshots: SnapshotController, profile: ProfileController,
|
||||
live: LiveSettingsController, log: LogController, *,
|
||||
save_path: QLineEdit, save_name: QLineEdit, record_count: QSpinBox,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._parent, self._run_configs_dir = parent, run_configs_dir
|
||||
self._pipeline, self._preprocess, self._snapshots = pipeline, preprocess, snapshots
|
||||
self._profile, self._live, self._log = profile, live, log
|
||||
self._save_path, self._save_name, self._record_count = save_path, save_name, record_count
|
||||
self._server: WebUiServer | None = None
|
||||
self._status: dict = {}
|
||||
self._live_settings: list[dict] = []
|
||||
self._frame: dict | None = None
|
||||
self._pending_lock = threading.Lock()
|
||||
self._pending_calls: set[_WebActionCall] = set()
|
||||
self._closed = self._streaming = self._grabbing = False
|
||||
self._dirty = True
|
||||
self._last_png: str | None = None
|
||||
self._last_grab_s = 0.0
|
||||
self._frame_seq = self._snapshot_errors = 0
|
||||
self._action_requested.connect(self._run_action)
|
||||
self._streaming_changed.connect(self._set_streaming)
|
||||
plots = pipeline.plots
|
||||
plots.stack.currentChanged.connect(self._mark_dirty)
|
||||
for plot in plots.stack.findChildren(pg.PlotWidget):
|
||||
scene = plot.scene()
|
||||
assert scene is not None
|
||||
scene.changed.connect(self._mark_dirty)
|
||||
plot.sigDeviceRangeChanged.connect(self._mark_dirty)
|
||||
|
||||
def start_server(self) -> None:
|
||||
if self._closed or self._server is not None:
|
||||
return
|
||||
try:
|
||||
from python_app.webui.server import WebUiServer
|
||||
|
||||
if env_enabled("RADAR_SYSTEM_HEADLESS"):
|
||||
self._parent.resize(1600, 900)
|
||||
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.refresh_snapshot()
|
||||
self._server = WebUiServer(self, port=web_ui_port())
|
||||
self._server.start()
|
||||
except Exception:
|
||||
logger.warning("Failed to start web UI", exc_info=True)
|
||||
if self._server is not None:
|
||||
with suppress(Exception):
|
||||
self._server.stop()
|
||||
self._server = None
|
||||
|
||||
def shutdown(self) -> None:
|
||||
with self._pending_lock:
|
||||
self._closed, self._streaming = True, False
|
||||
pending = tuple(self._pending_calls)
|
||||
for call in pending:
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
if self._server is not None:
|
||||
with suppress(Exception):
|
||||
self._server.stop()
|
||||
self._server = None
|
||||
|
||||
def _dispatch(self, action: Callable[[], object]) -> None:
|
||||
call = _WebActionCall(action)
|
||||
with self._pending_lock:
|
||||
if self._closed:
|
||||
raise WebActionError("The application is closing")
|
||||
self._pending_calls.add(call)
|
||||
try:
|
||||
self._action_requested.emit(call)
|
||||
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
|
||||
if not call.cancel("The desktop did not start the action in time; the request was cancelled"):
|
||||
raise WebActionError("The desktop action is still running; check its status before retrying")
|
||||
if call.error is not None:
|
||||
raise WebActionError(call.error)
|
||||
finally:
|
||||
with self._pending_lock:
|
||||
self._pending_calls.discard(call)
|
||||
|
||||
def _run_action(self, call: _WebActionCall) -> None:
|
||||
if self._closed or self._pipeline.closing:
|
||||
call.cancel("The application is closing", queued_only=False)
|
||||
return
|
||||
if not call.begin():
|
||||
return
|
||||
error = None
|
||||
try:
|
||||
with self._log.capture_errors() as captured:
|
||||
call.action()
|
||||
error = captured[0] if captured else None
|
||||
self.refresh_snapshot()
|
||||
except Exception as exc:
|
||||
error = self._log.exception_summary(exc)
|
||||
finally:
|
||||
call.finish(error)
|
||||
|
||||
# The web thread reads only replaced snapshots; all widget reads happen below.
|
||||
def status(self) -> dict:
|
||||
return dict(self._status)
|
||||
|
||||
def current_live_settings(self) -> list[dict]:
|
||||
return list(self._live_settings)
|
||||
|
||||
def peek_frame(self) -> dict | None:
|
||||
return self._frame
|
||||
|
||||
def list_configs(self) -> list[str]:
|
||||
return sorted(path.name for path in self._run_configs_dir.glob("*.json") if path.is_file())
|
||||
|
||||
def start(self) -> None:
|
||||
self._dispatch(self._pipeline.start)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._dispatch(self._pipeline.stop)
|
||||
|
||||
def single_capture(self) -> None:
|
||||
self._dispatch(lambda: self._pipeline.start(single_capture=True))
|
||||
|
||||
def capture_tmp_reference(self) -> None:
|
||||
self._dispatch(self._preprocess.capture_tmp_reference)
|
||||
|
||||
def remove_last_measurement(self) -> None:
|
||||
self._dispatch(lambda: self._snapshots.delete_history(remove_last=True))
|
||||
|
||||
def clear_history(self) -> None:
|
||||
self._dispatch(lambda: self._snapshots.delete_history(remove_last=False))
|
||||
|
||||
def start_recording(self, path: str, name: str, count: int) -> None:
|
||||
if type(count) is not int or count < 1:
|
||||
raise ValueError("Recording count must be a positive integer")
|
||||
self._dispatch(lambda: self._start_recording(path, name, count))
|
||||
|
||||
def _start_recording(self, path: str, name: str, count: int) -> None:
|
||||
low, high = self._record_count.minimum(), self._record_count.maximum()
|
||||
if not low <= count <= high:
|
||||
raise ValueError(f"Recording count must be an integer from {low} to {high}")
|
||||
self._set_destination(path, name)
|
||||
self._record_count.setValue(count)
|
||||
self._snapshots.start_recording(count)
|
||||
|
||||
def _set_destination(self, path: str, name: str) -> None:
|
||||
if path.strip():
|
||||
self._save_path.setText(path)
|
||||
self._save_name.setText(name)
|
||||
|
||||
def save_dataset(self, path: str, name: str) -> None:
|
||||
self._dispatch(lambda: self._save_dataset(path, name))
|
||||
|
||||
def _save_dataset(self, path: str, name: str) -> None:
|
||||
self._set_destination(path, name)
|
||||
self._snapshots.save()
|
||||
|
||||
def load_config(self, name: str) -> None:
|
||||
def load() -> None:
|
||||
path = self._run_configs_dir / name
|
||||
if not name or name != Path(name).name or path.suffix.lower() != ".json" or not path.is_file():
|
||||
raise ValueError(f"Unknown run config: {name}")
|
||||
self._profile.load(path)
|
||||
self._dispatch(load)
|
||||
|
||||
def apply_live_settings(self, fields: dict) -> list[dict]:
|
||||
fields = dict(fields)
|
||||
unknown = set(fields) - _LIVE_FIELD_NAMES
|
||||
if unknown:
|
||||
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
|
||||
self._dispatch(lambda: self._live.apply_web(fields))
|
||||
return self.current_live_settings()
|
||||
|
||||
def select_combo(self, input_pos: int, output_pos: int) -> None:
|
||||
if any(type(pos) is not int or pos < 0 for pos in (input_pos, output_pos)):
|
||||
raise ValueError("Channel positions must be nonnegative integers")
|
||||
self._dispatch(lambda: self._pipeline.plots.select_combo(input_pos, output_pos))
|
||||
|
||||
def next_combo(self) -> None:
|
||||
self._dispatch(self._pipeline.plots.select_next_combo)
|
||||
|
||||
def refresh_snapshot(self) -> None:
|
||||
if self._closed or self._pipeline.closing:
|
||||
return
|
||||
try:
|
||||
pipeline, plots = self._pipeline, self._pipeline.plots
|
||||
input_pos, output_pos = plots.display_key or (0, 0)
|
||||
mode = self._live.active_mode
|
||||
self._status = {
|
||||
"running": pipeline.running, "processor_running": pipeline.processor_running,
|
||||
"ring_name": self._profile.state.config.rings.results.name,
|
||||
"active_config": self._profile.state.path.name,
|
||||
"save_path": self._save_path.text(), "save_name": self._save_name.text(),
|
||||
"record_count": self._record_count.value(), "recording": pipeline.recording.status(),
|
||||
"raw_count": len(pipeline.history.raw), "preprocessed_count": len(pipeline.history.preprocessed),
|
||||
"result_count": len(pipeline.history.results),
|
||||
"sar3d_status": plots.sar3d.status_label.text() if mode == "sar3d" else "",
|
||||
"display_combo": {"enabled": mode == "bscan", "input": input_pos, "output": output_pos,
|
||||
"options": sorted(plots.bscan.history_by_combo)},
|
||||
}
|
||||
self._live_settings = self._live.config.web_schema()
|
||||
frame = self._grab_frame_if_due()
|
||||
if frame is not None:
|
||||
self._frame = frame
|
||||
except Exception:
|
||||
self._snapshot_errors += 1
|
||||
if self._snapshot_errors % 200 == 1:
|
||||
logger.warning("Web UI snapshot refresh failed", exc_info=True)
|
||||
|
||||
def set_frame_streaming(self, enabled: bool) -> None:
|
||||
if not self._closed:
|
||||
self._streaming_changed.emit(enabled)
|
||||
|
||||
def _set_streaming(self, enabled: bool) -> None:
|
||||
if not self._closed:
|
||||
self._streaming = enabled
|
||||
if enabled:
|
||||
self._dirty = True
|
||||
|
||||
def _mark_dirty(self, *_args: object) -> None:
|
||||
if not self._closed and not self._grabbing:
|
||||
self._dirty = True
|
||||
|
||||
def _grab_frame_if_due(self) -> dict | None:
|
||||
if self._closed or not self._streaming or not self._dirty:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
if now - self._last_grab_s < _GRAB_INTERVAL_S:
|
||||
return None
|
||||
self._last_grab_s, self._dirty, self._grabbing = now, False, True
|
||||
try:
|
||||
png = self._grab_png()
|
||||
except Exception:
|
||||
self._dirty = True
|
||||
raise
|
||||
finally:
|
||||
self._grabbing = False
|
||||
if png is None or png == self._last_png:
|
||||
return None
|
||||
self._last_png = png
|
||||
self._frame_seq += 1
|
||||
return {"type": "frame", "seq": self._frame_seq, "mode": self._live.active_mode, "png_b64": png}
|
||||
|
||||
def _grab_png(self) -> str | None:
|
||||
widget = self._pipeline.plots.capture_widget
|
||||
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(buffer.data().data()).decode()
|
||||
Reference in New Issue
Block a user