Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+133
View File
@@ -0,0 +1,133 @@
"""Table editor for antenna switch positions and coordinates in metres."""
from __future__ import annotations
from dataclasses import asdict, is_dataclass
import math
from PyQt6.QtCore import QLocale, QSignalBlocker, pyqtSignal
from PyQt6.QtGui import QDoubleValidator, QIntValidator
from PyQt6.QtWidgets import (
QAbstractItemView, QHeaderView, QHBoxLayout, QLineEdit, QMessageBox, QPushButton,
QStyledItemDelegate, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
class _CoordinateDelegate(QStyledItemDelegate):
def createEditor(self, parent, option, index):
editor = QLineEdit(parent)
validator = (
QIntValidator(0, self.parent().property("position_count") - 1, editor)
if index.column() == 0 else QDoubleValidator(editor)
)
validator.setLocale(QLocale.c())
editor.setValidator(validator)
return editor
class AntennaGeometryEditor(QWidget):
"""Keep geometry explicit and preserve all three coordinates on profile round trips."""
changed = pyqtSignal()
def __init__(self, position_field: str, entries, *, position_count: int, parent=None) -> None:
super().__init__(parent)
self.position_field = position_field
self._table = QTableWidget(0, 4, self)
self.set_position_count(position_count)
self._table.setHorizontalHeaderLabels([
"Output" if position_field == "output_pos" else "Input", "X (m)", "Y (m)", "Z (m)",
])
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self._table.verticalHeader().hide()
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setItemDelegate(_CoordinateDelegate(self._table))
self._table.setMinimumHeight(130)
self._table.setMaximumHeight(220)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._table)
buttons = QHBoxLayout()
self._add_button = QPushButton("Add antenna", self)
self._remove_button = QPushButton("Remove selected", self)
buttons.addWidget(self._add_button)
buttons.addWidget(self._remove_button)
layout.addLayout(buttons)
self.set_records(entries)
self._table.itemChanged.connect(self._on_item_changed)
self._add_button.clicked.connect(self._add_row)
self._remove_button.clicked.connect(self._remove_rows)
def records(self) -> list[dict]:
records = []
for row in range(self._table.rowCount()):
records.append(dict(zip(
(self.position_field, "x_m", "y_m", "z_m"),
(self._table.item(row, column).text() for column in range(4)),
strict=True,
)))
return self._normalize(records)
def _normalize(self, entries) -> list[dict]:
records = []
positions = set()
for entry in entries:
entry = asdict(entry) if is_dataclass(entry) else entry
if any(isinstance(entry.get(key), bool) for key in (self.position_field, "x_m", "y_m", "z_m")):
raise ValueError("Antenna positions and coordinates must be numbers, not booleans")
position = int(entry[self.position_field])
if float(entry[self.position_field]) != position or not 0 <= position < self._table.property("position_count"):
raise ValueError("Antenna position is outside the configured switch positions")
if position in positions:
raise ValueError(f"Duplicate antenna position: {position}")
positions.add(position)
coordinates = {key: float(entry.get(key, 0.0)) for key in ("x_m", "y_m", "z_m")}
if any(not math.isfinite(value) or abs(value) > 3.4e38 for value in coordinates.values()):
raise ValueError("Antenna coordinates must be finite numbers")
records.append({self.position_field: position, **coordinates})
return records
def set_position_count(self, count: int) -> None:
self.position_count = int(count)
self._table.setProperty("position_count", int(count))
def set_records(self, entries) -> None:
records = self._normalize(entries)
with QSignalBlocker(self._table):
self._table.setRowCount(len(records))
for row, record in enumerate(records):
for column, value in enumerate(record.values()):
self._table.setItem(row, column, QTableWidgetItem(str(value)))
self._valid_records = records
self._add_button.setEnabled(len(records) < self._table.property("position_count"))
self.changed.emit()
def _on_item_changed(self, _item) -> None:
try:
self._valid_records = self.records()
except ValueError as exc:
with QSignalBlocker(self):
self.set_records(self._valid_records)
QMessageBox.warning(self, "Invalid antenna geometry", str(exc))
return
self.changed.emit()
def _add_row(self) -> None:
records = self.records()
if len(records) >= self._table.property("position_count"):
return
positions = {record[self.position_field] for record in records}
position = next(index for index in range(len(positions) + 1) if index not in positions)
self.set_records([*records, {self.position_field: position}])
self._table.setCurrentCell(len(records), 1)
self._table.editItem(self._table.item(len(records), 1))
def _remove_rows(self) -> None:
rows = {index.row() for index in self._table.selectionModel().selectedRows()}
if rows:
with QSignalBlocker(self._table):
for row in sorted(rows, reverse=True):
self._table.removeRow(row)
self._valid_records = self.records()
self._add_button.setEnabled(True)
self.changed.emit()
+22 -46
View File
@@ -23,6 +23,7 @@ from PyQt6.QtGui import QTextCursor
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
from python_app.gui.controllers.app_window_control_button_mixin import AppWindowControlButtonMixin
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
@@ -160,6 +161,9 @@ class AppWindow(
self._pending_startup_log_entries: list[tuple[str, str, str | None]] = []
# Guards closeEvent against re-entrant teardown (e.g. a second signal).
self._closing = False
self._close_after_stop = False
self._stopping_run = False
self._stop_request_seq = 0
def _init_logging(self) -> None:
"""Configure the application logger and the bridge that feeds the GUI panel.
@@ -336,11 +340,10 @@ class AppWindow(
def _init_history_state(self) -> None:
"""Initialize runtime history buffers and render-cache state."""
bscan_cpp_replay_window = self._cpp_bscan_replay_window_from_config()
save_history_limit = self._save_history_limit_from_config()
self._raw_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
self._pre_history: deque[SweepCollection] = deque(maxlen=save_history_limit)
self._result_history: deque[ResultCollection] = deque(maxlen=save_history_limit)
bscan_cpp_replay_window = self._cpp_bscan_replay_window_for_config(self._defaults_config)
self._raw_history: deque[SweepCollection] = deque(maxlen=GUI_SAVE_HISTORY_LIMIT)
self._pre_history: deque[SweepCollection] = deque(maxlen=GUI_SAVE_HISTORY_LIMIT)
self._result_history: deque[ResultCollection] = deque(maxlen=GUI_SAVE_HISTORY_LIMIT)
# Sequence id must survive GUI restarts so history commands stay monotonic.
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
@@ -371,20 +374,11 @@ class AppWindow(
self._gpr_region_contours = []
self._gpr_geometry_signature = None
self._gpr_selected_geometry = None
self._phase_viewbox = None
self._history_run_signature = None
self._processor_run_signature = None
self._active_processing_mode = "pass_through"
self._radar_limits: dict[str, float | int] | None = None
def _cpp_bscan_replay_window_from_config(self) -> int:
"""Return the C++ B-scan replay window for the active config."""
return self._cpp_bscan_replay_window_for_config(self._defaults_config)
def _save_history_limit_from_config(self) -> int:
"""Return maxlen for GUI snapshot-save deques (independent of ring capacities)."""
return self._save_history_limit_for_config(self._defaults_config)
def _init_runtime_limits(self) -> None:
"""Initialize read/drain loop limits used by polling and snapshot code."""
self._max_pop_per_poll = 256
@@ -638,30 +632,6 @@ class AppWindow(
)
return "<div style='margin:0 0 6px 0;'>" + "".join(body_parts) + "</div>"
def _append_log_entry(
self,
level: str,
text: str,
*,
details: str | None = None,
once_key: str | None = None,
) -> None:
"""Append formatted log entry with timestamp and optional details."""
if once_key is not None:
if once_key in self._logged_once_keys:
return
self._logged_once_keys.add(once_key)
cursor = self._log_box.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self._log_box.setTextCursor(cursor)
self._log_box.insertHtml(self._render_log_entry_html(level, text, details))
self._log_box.insertPlainText("\n")
self._log_box.ensureCursorVisible()
if level.upper() == "ERROR" and hasattr(self, "_status_label"):
self._status_label.setText("Status: error")
def _on_log_level_selected(self, level_text: str) -> None:
"""Apply the chosen log level immediately (sub-level logs stop being generated).
@@ -746,12 +716,8 @@ class AppWindow(
# Missing or malformed file should not block startup.
return 0
raw_value = payload.get("history_command_seq", 0)
if isinstance(raw_value, bool):
return 0
if isinstance(raw_value, (int, float)):
return max(0, int(raw_value))
return 0
raw_value = payload.get("history_command_seq", 0) if isinstance(payload, dict) else 0
return raw_value if type(raw_value) is int and 0 <= raw_value < (1 << 64) - 1 else 0
def _capture_web_action_error(self, message: str) -> bool:
"""Record the first error of an in-flight web action so the browser can show it.
@@ -804,18 +770,24 @@ class AppWindow(
Guarded against re-entrancy so a second close signal (or a ``window.close()``
after the event loop has already returned) does not run teardown twice.
"""
if self._stopping_run:
# A drain pumps Qt events. Finish ingesting/finalizing before teardown
# can close the same readers and recording writer beneath that drain.
self._close_after_stop = True
self._stop_all_requested = True
event.ignore()
return
if self._closing:
# Re-entrant close (second signal, or window.close() after the event loop
# already returned): teardown is in progress or done — do nothing more.
super().closeEvent(event)
return
self._closing = True
self._timer.stop()
self._log("Window closing; shutting down runtime.")
try:
# 0) Stop the web server first so a late request cannot start work.
self._shutdown_web_ui()
# 0) Stop the disk-recording writer thread so it is not orphaned.
self._shutdown_recording()
# 0) Stop the GPIO button watcher so a late press cannot start work.
self._stop_control_button_watcher()
self._resume_pipeline_after_capture = False
@@ -825,11 +797,15 @@ class AppWindow(
self._close_replay_ring_writer()
# 1) Abort active capture first (releases exclusive hardware resources).
self._abort_capture_sequence(resume_pipeline=False)
# Drain acquisition stages before finalizing the last recording chunk.
if self._recording_active:
self._stop_run()
# 2) Stop all managed processes/readers.
self._stop_all_processes()
# 3) Close auxiliary dialog windows.
if self._preprocess_dialog is not None:
self._preprocess_dialog.close()
finally:
self._shutdown_recording()
self._log_debug("Window teardown finished.")
super().closeEvent(event)
@@ -2,17 +2,21 @@
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,
QPlainTextEdit,
QSpinBox,
)
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
_GPR_MODES = ("gpr", "legacy_gpr")
@@ -56,6 +60,9 @@ _WEB_LIVE_SCHEMA = [
("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"})),
@@ -107,6 +114,7 @@ _NON_WIDGET_LIVE_FIELDS = frozenset({
"reprocess_current_result",
"history_command",
"history_command_seq",
"settings_revision",
})
# Display toggles: GUI-only rendering choices (kept in the GUI profile, not the live
@@ -118,21 +126,10 @@ _WEB_DISPLAY_SCHEMA = [
("combo_filter", "Display", ("pass_through",), _attr("_pass_through_combo_filter_input")),
]
# Stable run_config fields (GPR medium and antenna geometry). They live in run_config and
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
# "applies on Start" and editing them just updates the widget for the next start.
_WEB_STABLE_SCHEMA = [
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative
# permittivity is a legacy-GPR-only knob; BP ignores it.
("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")),
]
# Every field the web form renders and may write back, across all three categories.
# Every field the web form renders and may write back.
_WEB_FORM_GETTERS = {
field: getter
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA, _WEB_STABLE_SCHEMA)
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA)
for field, _group, _modes, getter in source
}
@@ -157,8 +154,9 @@ def _web_field_schema(field: str, group: str, widget) -> dict | None:
return {**base, "kind": "float", "value": float(widget.value()),
"min": float(widget.minimum()), "max": float(widget.maximum()),
"step": float(widget.singleStep()), "decimals": int(widget.decimals())}
if isinstance(widget, QPlainTextEdit):
return {**base, "kind": "textarea", "value": widget.toPlainText()}
if isinstance(widget, 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
@@ -180,48 +178,47 @@ def _read_live_widget_value(widget):
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 all three widget categories — live settings, GUI display toggles, and stable
run_config fields — so the web form shows exactly what the desktop processing panel
does. Stable fields are tagged ``applies_on_start`` (they take effect on the next run).
Covers live settings and GUI display toggles, matching the desktop panel.
"""
mode = window._processing_mode.currentText()
schema: list[dict] = []
for source, extra in (
(_WEB_LIVE_SCHEMA, {}),
(_WEB_DISPLAY_SCHEMA, {}),
(_WEB_STABLE_SCHEMA, {"applies_on_start": True}),
):
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, **extra})
schema.append(entry)
return schema
def _set_web_live_field(window, field: str, value) -> None:
"""Write one web value into the desktop widget that feeds ``field`` (any category)."""
getter = _WEB_FORM_GETTERS.get(field)
if getter is None:
return
widget = getter(window)
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):
widget.setChecked(bool(value))
elif isinstance(widget, QSpinBox):
widget.setValue(int(float(value)))
elif isinstance(widget, QDoubleSpinBox):
widget.setValue(float(value))
elif isinstance(widget, QPlainTextEdit):
widget.setPlainText(str(value))
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))
@@ -297,6 +294,7 @@ class AppWindowLiveProcessingMixin:
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,
)
@@ -315,12 +313,15 @@ class AppWindowLiveProcessingMixin:
"""
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(),
)
)
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.
@@ -339,6 +340,10 @@ class AppWindowLiveProcessingMixin:
"""
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
@@ -356,15 +361,30 @@ class AppWindowLiveProcessingMixin:
self._log_debug(f"Applying web live settings: fields={sorted(settings)}.")
self._suppress_live_settings_handler = True
try:
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
# legacy_gpr widget based on the active mode.
if "processor_mode" in settings:
_set_web_live_field(self, "processor_mode", settings.pop("processor_mode"))
for name, value in settings.items():
_set_web_live_field(self, name, value)
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._on_processing_live_settings_changed()
self._sync_socket_speed_controls()
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)
@@ -372,6 +392,12 @@ class AppWindowLiveProcessingMixin:
"""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._await_processing_revision = True
self._on_processing_live_settings_changed()
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
@@ -399,11 +425,12 @@ class AppWindowLiveProcessingMixin:
collection = latest
if collection is None and self._result_history:
collection = self._result_history[-1]
if collection is None or not self._draw_results(collection):
if current_mode == "sar3d":
self._clear_sar3d_plot()
else:
self._clear_gpr_plot()
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:
@@ -589,9 +616,9 @@ class AppWindowLiveProcessingMixin:
self._clear_gpr_plot()
return
if self._processing_mode.currentText() == "sar3d":
if self._result_history and self._draw_results(self._result_history[-1]):
return
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])
@@ -10,6 +10,8 @@ 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 (
@@ -27,8 +29,7 @@ class AppWindowConfigProfileIOMixin:
raise FileExistsError(f"Config profile output already exists: {output_path}")
profile = self._build_gui_profile()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(profile.to_dict(), indent=2), encoding="utf-8")
atomic_write_json(output_path, profile.to_dict(), durable=True)
return profile
def _set_combo_selection_mode(self, mode: str) -> None:
@@ -42,6 +43,7 @@ class AppWindowConfigProfileIOMixin:
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"
@@ -52,6 +54,32 @@ class AppWindowConfigProfileIOMixin:
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."""
@@ -124,11 +152,10 @@ class AppWindowConfigProfileIOMixin:
C++ replay window still follows ring capacities, since it bounds how much
of the history the processor can re-publish coherently.
"""
save_history_limit = self._save_history_limit_for_config(config)
bscan_cpp_replay_window = self._cpp_bscan_replay_window_for_config(config)
self._raw_history = deque(self._raw_history, maxlen=save_history_limit)
self._pre_history = deque(self._pre_history, maxlen=save_history_limit)
self._result_history = deque(self._result_history, maxlen=save_history_limit)
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()
@@ -351,7 +378,6 @@ class AppWindowConfigProfileIOMixin:
self._sar3d_comp_range_power,
self._sar3d_use_compensation,
self._sar3d_normalize_each_frame,
self._save_count,
self._record_count,
self._save_path_input,
self._save_name_input,
@@ -457,18 +483,10 @@ class AppWindowConfigProfileIOMixin:
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.setPlainText(
"\n".join(
f"{int(entry.output_pos)} {float(entry.x_m):g}"
for entry in config.gpr.tx_geometry
)
)
self._gpr_rx_geometry_input.setPlainText(
"\n".join(
f"{int(entry.input_pos)} {float(entry.x_m):g}"
for entry in config.gpr.rx_geometry
)
)
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))
@@ -555,7 +573,6 @@ class AppWindowConfigProfileIOMixin:
self._sar3d_normalize_each_frame.setChecked(bool(sar3d_state.normalize_each_frame))
self._sync_socket_speed_controls()
self._save_count.setValue(int(gui_state.data_actions.save_count))
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))
@@ -590,6 +607,7 @@ class AppWindowConfigProfileIOMixin:
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)
@@ -28,9 +28,6 @@ class AppWindowRadarLimitsMixin:
else:
radar_service = create_single_radar_service(config)
if isinstance(radar_service, LibreVnaService) and not radar_service.driver_available:
raise RuntimeError("LibreVNA Python driver is not available for device limits query")
limits = radar_service.read_device_limits()
return self._apply_radar_limits_to_ui(limits)
@@ -81,52 +81,6 @@ class AppWindowConfigStateBuildersMixin:
env[key] = value
return env
@staticmethod
def _parse_geometry_line_coordinates(parts: list[str]) -> tuple[float, float, float]:
"""Parse 1/2/3 trailing coordinate fields into (x, y, z); missing axes default to 0."""
x_m = float(parts[0])
y_m = float(parts[1]) if len(parts) >= 2 else 0.0
z_m = float(parts[2]) if len(parts) >= 3 else 0.0
return x_m, y_m, z_m
@staticmethod
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
"""Parse line-based Tx geometry editor text: `output_pos x_m [y_m] [z_m]`."""
entries: list[GprTxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 2 or len(parts) > 4:
raise ValueError(
f"Invalid Tx geometry line {line_number}: expected `output_pos x_m [y_m] [z_m]`"
)
x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:])
entries.append(
GprTxGeometryModel(output_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m)
)
return entries
@staticmethod
def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]:
"""Parse line-based Rx geometry editor text: `input_pos x_m [y_m] [z_m]`."""
entries: list[GprRxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 2 or len(parts) > 4:
raise ValueError(
f"Invalid Rx geometry line {line_number}: expected `input_pos x_m [y_m] [z_m]`"
)
x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:])
entries.append(
GprRxGeometryModel(input_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m)
)
return entries
@staticmethod
def _format_combos_text_from_config(config: RunConfigModel) -> str:
"""Render configured combos for UI text editor, keeping full matrix as empty."""
@@ -192,18 +146,6 @@ class AppWindowConfigStateBuildersMixin:
),
)
@staticmethod
def _save_history_limit_for_config(config: RunConfigModel) -> int:
"""Return maxlen for GUI snapshot-save deques.
Independent of ring capacities — see :data:`GUI_SAVE_HISTORY_LIMIT`.
The `config` argument is kept for symmetry with
:meth:`_cpp_bscan_replay_window_for_config` and possible future per-profile
overrides.
"""
del config
return GUI_SAVE_HISTORY_LIMIT
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)
@@ -217,74 +159,17 @@ class AppWindowConfigStateBuildersMixin:
single_output=str(int(default_combo.output)),
),
processing=GuiProcessingStateModel(
selected_mode="pass_through",
pass_through=GuiPassThroughStateModel(
show_magnitude=True,
show_phase=True,
unwrap_phase=False,
combo_filter="",
fixed_y_enabled=False,
y_min_db=-100.0,
y_max_db=0.0,
),
bscan=GuiBscanStateModel(
axis="abs",
cut_m=0.824,
max_depth_m=1.0,
gain=1.0,
start_freq_mhz=100.0,
stop_freq_mhz=8800.0,
subtract_mean_ascan=False,
),
gpr=GuiGprStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
output_positions=self._default_gpr_output_positions_from_config(config),
min_depth_m=2.0,
max_depth_m=14.0,
range_comp_power=0.1,
object_min_frac=0.7,
score_mode="combined",
motion_mode="int_minus",
look_angle_deg=0.0,
direction_sign=1.0,
speed_m_s=0.0,
ignore_socket_speed_enabled=False,
max_detected_objects_to_draw=5,
draw_top_m_objects=2,
object_approach_min_frames=3,
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
background_subtract_enabled=True,
background_mean_count=10,
remove_sidelobe_objects_enabled=True,
imaging_plane_y_m=0.0,
render_mode="heatmap",
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
visible_z_min_m=0.0,
visible_z_max_m=14.0,
),
legacy_gpr=GuiLegacyGprStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
output_positions=self._default_gpr_output_positions_from_config(config),
min_depth_m=2.0,
max_depth_m=14.0,
comp_power=0.2,
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
speed_m_s=0.0,
ignore_socket_speed_enabled=False,
look_angle_deg=0.0,
snr_thresh=4.5,
snr_comp_max=25.0,
background_subtract_enabled=True,
background_mean_count=10,
render_mode="heatmap",
min_visible_pair_count=1,
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
visible_z_min_m=0.0,
visible_z_max_m=14.0,
),
sar3d=GuiSar3dStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
@@ -292,15 +177,7 @@ class AppWindowConfigStateBuildersMixin:
),
),
data_actions=GuiDataActionsStateModel(
save_count=10,
save_path=str(self._project_root / "python_app/data/snapshots"),
save_name="snapshot_manual",
record_count=100,
),
preprocess_dialog=GuiPreprocessDialogStateModel(
set_name="set_001",
radar_config_dir="",
use_all_radar_configs=False,
),
)
@@ -442,7 +319,6 @@ class AppWindowConfigStateBuildersMixin:
),
),
data_actions=GuiDataActionsStateModel(
save_count=int(self._save_count.value()),
save_path=self._save_path_input.text().strip(),
save_name=self._save_name_input.text().strip(),
record_count=int(self._record_count.value()),
@@ -502,8 +378,8 @@ class AppWindowConfigStateBuildersMixin:
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 = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText())
config.gpr.rx_geometry = self._parse_gpr_rx_geometry_text(self._gpr_rx_geometry_input.toPlainText())
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,
@@ -7,6 +7,7 @@ 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,
@@ -16,6 +17,7 @@ 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,
@@ -57,6 +59,11 @@ class AppWindowPipelineMixin:
def _start_run(self, *, single_capture: bool = False) -> 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 self._capture_session is not None:
self._show_error(
"Cannot start pipeline during active capture sequence",
@@ -76,15 +83,20 @@ class AppWindowPipelineMixin:
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 = 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()
@@ -207,13 +219,17 @@ class AppWindowPipelineMixin:
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()
if config.radar.driver_mode == "native":
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):
@@ -241,13 +257,14 @@ class AppWindowPipelineMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to apply radar settings", exc)
finally:
if was_running:
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.model == RunConfigModel.COMPACT_M_K209_MODEL and config.radar.driver_mode != "native":
raise RuntimeError("Compact-M K209 requires radar.driver_mode='native'")
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
@@ -265,9 +282,6 @@ class AppWindowPipelineMixin:
return
radar_service = create_single_radar_service(config)
if not getattr(radar_service, "driver_available", True):
raise RuntimeError("LibreVNA Python driver is not available for native pre-configuration")
try:
radar_service.open()
radar_service.configure(config.radar.sweep)
@@ -278,49 +292,77 @@ class AppWindowPipelineMixin:
def _stop_run(self) -> None:
"""Stop acquisition-side processes and close readers as needed."""
self._pipeline_should_run = False # an explicit stop disables crash auto-restart
was_running = self._supervisor.is_running()
if 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)
else:
self._supervisor.stop()
self._drain_rings_once_for_history()
# Flush any in-progress disk recording so its buffered (not-yet-full) chunk is
# written rather than lost; the drains above already captured the last data.
self._finalize_recording_on_stop()
keep_results_reader = self._supervisor.is_processor_running()
self._close_readers(keep_results=keep_results_reader)
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")
if was_running:
if keep_results_reader:
self._log("Acquisition stopped (data_processor kept running)")
else:
self._log("Pipeline stopped")
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._pipeline_should_run = False # an explicit stop disables crash auto-restart
was_running = self._supervisor.is_running() or self._supervisor.is_processor_running()
self._supervisor.stop_all()
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
self._close_readers(keep_results=False)
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._processor_run_signature = None
self._update_history_indicator()
self._status_label.setText("Status: idle")
if was_running:
self._log("All pipeline processes stopped")
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``."""
@@ -342,6 +384,8 @@ class AppWindowPipelineMixin:
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()
@@ -366,12 +410,10 @@ class AppWindowPipelineMixin:
# crash-storm budget (it only caps consecutive crash-restarts).
self._pipeline_restart_count = 0
render_started_ns = time.monotonic_ns()
self._draw_preferred_collection(result_latest=result_latest)
self._draw_results(result_latest)
self._pipeline_metrics.record(
"rendering", time.monotonic_ns() - render_started_ns
)
else:
self._draw_preferred_collection(result_latest=None)
except Exception as exc: # noqa: BLE001
self._handle_reader_poll_error(exc)
@@ -395,7 +437,12 @@ class AppWindowPipelineMixin:
self._log_exception("Failed to collect process exit reports", exc, level="ERROR")
return
if unexpected and getattr(self, "_pipeline_should_run", False):
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:
@@ -441,7 +488,7 @@ class AppWindowPipelineMixin:
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 the next crash signal
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:
@@ -529,8 +576,15 @@ class AppWindowPipelineMixin:
return False
self._draw_results(target_result)
self._log("Single capture completed")
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:
@@ -551,12 +605,12 @@ class AppWindowPipelineMixin:
return None
def _read_all_raw(self) -> SweepCollection | 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):
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
@@ -575,24 +629,27 @@ class AppWindowPipelineMixin:
self._single_capture_target_collection_id = collection.collection_id
return latest
def _read_all_preprocessed(self) -> None:
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
for _ in range(self._max_pop_per_poll):
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) -> ResultCollection | None:
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):
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
@@ -627,36 +684,19 @@ class AppWindowPipelineMixin:
# 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) -> None:
def _drain_rings_once_for_history(self) -> bool:
"""Perform one non-blocking read pass to extend histories."""
if self._raw_reader is not None:
self._read_all_raw()
self._read_all_preprocessed()
if self._result_reader is not None:
self._read_all_results()
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 history sizes stabilize or timeout expires."""
"""Drain rings until readers are quiet or timeout expires, even at full history capacity."""
deadline = time.monotonic() + timeout_s
stable_rounds = 0
previous = (
len(self._raw_history),
len(self._pre_history),
len(self._result_history),
)
while time.monotonic() < deadline and stable_rounds < 2:
self._drain_rings_once_for_history()
current = (
len(self._raw_history),
len(self._pre_history),
len(self._result_history),
)
if current == previous:
stable_rounds += 1
else:
stable_rounds = 0
previous = current
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)
@@ -734,4 +774,3 @@ class AppWindowPipelineMixin:
config,
self._live_processing_config(),
)
@@ -198,7 +198,7 @@ class AppWindowBscanPlotMixin:
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
"""Draw B-scan image rebuilt from processed result history."""
self._disable_phase_axis()
self._trace_phase_plot.clear()
self._sync_bscan_history_from_results()
return self._draw_bscan_heatmap_from_history()
@@ -256,8 +256,8 @@ class AppWindowBscanPlotMixin:
image_item.setRect(QRectF(x_min, depth_min, sweep_width, depth_span))
axis_mode = self._bscan_axis.currentText()
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
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()
@@ -463,53 +463,8 @@ class AppWindowBscanPlotMixin:
except ValueError:
return None
def _bscan_lookup_table(self, axis_mode: str) -> np.ndarray:
"""Return lookup table for current B-scan axis mode."""
return bscan_lookup_table(axis_mode)
@staticmethod
def _build_lut(stops: list[str], *, size: int = 256) -> np.ndarray:
"""Backward-compatible wrapper around LUT builder."""
return build_lut(stops, size=size)
@staticmethod
def _bscan_levels(sweeps: np.ndarray, axis_mode: str) -> tuple[float, float]:
"""Return display levels for B-scan image."""
return bscan_levels(sweeps, axis_mode)
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
def _ensure_phase_view_box(self) -> pg.ViewBox:
"""Create or return secondary right-axis ViewBox for phase curves."""
plot_item = self._bscan_plot.getPlotItem()
phase_view_box = self._phase_viewbox
if phase_view_box is None:
phase_view_box = pg.ViewBox()
self._phase_viewbox = phase_view_box
plot_item.scene().addItem(phase_view_box)
plot_item.getAxis("right").linkToView(phase_view_box)
phase_view_box.setXLink(plot_item.vb)
plot_item.vb.sigResized.connect(self._update_phase_view_box_geometry)
self._update_phase_view_box_geometry()
return phase_view_box
def _update_phase_view_box_geometry(self) -> None:
"""Keep right-axis ViewBox geometry in sync with main plot ViewBox."""
phase_view_box = self._phase_viewbox
if phase_view_box is None:
return
plot_item = self._bscan_plot.getPlotItem()
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
def _clear_phase_overlay(self) -> None:
"""Remove all phase curves from secondary ViewBox."""
self._trace_phase_plot.clear()
def _disable_phase_axis(self) -> None:
"""Hide right axis and clear phase overlay when phase is not rendered."""
self._clear_phase_overlay()
@@ -6,6 +6,7 @@ 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,
@@ -95,7 +96,7 @@ class AppWindowGprPlotMixin:
self._configure_gpr_plot_axes()
if self._gpr_lookup_table is None:
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
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)
@@ -179,29 +180,29 @@ class AppWindowGprPlotMixin:
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 = (
self._gpr_tx_geometry_input.toPlainText(),
self._gpr_rx_geometry_input.toPlainText(),
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
tx_entries = self._parse_gpr_tx_geometry_text(signature[0])
rx_entries = self._parse_gpr_rx_geometry_text(signature[1])
requested_input_set = set(requested_inputs)
requested_output_set = set(requested_outputs)
rx_entries = sorted(rx_entries, key=lambda entry: int(entry.input_pos))
tx_entries = sorted(tx_entries, key=lambda entry: int(entry.output_pos))
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 int(entry.input_pos) in 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 int(entry.output_pos) in requested_output_set]
tx_entries = [entry for entry in tx_entries if entry["output_pos"] in requested_output_set]
x_tx = np.asarray([float(entry.x_m) for entry in tx_entries], dtype=np.float32)
x_rx = np.asarray([float(entry.x_m) for entry in rx_entries], dtype=np.float32)
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
@@ -1,14 +1,17 @@
"""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, the volume normalized to a peak of 1:
the ground away from the radar. Payloads share normalization by the maximum
away from the Y boundaries:
- ``sar3d_front``, ``sar3d_side``, ``sar3d_top``: maximum projections onto X-Y
(looking ahead from the radar), Z-Y (from the side) and X-Z (from above);
- ``sar3d_oblique``: orthographic projection along the camera of the notebook's
3D scene; ``sar3d_oblique_basis`` (2x3) gives its image axes u and v as scene
directions, which places the scene box, the ground and the maximum on it;
- ``sar3d_peak``: [level, X, Y, Z] of the volume maximum before normalization.
- ``sar3d_peak``: [level, X, Y, Z] of that maximum before normalization.
- ``sar3d_status``: [code, UTF-8 bytes...] with ready CUDA (0), no signal (1),
processing error (2), or CUDA unavailable (3), including the explanation.
"""
from __future__ import annotations
@@ -22,7 +25,7 @@ from PyQt6.QtCore import QRectF, Qt
from python_app.gui.controllers.app_window_plot.bscan_plot_mixin import build_lut
from python_app.models.dataset_model import ResultCollection
# Colours start at this fraction of the maximum; weaker voxels stay transparent,
# 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
@@ -31,7 +34,7 @@ SAR3D_VIEWS: dict[str, tuple[str, str, str, tuple[int, int] | None]] = {
"sar3d_front": ("Front (X-Y)", "X", "Y", (0, 1)),
"sar3d_side": ("Side (Z-Y)", "Z", "Y", (2, 1)),
"sar3d_top": ("Top (X-Z)", "X", "Z", (0, 2)),
"sar3d_oblique": ("3D", "", "", None),
"sar3d_oblique": ("3D projection", "", "", None),
}
# Corners of the scene box indexed by bits (x, y, z); an edge joins corners one bit apart.
@@ -40,6 +43,22 @@ _BOX_EDGES = [(a, b) for a, b in itertools.combinations(range(8), 2) if bin(a ^
_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)
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")
def _image_rect(x_axis: np.ndarray, y_axis: np.ndarray) -> QRectF:
"""Rectangle covering the pixels centred on the axis samples."""
dx = (x_axis[-1] - x_axis[0]) / max(len(x_axis) - 1, 1)
@@ -97,7 +116,7 @@ class AppWindowSar3dPlotMixin:
oblique.addItem(label)
self._sar3d_axis_labels[axis_name] = label
def _clear_sar3d_plot(self) -> None:
def _clear_sar3d_plot(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()
@@ -105,13 +124,21 @@ class AppWindowSar3dPlotMixin:
self._sar3d_plots[name].setTitle(title)
self._sar3d_box.setData([], [])
self._sar3d_ground.setData([], [])
for label in self._sar3d_axis_labels.values():
label.hide()
self._sar3d_status_label.setText(message)
def _draw_sar3d_projections(self, collection: ResultCollection) -> bool:
"""Draw the 3D SAR views of a result collection; return success."""
"""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]}")
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()
self._clear_sar3d_plot("SAR3D: waiting for projection data")
return False
peak = self._collection_payload_by_name(collection, "sar3d_peak", kind=4)
level, *peak_xyz = peak.table[0] if peak is not None else (None,)
@@ -147,12 +174,14 @@ class AppWindowSar3dPlotMixin:
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()
(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])
if level is not None:
x, y, z = peak_xyz
self._sar3d_plots["sar3d_oblique"].setTitle(
f"3D | max X {x:+.2f}, Y {y:+.2f}, Z {z:.2f} m | level {level:.3g}"
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")
return True
@@ -21,18 +21,19 @@ class AppWindowPlotMixin(
):
"""Routes plotting to trace, B-scan, GPR, or 3D SAR-specific mixins."""
def _draw_preferred_collection(
self,
*,
result_latest: ResultCollection | None,
) -> None:
"""Draw latest available result collection if present."""
if result_latest is None:
return
self._draw_results(result_latest)
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"}:
@@ -42,11 +43,11 @@ class AppWindowPlotMixin(
return self._draw_trace_lines(collection)
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
"""Return `True` when collection contains at least one trace payload."""
if collection.collection_payloads:
"""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 and payload.trace.size > 0:
if payload.kind == 1:
return True
return False
@@ -122,6 +122,8 @@ class AppWindowPreprocessMixin:
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 self._capture_session is not None:
self._show_error(
"Cannot capture tmp reference during active capture sequence",
@@ -138,12 +140,15 @@ class AppWindowPreprocessMixin:
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)
@@ -194,7 +199,7 @@ class AppWindowPreprocessMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to capture tmp reference", exc)
finally:
if pipeline_was_paused:
if pipeline_was_paused and self._pipeline_resume_allowed(resume_seq):
self._start_run()
self._control_button_busy = False
@@ -333,6 +338,8 @@ class AppWindowPreprocessMixin:
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 self._capture_session is not None:
self._show_error("Another capture sequence is already active", details=self._capture_state_details())
return
@@ -344,9 +351,12 @@ class AppWindowPreprocessMixin:
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
@@ -398,6 +408,8 @@ class AppWindowPreprocessMixin:
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 self._capture_session is not None:
self._show_error(
"Cannot create neutral sets during active capture sequence",
@@ -412,11 +424,12 @@ class AppWindowPreprocessMixin:
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 available only for kamil_adc and librevna_multi"
"Neutral S21 sets are not supported for this radar configuration"
)
return
@@ -435,11 +448,14 @@ class AppWindowPreprocessMixin:
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_set("s21_calibration", radar_key, set_name, calibration)
self._store.save_set("s21_reference", radar_key, set_name, reference)
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
@@ -458,7 +474,7 @@ class AppWindowPreprocessMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to create neutral S21 sets", exc)
finally:
if pipeline_was_paused:
if pipeline_was_paused and self._pipeline_resume_allowed(resume_seq):
self._start_run()
def _build_single_radar_capture_session(
@@ -556,19 +572,24 @@ class AppWindowPreprocessMixin:
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 not session.is_complete():
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()
@@ -578,7 +599,7 @@ class AppWindowPreprocessMixin:
Returns False for a duplicate request (e.g. a click delivered while an
error dialog inside a capture pumps the event loop).
"""
if self._preprocess_capture_busy:
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
@@ -11,11 +11,11 @@ Key properties:
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. The writes never stall acquisition or the web UI. If
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.
* **Aligned at capture.** Each result is paired with its raw/preprocessed collection by
``collection_id`` as it arrives, so no cross-stage buffering is needed.
* **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.
@@ -29,6 +29,7 @@ from contextlib import suppress
import logging
from pathlib import Path
import queue
import shutil
import threading
import time
@@ -39,9 +40,6 @@ _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
# Safety bound on raw/preprocessed collections still waiting for their result (normally
# a handful). If the processor stalls, the oldest are dropped so the maps stay bounded.
_RECORDING_MAX_PENDING = 512
class AppWindowRecordingMixin:
@@ -54,13 +52,12 @@ class AppWindowRecordingMixin:
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_pending_raw: dict[int, object] = {}
self._recording_pending_pre: dict[int, object] = {}
self._recording_chunk_raw: list = []
self._recording_chunk_pre: list = []
self._recording_chunk_results: list = []
@@ -82,6 +79,9 @@ class AppWindowRecordingMixin:
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():
@@ -91,6 +91,7 @@ class AppWindowRecordingMixin:
)
return
writer = None
try:
writer = self._store.create_snapshot_stream(
Path(self._save_path_input.text().strip()).expanduser(),
@@ -102,6 +103,9 @@ class AppWindowRecordingMixin:
self._snapshot_config_profile_path(writer.directory), allow_overwrite=False
)
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
@@ -109,26 +113,41 @@ class AppWindowRecordingMixin:
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_pending_raw = {}
self._recording_pending_pre = {}
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()
self._recording_thread = threading.Thread(
target=self._recording_writer_loop,
args=(writer, self._recording_queue, self._recording_stop),
name="disk-recorder",
daemon=True,
)
self._recording_thread.start()
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
self._log(f"Disk recording armed: streaming the next {target} measurement(s) to {writer.directory}")
@@ -139,7 +158,7 @@ class AppWindowRecordingMixin:
) -> None:
"""Write queued chunks to disk until a sentinel, a stop request, or an error.
Runs on a daemon thread; touches only the writer (file I/O) and logging — never
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():
@@ -160,75 +179,62 @@ class AppWindowRecordingMixin:
# -- capture (called from the ring-read hooks on the poll tick) -----------
def _record_collection(self, stage: str, collection) -> None:
"""Stream one arriving collection while recording; hand chunks to the writer thread.
``stage`` is ``"raw"``, ``"preprocessed"`` or ``"results"``. Raw/preprocessed
collections are held by ``collection_id`` until their result arrives; the result
pairs them and appends an aligned measurement to the current chunk.
"""
"""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._stash_pending(self._recording_pending_raw, collection)
self._recording_chunk_raw.append(collection)
elif stage == "preprocessed":
self._stash_pending(self._recording_pending_pre, collection)
self._recording_chunk_pre.append(collection)
elif stage == "results":
self._record_result(collection)
@staticmethod
def _stash_pending(pending: dict, collection) -> None:
"""Hold a raw/preprocessed collection by id until its result arrives (bounded)."""
pending[int(collection.collection_id)] = collection
while len(pending) > _RECORDING_MAX_PENDING:
pending.pop(next(iter(pending))) # drop oldest; its result never came
def _record_result(self, result) -> None:
"""Pair a result with its raw/preprocessed, buffer it, and flush/finish as needed."""
if self._recording_collected_count() >= self._recording_target:
return # already have the full target accepted
collection_id = int(result.collection_id)
raw = self._recording_pending_raw.pop(collection_id, None)
preprocessed = self._recording_pending_pre.pop(collection_id, None)
if raw is not None:
self._recording_chunk_raw.append(raw)
if preprocessed is not None:
self._recording_chunk_pre.append(preprocessed)
self._recording_chunk_results.append(result)
if len(self._recording_chunk_results) >= _RECORDING_CHUNK_SIZE:
if not self._flush_recording_chunk():
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 not self._flush_recording_chunk():
return
self._stop_collecting()
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:
"""Hand the buffered chunk to the writer thread. Returns ``False`` if it aborted."""
if not self._recording_chunk_results:
"""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
if self._recording_write_error is not None or not self._recording_thread.is_alive():
self._abort_recording_after_write_error() # never block on a dead consumer
return False
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 = []
self._recording_queue.put(chunk) # brief backpressure only if the disk lags
return True
def _stop_collecting(self) -> None:
"""Stop accepting measurements and tell the writer to drain and exit."""
"""Queue an exit sentinel after every accepted chunk, including a full queue."""
self._recording_collecting = False
with suppress(Exception):
self._recording_queue.put_nowait(None) # sentinel after the last chunk
self._enqueue_recording_work(None)
# -- completion / teardown (GUI thread) -----------------------------------
@@ -249,35 +255,19 @@ class AppWindowRecordingMixin:
self._log(f"Disk recording complete: {written} measurement(s) written to {directory}")
def _finalize_recording_on_stop(self) -> None:
"""Flush the partial chunk and finish the recording when the run is stopped.
Pressing Stop mid-recording writes everything collected so far — including a
not-yet-full chunk — to disk and ends the recording, so no buffered measurement
is lost. The dataset then holds exactly what was seen before Stop. Called from
``_stop_run`` after its ring drains, so the last in-flight measurements are
already captured. The writer is joined briefly (bounded, like the stop drains)
so completion is deterministic without waiting on a later poll tick.
"""
if not self._recording_active or not self._recording_collecting:
"""Flush accepted data on Stop and retain the writer until its queue is drained."""
if not self._recording_active:
return
if not self._flush_recording_chunk():
return # writer already failed and was surfaced/reset
self._stop_collecting()
if self._recording_collecting:
if not self._flush_recording_chunk():
return
self._stop_collecting()
thread = self._recording_thread
directory = self._recording_writer.directory if self._recording_writer is not None else "?"
if thread is not None:
thread.join(timeout=3.0)
write_error = self._recording_write_error
written = self._recording_enqueued
self._reset_recording_state()
if write_error is not None:
self._show_error(
"Disk recording stopped with a write error",
details=f"dataset={directory}\nerror={write_error}",
)
else:
self._log(f"Disk recording stopped: {written} measurement(s) written to {directory}")
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."""
@@ -302,21 +292,17 @@ class AppWindowRecordingMixin:
self._recording_thread = None
self._recording_write_error = None
self._recording_enqueued = 0
self._recording_pending_raw = {}
self._recording_pending_pre = {}
self._recording_chunk_raw = []
self._recording_chunk_pre = []
self._recording_chunk_results = []
def _shutdown_recording(self) -> None:
"""Stop the writer thread on app teardown, briefly joining so it is not orphaned."""
thread = self._recording_thread
self._recording_stop.set()
if self._recording_queue is not None:
with suppress(Exception):
self._recording_queue.put_nowait(None)
if thread is not None and thread.is_alive():
thread.join(timeout=2.0)
"""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 ---------------------------------------------------------------
@@ -6,7 +6,8 @@ from pathlib import Path
import time
from PyQt6.QtWidgets import QFileDialog
from python_app.gui.runtime.history import record_result_history, remove_last_aligned_histories
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
@@ -39,8 +40,10 @@ class AppWindowSnapshotMixin:
return output_dir / "config_profile.json"
def _save_snapshot(self) -> None:
"""Save the last-N runtime measurements as a numpy snapshot (the "Save Dataset" button)."""
"""Save up to 1000 recent runtime measurements as a numpy snapshot."""
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())
@@ -50,7 +53,8 @@ class AppWindowSnapshotMixin:
list(self._raw_history),
list(self._pre_history),
list(self._result_history),
int(self._save_count.value()),
GUI_SAVE_HISTORY_LIMIT,
include_unaligned=True,
)
def _snapshot_destination_dir(self) -> Path:
@@ -72,10 +76,12 @@ class AppWindowSnapshotMixin:
preprocessed_history: list,
result_history: list,
last_n: int,
*,
include_unaligned: bool = False,
) -> None:
"""Save the given aligned histories as a numpy snapshot + adjacent config profile.
"""Save the given histories as a numpy snapshot + adjacent config profile.
Shared by the manual "Save Dataset" button (which passes the runtime history)
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.
"""
@@ -90,6 +96,7 @@ class AppWindowSnapshotMixin:
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:
@@ -124,13 +131,15 @@ class AppWindowSnapshotMixin:
def _save_vna_history_json(self) -> None:
"""Save one VNA-history JSON per available combo in runtime history."""
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 = int(self._save_count.value())
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()
@@ -144,6 +153,7 @@ class AppWindowSnapshotMixin:
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:
@@ -210,6 +220,7 @@ class AppWindowSnapshotMixin:
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
@@ -220,6 +231,8 @@ class AppWindowSnapshotMixin:
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
@@ -260,7 +273,7 @@ class AppWindowSnapshotMixin:
f"results={dropped_results}"
)
if resume_acquisition:
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)
@@ -280,64 +293,28 @@ class AppWindowSnapshotMixin:
if self._raw_reader is None and self._pre_reader is None and self._result_reader is None:
return
try:
start_raw_count = len(self._raw_history)
start_pre_count = len(self._pre_history)
start_result_count = len(self._result_history)
readers = (self._raw_reader, self._pre_reader, self._result_reader)
observed = [False, False, False]
deadline = time.monotonic() + 1.2
while True:
progress = False
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 self._raw_reader is not None:
for _ in range(self._max_pop_per_snapshot_drain):
collection = self._raw_reader.pop_raw_collection()
if collection is None:
break
self._raw_history.append(collection)
progress = True
if self._pre_reader is not None:
for _ in range(self._max_pop_per_snapshot_drain):
collection = self._pre_reader.pop_preprocessed_collection()
if collection is None:
break
self._pre_history.append(collection)
progress = True
if self._result_reader is not None:
for _ in range(self._max_pop_per_snapshot_drain):
collection = self._result_reader.pop_result_collection()
if collection is None:
break
record_result_history(self._result_history, collection)
progress = True
if progress:
if any(collection is not None for collection in latest):
continue
raw_count = len(self._raw_history)
pre_count = len(self._pre_history)
result_count = len(self._result_history)
missing_raw = self._raw_reader is not None and raw_count == start_raw_count
missing_pre = self._pre_reader is not None and pre_count == start_pre_count
missing_results = self._result_reader is not None and result_count == start_result_count
got_results = result_count > start_result_count
got_raw_or_pre = raw_count > start_raw_count or pre_count > start_pre_count
# Event-loop-friendly waits so the keepalive/watchdog timers and
# queued signals keep firing instead of freezing the headless daemon.
if got_results and (missing_raw or missing_pre) and time.monotonic() < deadline:
self._pump_events_during_drain(0.01)
continue
if got_raw_or_pre and missing_results and time.monotonic() < deadline:
self._pump_events_during_drain(0.01)
continue
if (
self._result_reader is not None
and max(raw_count, pre_count) > result_count
and time.monotonic() < deadline
):
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
@@ -10,6 +10,7 @@ Layout is intentionally split into two independent plot surfaces:
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QComboBox,
QFrame,
@@ -145,13 +146,22 @@ class AppWindowUiMixin:
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 = QGridLayout(self._sar3d_plots_container)
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")
layout.addWidget(self._sar3d_plots[name], index // 2, index % 2)
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)
@@ -44,17 +44,42 @@ class _WebActionCall:
a request/response so failures reach the browser.
"""
__slots__ = ("done", "error")
__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/stable fields plus the
# 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()
@@ -89,8 +114,7 @@ class AppWindowWebController(QObject):
"""
# Control signals carry a trailing _WebActionCall the GUI slot fills in, so the web
# thread can block on the real outcome. apply_settings stays fire-and-forget: it is
# validated up front and returns the live schema, not a pass/fail.
# 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)
@@ -99,7 +123,9 @@ class AppWindowWebController(QObject):
start_recording_requested = pyqtSignal(str, str, int, object)
load_config_requested = pyqtSignal(str, object)
save_dataset_requested = pyqtSignal(str, str, object)
apply_settings_requested = pyqtSignal(dict)
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)
@@ -107,10 +133,13 @@ class AppWindowWebController(QObject):
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: dict, frame: dict | None) -> None:
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
@@ -143,11 +172,28 @@ class AppWindowWebController(QObject):
action surfaced an error or did not finish within the timeout.
"""
call = _WebActionCall()
signal.emit(*args, call)
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
raise WebActionError("The desktop did not complete the action in time")
if call.error is not None:
raise WebActionError(call.error)
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)
@@ -166,13 +212,15 @@ class AppWindowWebController(QObject):
def start_recording(self, path: str, name: str, count: int) -> None:
"""Arm a run + disk recording of the next ``count`` measurements (the desktop button)."""
self._dispatch(self.start_recording_requested, path, name, int(count))
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) -> dict:
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.apply_settings_requested.emit(dict(fields))
self._dispatch(self.apply_settings_requested, dict(fields))
return self.current_live_settings()
def load_config(self, name: str) -> None:
@@ -187,9 +235,19 @@ class AppWindowWebController(QObject):
self._dispatch(self.load_config_requested, name)
def save_dataset(self, path: str, name: str) -> None:
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
"""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."""
@@ -245,7 +303,15 @@ class AppWindowWebMixin:
controller.save_dataset_requested.connect(
lambda path, name, call: self._run_web_action(call, self._save_web_dataset, path, name)
)
controller.apply_settings_requested.connect(self._apply_web_live_settings)
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
@@ -268,17 +334,24 @@ class AppWindowWebMixin:
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)
call.error = capture[0] if capture else None
error = capture[0] if capture else None
self._web_update_snapshot()
except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop
call.error = self._exception_summary(exc)
error = self._exception_summary(exc)
finally:
self._web_action_error_capture = previous_capture
call.done.set()
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.
@@ -289,12 +362,11 @@ class AppWindowWebMixin:
"""
config_path = _safe_run_config_path(self._run_configs_dir, name)
if config_path is None:
self._log_warning(f"Ignored web request to load unknown run config: {name}")
return
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 dataset from the browser via the same handler as the desktop button.
"""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
@@ -313,11 +385,15 @@ class AppWindowWebMixin:
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)
if count >= 1:
self._record_count.setValue(min(count, self._record_count.maximum()))
self._record_count.setValue(count)
self._start_run_with_recording()
def _web_update_snapshot(self) -> None:
@@ -349,6 +425,16 @@ class AppWindowWebMixin:
"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(),
@@ -377,8 +463,10 @@ class AppWindowWebMixin:
}
def _grab_plot_png_b64(self) -> str | None:
"""Render the currently-visible plot page to a base64 PNG (exactly as shown)."""
"""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()
@@ -391,6 +479,9 @@ class AppWindowWebMixin:
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):
@@ -22,7 +22,8 @@ def build_data_actions_group(owner) -> QGroupBox:
layout.setSpacing(8)
data_defaults = owner._gui_defaults.data_actions
save_button = QPushButton("Save Dataset")
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")
@@ -43,10 +44,6 @@ def build_data_actions_group(owner) -> QGroupBox:
)
record_button.clicked.connect(owner._start_run_with_recording)
record_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
owner._save_count = QSpinBox()
owner._save_count.setMinimum(1)
owner._save_count.setMaximum(10_000)
owner._save_count.setValue(int(data_defaults.save_count))
owner._record_count = QSpinBox()
owner._record_count.setMinimum(1)
owner._record_count.setMaximum(1_000_000)
@@ -64,13 +61,6 @@ def build_data_actions_group(owner) -> QGroupBox:
button_grid.setColumnStretch(1, 1)
layout.addLayout(button_grid)
count_row = QHBoxLayout()
count_row.setSpacing(8)
count_row.addWidget(QLabel("Number of Measurements to Save"))
count_row.addWidget(owner._save_count)
count_row.addStretch(1)
layout.addLayout(count_row)
record_count_row = QHBoxLayout()
record_count_row.setSpacing(8)
record_count_row.addWidget(QLabel("Number of Sweeps to Record"))
@@ -8,9 +8,7 @@ from PyQt6.QtWidgets import (
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QLabel,
QLineEdit,
QPlainTextEdit,
QSizePolicy,
QSpinBox,
QStackedWidget,
@@ -20,6 +18,7 @@ from PyQt6.QtWidgets import (
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
# Same semantics on every GPR-like page; the data_processor applies them.
@@ -30,31 +29,6 @@ _IGNORE_SOCKET_SPEED_TOOLTIP = (
)
def _format_geometry_row(position: int, x_m: float, y_m: float, z_m: float) -> str:
"""Render one geometry row, trimming trailing zero y/z so 1D layouts stay compact."""
if z_m != 0.0:
return f"{position} {x_m:g} {y_m:g} {z_m:g}"
if y_m != 0.0:
return f"{position} {x_m:g} {y_m:g}"
return f"{position} {x_m:g}"
def _format_tx_geometry(owner) -> str:
"""Render Tx geometry defaults into editable line-based text."""
return "\n".join(
_format_geometry_row(int(entry.output_pos), float(entry.x_m), float(entry.y_m), float(entry.z_m))
for entry in owner._defaults_config.gpr.tx_geometry
)
def _format_rx_geometry(owner) -> str:
"""Render Rx geometry defaults into editable line-based text."""
return "\n".join(
_format_geometry_row(int(entry.input_pos), float(entry.x_m), float(entry.y_m), float(entry.z_m))
for entry in owner._defaults_config.gpr.rx_geometry
)
def _build_processing_mode_page(parent: QStackedWidget, rows: list[FormRow], *, split_index: int | None = None) -> QWidget:
"""Create one processing-mode page with a two-column form body."""
page = QWidget(parent)
@@ -199,21 +173,19 @@ def build_processing_group(owner) -> QGroupBox:
# Medium permittivity is a legacy-GPR-only knob (shown on the legacy page below).
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so it is not
# offered there. Applies on the next pipeline start (a run_config field).
# offered there.
owner._gpr_relative_permittivity = QDoubleSpinBox()
owner._gpr_relative_permittivity.setDecimals(4)
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
owner._gpr_relative_permittivity.setSingleStep(0.05)
owner._gpr_relative_permittivity.setToolTip("Applied on the next pipeline start (Save Config and restart).")
owner._gpr_relative_permittivity.setValue(float(gpr_defaults.relative_permittivity))
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m [y_m] [z_m]")
owner._gpr_tx_geometry_input.setFixedHeight(78)
owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner))
owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m [y_m] [z_m]")
owner._gpr_rx_geometry_input.setFixedHeight(78)
owner._gpr_tx_geometry_input = AntennaGeometryEditor(
"output_pos", gpr_defaults.tx_geometry, position_count=owner._defaults_config.output_switch.positions,
)
owner._gpr_rx_geometry_input = AntennaGeometryEditor(
"input_pos", gpr_defaults.rx_geometry, position_count=owner._defaults_config.input_switch.positions,
)
owner._gpr_common_page = _build_processing_mode_page(
group,
@@ -224,9 +196,9 @@ def build_processing_group(owner) -> QGroupBox:
split_index=1,
)
owner._gpr_geometry_hint = QLabel("To apply Tx/Rx geometry changes: Save Config and restart the app")
owner._gpr_geometry_hint.setWordWrap(True)
owner._gpr_common_page.layout().addWidget(owner._gpr_geometry_hint)
owner._gpr_tx_geometry_input.changed.connect(owner._on_gpr_geometry_changed)
owner._gpr_rx_geometry_input.changed.connect(owner._on_gpr_geometry_changed)
owner._gpr_relative_permittivity.valueChanged.connect(owner._on_gpr_geometry_changed)
owner._gpr_input_positions_input = QLineEdit(str(gpr_live_defaults.input_positions))
owner._gpr_input_positions_input.setPlaceholderText("0,1,2")
@@ -45,6 +45,8 @@ def build_switch_group(owner) -> QGroupBox:
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_row = QHBoxLayout()
single_row.setSpacing(8)
@@ -54,6 +56,7 @@ def build_switch_group(owner) -> QGroupBox:
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)
layout.addLayout(single_row)
owner._combo_select_group = QButtonGroup(group)
@@ -63,6 +66,7 @@ def build_switch_group(owner) -> QGroupBox:
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)