improved logging
This commit is contained in:
+137
-55
@@ -12,13 +12,12 @@ from datetime import datetime
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||
|
||||
@@ -40,12 +39,56 @@ from python_app.orchestration.pipeline_metrics import PipelineMetrics
|
||||
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_model
|
||||
from python_app.orchestration.process_supervisor import ProcessSupervisor
|
||||
from python_app.orchestration.shm_reader import ShmRingReader
|
||||
from python_app.logging_setup import (
|
||||
DEFAULT_LOG_LEVEL,
|
||||
add_handler,
|
||||
configure_logging,
|
||||
get_logger,
|
||||
set_log_level,
|
||||
)
|
||||
from python_app.storage.npz_store import NpzStore
|
||||
from python_app.workflows.multi_radar_capture_workflow import MultiRadarSequentialCaptureSession
|
||||
from python_app.workflows.radar_config_variants import RadarConfigScanSummary, RadarConfigVariant
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureSession
|
||||
|
||||
|
||||
def _panel_extra(details: str | None, once_key: str | None) -> dict[str, object]:
|
||||
"""Carry GUI-panel-only fields (details block, once-key dedup) on a log record."""
|
||||
return {"panel_details": details, "panel_once_key": once_key}
|
||||
|
||||
|
||||
class _PanelLogBridge(QObject):
|
||||
"""Marshals log records from any thread onto the GUI thread for panel rendering.
|
||||
|
||||
A :class:`logging.Handler` can fire on a worker thread (readers, broadcaster),
|
||||
but the log widget may only be touched on the GUI thread; emitting this queued
|
||||
signal hands the record across safely (the GPIO-button pattern).
|
||||
"""
|
||||
|
||||
record = pyqtSignal(str, str, object, object) # display level, message, details, once_key
|
||||
|
||||
|
||||
class _QtLogPanelHandler(logging.Handler):
|
||||
"""Logging handler that forwards application log records to the GUI log panel."""
|
||||
|
||||
def __init__(self, bridge: _PanelLogBridge) -> None:
|
||||
super().__init__()
|
||||
self._bridge = bridge
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Forward one record to the panel bridge, mapping WARNING to the short 'WARN'."""
|
||||
try:
|
||||
display_level = "WARN" if record.levelname == "WARNING" else record.levelname
|
||||
self._bridge.record.emit(
|
||||
display_level,
|
||||
record.getMessage(),
|
||||
getattr(record, "panel_details", None),
|
||||
getattr(record, "panel_once_key", None),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - logging must never raise into the caller
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
class AppWindow(
|
||||
AppWindowUiMixin,
|
||||
AppWindowConfigMixin,
|
||||
@@ -64,7 +107,7 @@ class AppWindow(
|
||||
super().__init__()
|
||||
|
||||
self._init_paths(project_root)
|
||||
self._init_headless_logger()
|
||||
self._init_logging()
|
||||
self._init_runtime_services()
|
||||
self._init_config_profile_state()
|
||||
self._init_reader_handles()
|
||||
@@ -85,39 +128,35 @@ class AppWindow(
|
||||
# Guards closeEvent against re-entrant teardown (e.g. a second signal).
|
||||
self._closing = False
|
||||
|
||||
def _init_headless_logger(self) -> None:
|
||||
"""Create a Python logger so headless WARN/ERROR reach journald and disk.
|
||||
def _init_logging(self) -> None:
|
||||
"""Configure the application logger and the bridge that feeds the GUI panel.
|
||||
|
||||
In headless mode the in-app log only reaches an offscreen widget, so an
|
||||
operator (or `journalctl`) would never see failures. We attach a stderr
|
||||
StreamHandler (captured by journald) plus a small rotating file under
|
||||
`runtime/logs`; in GUI mode no handler is attached and the logger stays
|
||||
inert, preserving the visible log widget as the sole sink.
|
||||
Installs the rotating-file (``runtime/logs/radar.log``) and stderr handlers on
|
||||
the ``python_app`` logger so every module's logs — and the GUI's own ``_log*``
|
||||
calls — share one level-controlled, rotated pipeline. The verbosity floor starts
|
||||
at the default and is replaced with the configured level once run_config loads;
|
||||
the GUI panel is wired in once its widget exists (see ``_attach_log_panel``).
|
||||
"""
|
||||
self._headless_logger: logging.Logger | None = None
|
||||
if not self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
log_dir = self._project_root / "python_app/runtime/logs"
|
||||
configure_logging(level=DEFAULT_LOG_LEVEL, log_dir=log_dir, console=True)
|
||||
self._gui_logger = get_logger("gui")
|
||||
self._log_panel_bridge = _PanelLogBridge()
|
||||
self._log_panel_bridge.record.connect(self._on_log_record)
|
||||
|
||||
def _attach_log_panel(self) -> None:
|
||||
"""Route application log records into the on-screen panel (widget now exists)."""
|
||||
add_handler(_QtLogPanelHandler(self._log_panel_bridge))
|
||||
|
||||
def _on_log_record(self, level: str, text: str, details: object, once_key: object) -> None:
|
||||
"""Render one forwarded log record in the panel (always on the GUI thread)."""
|
||||
if not hasattr(self, "_log_box"):
|
||||
return
|
||||
logger = logging.getLogger("radar_system.gui")
|
||||
logger.setLevel(logging.WARNING)
|
||||
logger.propagate = False
|
||||
logger.handlers.clear()
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)-5s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
self._append_log_entry(
|
||||
level,
|
||||
text,
|
||||
details=details if isinstance(details, str) else None,
|
||||
once_key=once_key if isinstance(once_key, str) else None,
|
||||
)
|
||||
stream_handler = logging.StreamHandler(stream=sys.stderr)
|
||||
stream_handler.setFormatter(formatter)
|
||||
logger.addHandler(stream_handler)
|
||||
# A rotating file keeps recent failures around after a journald restart.
|
||||
with suppress(Exception):
|
||||
log_dir = self._project_root / "python_app/runtime/logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_dir / "gui.log", maxBytes=1_000_000, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
self._headless_logger = logger
|
||||
|
||||
def _init_runtime_services(self) -> None:
|
||||
"""Initialize long-lived service objects used by mixins."""
|
||||
@@ -155,7 +194,12 @@ class AppWindow(
|
||||
return 50
|
||||
|
||||
def _init_config_profile_state(self) -> None:
|
||||
"""Resolve startup profile path, load active profile, and queue fallback notices."""
|
||||
"""Resolve the startup profile path, load the active profile, and queue any
|
||||
fallback notices for replay once the log panel exists.
|
||||
|
||||
On failure to load a non-root profile, falls back to the root run_config.json;
|
||||
a failure to load the root profile itself is fatal and re-raised.
|
||||
"""
|
||||
active_profile_path = self._resolve_startup_profile_path()
|
||||
try:
|
||||
profile = GuiProfileModel.load_from_path(active_profile_path)
|
||||
@@ -172,6 +216,7 @@ class AppWindow(
|
||||
|
||||
self._active_profile_path = active_profile_path
|
||||
self._defaults_config = profile.run_config.clone()
|
||||
set_log_level(self._defaults_config.logging.level)
|
||||
if profile.gui is not None:
|
||||
self._gui_defaults = profile.gui
|
||||
else:
|
||||
@@ -277,8 +322,13 @@ class AppWindow(
|
||||
self._timer.timeout.connect(self._poll_rings)
|
||||
|
||||
def _bootstrap_ui_runtime(self) -> None:
|
||||
"""Build UI and apply initial runtime-bound state after widgets exist."""
|
||||
"""Build the UI and apply initial runtime-bound state once widgets exist.
|
||||
|
||||
This is the first point at which the log panel is wired in, so it also
|
||||
replays any startup entries buffered during the headless init phase.
|
||||
"""
|
||||
self._build_ui()
|
||||
self._attach_log_panel()
|
||||
self._flush_pending_startup_log_entries()
|
||||
self._log(f"Active config profile: {self._active_profile_path}")
|
||||
self._refresh_preprocess_summary_labels()
|
||||
@@ -293,6 +343,10 @@ class AppWindow(
|
||||
self._init_web_ui()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self._install_headless_watchdog()
|
||||
self._log_debug(
|
||||
f"Window bootstrap complete (mode={self._active_processing_mode}, "
|
||||
f"ring poll interval={self._timer.interval()}ms)."
|
||||
)
|
||||
|
||||
def _resolve_startup_profile_path(self) -> Path:
|
||||
"""Resolve active profile path from session-state or root fallback path."""
|
||||
@@ -373,6 +427,7 @@ class AppWindow(
|
||||
self._headless_watchdog.setInterval(2000)
|
||||
self._headless_watchdog.timeout.connect(self._headless_watchdog_tick)
|
||||
self._headless_watchdog.start()
|
||||
self._log_debug("Headless watchdog armed (interval=2000ms).")
|
||||
|
||||
def _headless_watchdog_tick(self) -> None:
|
||||
"""Escalate any unexpected managed-process exit to a fatal headless restart."""
|
||||
@@ -410,7 +465,11 @@ class AppWindow(
|
||||
return path.expanduser().resolve(strict=False)
|
||||
|
||||
def _remember_active_profile_path(self, path: Path, *, startup: bool = False) -> None:
|
||||
"""Persist last successfully used config profile path."""
|
||||
"""Persist the last successfully used config profile path to GUI session-state.
|
||||
|
||||
A write failure is non-fatal: it is queued during startup or logged as a
|
||||
warning afterwards, since it only affects which profile reopens next launch.
|
||||
"""
|
||||
normalized_path = self._normalize_profile_path(path)
|
||||
self._active_profile_path = normalized_path
|
||||
try:
|
||||
@@ -434,19 +493,29 @@ class AppWindow(
|
||||
self._pending_startup_log_entries.append((level.upper(), text, details))
|
||||
|
||||
def _flush_pending_startup_log_entries(self) -> None:
|
||||
"""Flush startup log entries into the runtime log box after UI creation."""
|
||||
if not self._pending_startup_log_entries:
|
||||
return
|
||||
"""Replay queued startup entries through the logger now that every sink exists.
|
||||
|
||||
Entries logged before the panel widget existed were buffered; routing them
|
||||
through the logger here delivers them to the file/console and the panel at once.
|
||||
"""
|
||||
levels = {"WARN": logging.WARNING, "ERROR": logging.ERROR}
|
||||
for level, text, details in self._pending_startup_log_entries:
|
||||
self._append_log_entry(level, text, details=details)
|
||||
self._gui_logger.log(levels.get(level, logging.INFO), text, extra=_panel_extra(details, None))
|
||||
self._pending_startup_log_entries.clear()
|
||||
|
||||
def _apply_initial_radar_limits(self) -> None:
|
||||
"""Apply startup radar-limits strategy according to selected radar mode."""
|
||||
"""Apply the startup radar-limits strategy according to the selected radar mode.
|
||||
|
||||
In ``native`` mode the limits are queried from the connected device; otherwise
|
||||
the UI is populated with no device-imposed limits.
|
||||
"""
|
||||
if self._defaults_config.radar.driver_mode == "native":
|
||||
self._log_debug("Querying radar limits from device (native driver mode).")
|
||||
self._refresh_radar_limits_from_device()
|
||||
return
|
||||
self._log_debug(
|
||||
f"Skipping device radar-limit query (driver mode={self._defaults_config.radar.driver_mode})."
|
||||
)
|
||||
self._apply_radar_limits_to_ui(None)
|
||||
|
||||
@staticmethod
|
||||
@@ -483,6 +552,7 @@ class AppWindow(
|
||||
|
||||
level_upper = level.upper()
|
||||
palette = {
|
||||
"DEBUG": ("#6c7b8d", "#52627a", "#8a97a8"),
|
||||
"INFO": ("#1d5fbf", "#1f2937", "#526277"),
|
||||
"WARN": ("#9a5b00", "#5c4300", "#7a6640"),
|
||||
"ERROR": ("#c43d4d", "#6b1f2a", "#8b5d66"),
|
||||
@@ -513,26 +583,32 @@ class AppWindow(
|
||||
if level_upper == "ERROR" and hasattr(self, "_status_label"):
|
||||
self._status_label.setText("Status: error")
|
||||
|
||||
# In headless mode the offscreen widget above is invisible, so also mirror
|
||||
# WARN/ERROR to the Python logger (stderr -> journald, plus rotating file)
|
||||
# where an operator can actually observe failures.
|
||||
headless_logger = getattr(self, "_headless_logger", None)
|
||||
if headless_logger is not None and level_upper in {"WARN", "ERROR"}:
|
||||
log_message = text if not details else f"{text}\n{details}"
|
||||
log_level = logging.ERROR if level_upper == "ERROR" else logging.WARNING
|
||||
headless_logger.log(log_level, log_message)
|
||||
def _on_log_level_selected(self, level_text: str) -> None:
|
||||
"""Apply the chosen log level immediately (sub-level logs stop being generated).
|
||||
|
||||
The value lives in run_config like any other field: it is recorded in the active
|
||||
config here and saved with the config through the normal path — no separate write.
|
||||
"""
|
||||
level = level_text.strip().lower()
|
||||
set_log_level(level)
|
||||
self._defaults_config.logging.level = level
|
||||
self._log(f"Log level set to {level.upper()}.")
|
||||
|
||||
def _log_debug(self, text: str, *, once_key: str | None = None) -> None:
|
||||
"""Log a diagnostic message (emitted only while the level is DEBUG)."""
|
||||
self._gui_logger.debug(text, extra=_panel_extra(None, once_key))
|
||||
|
||||
def _log(self, text: str, *, once_key: str | None = None) -> None:
|
||||
"""Append informational message to runtime log panel."""
|
||||
self._append_log_entry("INFO", text, once_key=once_key)
|
||||
"""Log an informational message to the panel, file, and console."""
|
||||
self._gui_logger.info(text, extra=_panel_extra(None, once_key))
|
||||
|
||||
def _log_warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
"""Append warning message to runtime log panel."""
|
||||
self._append_log_entry("WARN", text, details=details, once_key=once_key)
|
||||
"""Log a warning to the panel, file, and console."""
|
||||
self._gui_logger.warning(text, extra=_panel_extra(details, once_key))
|
||||
|
||||
def _log_error(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
|
||||
"""Append error message to runtime log panel."""
|
||||
self._append_log_entry("ERROR", text, details=details, once_key=once_key)
|
||||
"""Log an error to the panel, file, and console."""
|
||||
self._gui_logger.error(text, extra=_panel_extra(details, once_key))
|
||||
|
||||
def _log_exception(self, context: str, exc: Exception, *, level: str = "ERROR") -> tuple[str, str]:
|
||||
"""Log exception with detailed traceback and return `(message, details)`."""
|
||||
@@ -624,13 +700,18 @@ class AppWindow(
|
||||
dialog.exec()
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
"""Tear down workers, readers, and dialogs before the window is destroyed.
|
||||
|
||||
Guarded against re-entrancy so a second close signal (or a ``window.close()``
|
||||
after the event loop has already returned) does not run teardown twice.
|
||||
"""
|
||||
if self._closing:
|
||||
# Re-entrant close (second signal, or window.close() after the event loop
|
||||
# already returned): teardown is in progress or done — do nothing more.
|
||||
super().closeEvent(event)
|
||||
return
|
||||
self._closing = True
|
||||
self._log("Window closing; shutting down runtime.")
|
||||
try:
|
||||
# 0) Stop the web server first so a late request cannot start work.
|
||||
self._shutdown_web_ui()
|
||||
@@ -645,4 +726,5 @@ class AppWindow(
|
||||
if self._preprocess_dialog is not None:
|
||||
self._preprocess_dialog.close()
|
||||
finally:
|
||||
self._log_debug("Window teardown finished.")
|
||||
super().closeEvent(event)
|
||||
|
||||
@@ -256,6 +256,12 @@ class AppWindowLiveProcessingMixin:
|
||||
for name, value in fields.items()
|
||||
if name not in {"history_command", "history_command_seq"}
|
||||
}
|
||||
# Log only field names (not values) so remote edits are traceable
|
||||
# without recording arbitrary client-supplied payloads.
|
||||
self._log_debug(
|
||||
f"Applying web live settings: fields={sorted(settings)}, "
|
||||
f"history_command={history_command}."
|
||||
)
|
||||
self._suppress_live_settings_handler = True
|
||||
try:
|
||||
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
|
||||
|
||||
@@ -218,7 +218,16 @@ class AppWindowConfigProfileIOMixin:
|
||||
self._show_exception("Failed to load config profile", exc)
|
||||
|
||||
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
|
||||
"""Apply already parsed profile to GUI state without restarting the pipeline."""
|
||||
"""Apply an already parsed profile to GUI state without restarting the pipeline.
|
||||
|
||||
Repopulates every radar/processing/preprocess widget under signal blockers,
|
||||
resizes history buffers, and refreshes derived state. The pipeline is left
|
||||
untouched; callers handle user-facing logging and error reporting.
|
||||
"""
|
||||
self._log_debug(
|
||||
f"Applying loaded profile: path={profile_path}, "
|
||||
f"has_gui_state={profile.gui is not None}."
|
||||
)
|
||||
config = profile.run_config.clone()
|
||||
gui_state = profile.gui if profile.gui is not None else self._default_gui_state_for_config(config)
|
||||
self._defaults_config = config
|
||||
|
||||
@@ -422,6 +422,7 @@ class AppWindowConfigStateBuildersMixin:
|
||||
|
||||
config.runtime.settling_ms = int(self._settling_ms.text().strip())
|
||||
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
|
||||
config.logging.level = self._log_level_combo.currentText().strip().lower()
|
||||
|
||||
if config.is_matrix_radar:
|
||||
if config.is_multi_device and len(config.radar.multi_device.slave_serials) != 2:
|
||||
|
||||
@@ -304,16 +304,22 @@ class AppWindowPipelineMixin:
|
||||
self._log("All pipeline processes stopped")
|
||||
|
||||
def _close_readers(self, *, keep_results: bool = False) -> None:
|
||||
"""Close active ring readers."""
|
||||
"""Close active ring readers; keep the results reader when ``keep_results``."""
|
||||
closed = []
|
||||
if self._raw_reader is not None:
|
||||
self._raw_reader.close()
|
||||
self._raw_reader = None
|
||||
closed.append("raw")
|
||||
if self._pre_reader is not None:
|
||||
self._pre_reader.close()
|
||||
self._pre_reader = None
|
||||
closed.append("preprocessed")
|
||||
if not keep_results and self._result_reader is not None:
|
||||
self._result_reader.close()
|
||||
self._result_reader = None
|
||||
closed.append("results")
|
||||
if closed:
|
||||
self._log_debug(f"Closed ring readers: {', '.join(closed)}.")
|
||||
|
||||
def _poll_rings(self) -> None:
|
||||
"""Poll readers, ingest history, and trigger rendering."""
|
||||
|
||||
@@ -58,7 +58,13 @@ class AppWindowPreprocessMixin:
|
||||
"""Clear selected preprocess sets when radar-key-defining settings change."""
|
||||
try:
|
||||
radar_key = self._radar_key_from_ui()
|
||||
except Exception:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Radar fields can be mid-edit (empty/partial) while signals fire; the
|
||||
# key cannot be computed yet, so skip until the inputs are valid again.
|
||||
self._log_debug(
|
||||
f"Skipping preprocess-selection reset; radar key unavailable: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
previous_radar_key = getattr(self, "_selected_preprocess_radar_key", radar_key)
|
||||
@@ -778,8 +784,11 @@ class AppWindowPreprocessMixin:
|
||||
)
|
||||
|
||||
def _cleanup_capture_session(self) -> None:
|
||||
"""Close and clear current capture session object."""
|
||||
"""Close and clear the current capture session object."""
|
||||
if self._capture_session is not None:
|
||||
self._log_debug(
|
||||
f"Closing capture session: kind={self._capture_session.kind}."
|
||||
)
|
||||
self._capture_session.close()
|
||||
self._capture_session = None
|
||||
self._update_capture_dialog_state()
|
||||
|
||||
@@ -33,6 +33,7 @@ from python_app.gui.controllers.sections import (
|
||||
build_radar_group,
|
||||
build_switch_group,
|
||||
)
|
||||
from python_app.logging_setup import LOG_LEVELS
|
||||
|
||||
|
||||
class AppWindowUiMixin:
|
||||
@@ -178,6 +179,20 @@ class AppWindowUiMixin:
|
||||
self._log_toggle_button.setObjectName("sectionToggleButton")
|
||||
self._log_toggle_button.clicked.connect(lambda: self._toggle_log_panel())
|
||||
|
||||
# Log-level selector: live verbosity control, persisted to run_config.
|
||||
self._log_level_label = QLabel("Level", self._settings_panel)
|
||||
self._log_level_label.setObjectName("hintLabel")
|
||||
self._log_level_combo = QComboBox(self._settings_panel)
|
||||
self._log_level_combo.setObjectName("logLevelCombo")
|
||||
self._log_level_combo.addItems([name.capitalize() for name in LOG_LEVELS])
|
||||
self._log_level_combo.setToolTip("Logging verbosity — applied live and saved to run_config")
|
||||
current_level = self._defaults_config.logging.level.capitalize()
|
||||
current_index = self._log_level_combo.findText(current_level)
|
||||
if current_index >= 0:
|
||||
self._log_level_combo.setCurrentIndex(current_index)
|
||||
# Connect AFTER seeding the value so reflecting the config does not save it back.
|
||||
self._log_level_combo.currentTextChanged.connect(self._on_log_level_selected)
|
||||
|
||||
self._log_box = QTextEdit(self._settings_panel)
|
||||
self._log_box.setObjectName("runtimeLogBox")
|
||||
self._log_box.setReadOnly(True)
|
||||
@@ -196,6 +211,8 @@ class AppWindowUiMixin:
|
||||
header_layout.setSpacing(8)
|
||||
header_layout.addWidget(self._log_panel_title)
|
||||
header_layout.addStretch(1)
|
||||
header_layout.addWidget(self._log_level_label)
|
||||
header_layout.addWidget(self._log_level_combo)
|
||||
header_layout.addWidget(self._log_toggle_button)
|
||||
|
||||
panel_layout.addWidget(header_row)
|
||||
|
||||
@@ -198,6 +198,7 @@ class AppWindowWebMixin:
|
||||
with contextlib.suppress(Exception):
|
||||
server.stop()
|
||||
self._web_server = None
|
||||
self._log("Web UI stopped.")
|
||||
self._web_controller = None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -462,7 +462,12 @@ class PreprocessDialog(QDialog):
|
||||
)
|
||||
|
||||
def _ensure_preview_plots(self) -> bool:
|
||||
"""Create the amplitude+phase plot pair lazily on first successful capture."""
|
||||
"""Lazily create the amplitude+phase plot pair and report whether it exists.
|
||||
|
||||
Returns True once both plots are available. If construction fails (e.g. an
|
||||
incompatible PyQtGraph/PyQt6 build), marks the preview permanently
|
||||
unavailable so later captures fall back to the placeholder without retrying.
|
||||
"""
|
||||
if self._amplitude_plot is not None and self._phase_plot is not None:
|
||||
return True
|
||||
if self._preview_plot_unavailable:
|
||||
@@ -474,6 +479,8 @@ class PreprocessDialog(QDialog):
|
||||
amplitude_plot = self._build_preview_axis("Magnitude", "dB")
|
||||
phase_plot = self._build_preview_axis("Phase", "deg")
|
||||
except Exception:
|
||||
# Some PyQtGraph/PyQt6 combinations cannot build a PlotWidget here;
|
||||
# latch the failure so we show the text placeholder instead of retrying.
|
||||
self._preview_plot_unavailable = True
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user