Refactor radar system
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user