Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+1 -1
View File
@@ -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
+197
View File
@@ -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
+292
View File
@@ -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
@@ -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()