"""Central plot panel using Matplotlib embedded in Qt.
Displays time-domain asymmetry with error bars and optional fit overlay,
similar to WiMDA's main plot area.
The bunch-factor control rebins the displayed data and also defines the
dataset passed to fitting in the GUI. The original MuonDataset is preserved,
so changing the bunch factor after fitting only changes the plotted data while
the existing fit curve remains overlaid.
Navigation map
--------------
~7.3k lines, a single ``PlotPanel(QWidget)`` class; a known decomposition
target (see ``docs/audit/shared-foundations/FOLLOW-UPS.md``), not split in
this pass. Methods are not grouped by section comments in the source; they
cluster thematically in roughly this order:
- **Construction and control widgets** — ``__init__``, ``_create_limit_controls``,
``_create_plot_header``/``_create_plot_footer`` build the axis-limit fields
(``FloatLimitField``/``AxisLimitControls`` from ``gui/widgets/axis_limits.py``)
and the canvas chrome.
- **Frequency-axis unit/reference conversion** — the ``_convert_frequency_*``
and ``_display_x_*``/``_display_y_*`` helpers translate between canonical
MHz storage and the user-selected display unit/reference.
- **Navigation mode and axis-limit sync** — ``_set_navigation_mode``,
``_connect_axis_limit_callbacks``, ``_sync_limits_from_axes``,
``_on_axis_limits_changed``: keeps the Matplotlib toolbar pan/zoom state,
the limit spin fields, and the stored view limits consistent.
- **Dataset/projection plumbing** — ``get_analysis_dataset``, projection
axis helpers (``_axis_key_for_dataset``, ``set_projections``,
``fit_target_projection``), and the low-count/low-confidence masking used
before plotting (``_low_count_mask_for_dataset``, ``_low_confidence_mask_for_dataset``).
- **Rendering** — the ``plot_*`` family is the core draw path:
``plot_datasets``/``plot_dataset`` (single/overlay), ``plot_vector_subplots``,
``plot_grouped_time_domain_subplots``, ``plot_maxent_reconstruction*``, and
``plot_fit``/``set_global_fits`` for fit-curve overlay. ``_apply_limits`` and
the ``_auto_x_limits``/``_auto_y_limits*`` helpers frame the axes afterward.
- **Interaction** — mouse/canvas event handlers (``_on_canvas_button_press``,
``_on_canvas_motion_notify``, ``_on_canvas_button_release``) drive fit-range
and moments-window drag handles, annotation add/edit/delete, and the cursor
readout.
- **Export** — ``get_current_plot_export_data``, ``export_plotted_data_as_text``,
``export_plots_to_gle`` (via the shared ``run_gle_export`` orchestrator in
``gui/utils/gle_export.py``), ``export_current_plot``.
- **State I/O** — ``get_state``/``restore_state`` at the end serialize panel
configuration (bunch factor, view limits, projections, decimation, RRF) for
project persistence.
Entry points GUI callers use most: ``plot_datasets``/``plot_dataset``,
``plot_fit``, ``set_view_limits``/``get_view_limits``, ``get_state``/``restore_state``.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from pathlib import Path
import numpy as np
from PySide6.QtCore import QSignalBlocker, Qt, QTimer, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFrame,
QHBoxLayout,
QInputDialog,
QLabel,
QMenu,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QVBoxLayout,
QWidget,
)
from asymmetry.core.data.dataset import MuonDataset
from asymmetry.core.fourier.spectrum import reference_field_gauss
from asymmetry.core.fourier.units import convert as convert_field_unit
from asymmetry.core.fourier.units import (
gauss_to_mhz,
relative_shift_axis_label,
relative_shift_ppm,
shift_axis_label,
)
from asymmetry.core.transform.background import (
apply_grouped_background_correction,
available_background_modes,
resolve_background_mode,
)
from asymmetry.core.transform.grouping import good_event_count, group_forward_backward
from asymmetry.core.transform.integral import integrate_curve
from asymmetry.core.transform.peakfit import parabolic_peak
from asymmetry.core.transform.rebin import rebin, resolve_binning_mode
from asymmetry.core.utils.constants import (
PeriodMode,
)
from asymmetry.gui.export_paths import (
default_export_path,
remember_export_path,
)
from asymmetry.gui.panels.draggable_handles import nearest_handle
from asymmetry.gui.styles import tokens
from asymmetry.gui.styles.fonts import mono_font
from asymmetry.gui.styles.metrics import char_width, field_width_for
from asymmetry.gui.styles.plots import (
draw_empty_state_message,
draw_fit_range_span,
draw_zero_line,
period_overlay_palette,
style_axes,
style_figure,
style_legend,
)
from asymmetry.gui.styles.widgets import build_nav_button_qss
from asymmetry.gui.tasks import TaskRunner
from asymmetry.gui.utils.gle_export import (
GleExportBuild,
dedup_export_token,
gle_offset_supported,
run_gle_export,
safe_file_token,
show_export_result_dialog,
)
from asymmetry.gui.utils.waterfall import auto_waterfall_delta
from asymmetry.gui.widgets.axis_limits import AxisLimitControls, FloatLimitField
from asymmetry.gui.widgets.mpl_canvas import create_canvas
from asymmetry.gui.widgets.no_scroll_spin import NoScrollDoubleSpinBox
from asymmetry.gui.widgets.projection_chip_bar import ProjectionChipBar
from asymmetry.gui.widgets.rrf_controls import (
install_rrf_controls,
rrf_display_dataset,
rrf_display_fit_curve,
rrf_draw_badge,
)
_LOGGER = logging.getLogger(__name__)
# Metadata fields available for dataset labelling in the legend.
_LABEL_FIELDS: list[tuple[str, str]] = [
("Run", "run"),
("Field (G)", "field"),
("Temperature (K)", "temperature"),
("Comment", "comment"),
]
_TIME_VIEW_FIELDS: list[tuple[str, str]] = [
("FB Asymmetry", "fb_asymmetry"),
("Individual Groups", "groups"),
("Raw Counts", "raw_counts"),
]
#: Map the frequency plot panel's x-unit tokens to ``core.fourier.units`` units.
_FREQUENCY_X_UNIT_FIELD = {
"frequency_mhz": "mhz",
"field_gauss": "gauss",
"field_tesla": "tesla",
}
#: The three x-axis transform modes for the frequency panel.
_FREQUENCY_AXIS_MODES = ("absolute", "shift", "relative_ppm")
#: The two reference-resolution modes for shift axes.
_FREQUENCY_REFERENCE_MODES = ("run", "common")
#: Display y-labels for a unit-area field distribution, keyed on the effective
#: ``core.fourier.units`` field unit of the current x axis. The canonical MHz
#: axis keeps the core's own p(ν) label; any unknown/dimensionless unit falls
#: back to it too (factor 1 — the honest label).
_DENSITY_YLABELS = {
"gauss": "Field distribution p(B) (1/G)",
"tesla": "Field distribution p(B) (1/T)",
}
#: Export y-column names for a unit-area density, keyed like above.
_DENSITY_COLUMN_NAME = {
"mhz": "density_per_MHz",
"gauss": "density_per_G",
"tesla": "density_per_T",
}
# Self-describing x-column names for frequency-view ``.dat`` sidecars, keyed on
# the panel's current display unit. The correlation (hyperfine-coupling) axis
# names its column separately (``coupling_MHz``); shift/relative axes name their
# column from the mode (see ``_frequency_x_column_name``).
_FREQUENCY_X_COLUMN_NAME = {
"frequency_mhz": "frequency_MHz",
"field_gauss": "field_G",
"field_tesla": "field_T",
}
#: Shift-axis ``.dat`` x-column names, keyed on the display unit.
_FREQUENCY_SHIFT_X_COLUMN_NAME = {
"frequency_mhz": "shift_MHz",
"field_gauss": "shift_G",
"field_tesla": "shift_T",
}
# GLE has no fill transparency: gleplot's writer drops fill_between's alpha and
# rgb_to_gle quantizes computed tints to named colors (a 75%-gray tint maps to
# WHITE, and the series color itself renders as a solid block that swallows the
# line). The exported ±1σ band therefore uses an explicit light GLE tint
# matched to the series color; LIGHTGRAY/LIGHTBLUE/LIGHTCYAN/LIGHTGREEN are the
# only light tints in gleplot's passthrough color set.
_GLE_BAND_TINT = {
"black": "lightgray",
"gray": "lightgray",
"blue": "lightblue",
"cyan": "lightcyan",
"green": "lightgreen",
}
# Neutral y-range for a stacked projection subplot whose asymmetry is entirely
# non-finite (no data). Without it matplotlib keeps its default (0, 1) box,
# which reads as "counts" rather than "asymmetry, no data".
_EMPTY_PROJECTION_YLIM = (-0.3, 0.3)
# Forward/backward asymmetry saturates at ±100 % in early/late bad bins (zero or
# one-sided counts). These sentinel values are not signal; if autoscale picks them
# up the few-to-tens-of-% real asymmetry is squashed into an invisible band. They
# are clipped from the asymmetry-percent y-autoscale (never from frequency a.u. or
# raw-count axes, where large magnitudes are legitimate).
_ASYMMETRY_SATURATION_PERCENT = 100.0
# How far above the non-DC baseline (median) a non-DC spectral peak must rise
# before the frequency plot counts it as a real line when auto-framing on first
# paint. Below this the spectrum is treated as DC-only / featureless and the full
# Nyquist span is kept rather than zooming to a noise spike.
_FREQUENCY_PEAK_PROMINENCE = 4.0
# Robust ceiling factor for the time-domain signal-frame error clip. On
# low-statistics (transverse-field) data the late-time error bars diverge well
# past the signal envelope; each bin's error contribution to the y-autoscale is
# capped at this multiple of the signal scale so those bars cannot bury the
# precession. The ceiling sits above every bar on uniform-error data, so framing
# there is unchanged.
_SIGNAL_FRAME_ERROR_CEILING_FACTOR = 1.5
# Tail quantile for the inverse-variance-weighted asymmetry-percent y-frame. The
# counts-depleted late-time tail of low-statistics data scatters to large
# asymmetry (e.g. ±80 %) with *its own* moderate error — so it is neither a
# ±100 % sentinel nor clamped by the error ceiling (the outlier is the value, not
# the bar), and raw min/max reopens the ±220 % blowout. Framing to an
# inverse-variance-weighted quantile of the asymmetry ± error envelope instead
# down-weights those high-error bins by 1/σ², so the frame tracks the
# well-measured signal. This excludes the lightest QUANTILE / (1 − QUANTILE) of
# the cumulative inverse-variance weight at each end; on uniform-error (ZF/LF)
# data and small samples it reduces to the plain min/max envelope, so framing
# there is unchanged.
_SIGNAL_FRAME_WEIGHTED_QUANTILE = 0.0025
# Floor (in the y-quantity's units) under which a bin's error is treated as this
# value when forming the 1/σ² weight, so an exactly-zero error cannot produce an
# infinite weight. Tiny relative to any real asymmetry-percent error.
_SIGNAL_FRAME_WEIGHT_ERROR_FLOOR = 1e-6
[docs]
class PlotPanel(QWidget):
"""Matplotlib canvas for time- and frequency-domain plots.
Notes
-----
The bunch factor controls both the plotted representation and the dataset
prepared for fitting in the GUI. The stored source dataset remains
unchanged, and any rebinned fit dataset is produced as a temporary copy.
"""
bunch_factor_changed = Signal(int)
fit_range_changed = Signal(float, float)
view_limits_changed = Signal(float, float, float, float)
polarization_axis_changed = Signal(str)
#: Emitted with the projection label when a stacked subplot is clicked to
#: become the single-fit target (multi-subplot / ALL view only).
fit_target_projection_changed = Signal(str)
overlay_toggled = Signal(bool)
#: Emitted when the waterfall toggle or manual-Δ field changes and the host
#: should re-render the overlay (mirrors ``overlay_toggled``).
waterfall_changed = Signal()
time_view_changed = Signal(str)
# Payload dict for the status-bar cursor readout, or None to clear. Keys:
# x, y (snapped where possible), err, snr, peak (x,y)|None,
# window (mean,err,n)|None. See _build_cursor_readout.
cursor_coords_changed = Signal(object)
#: Spectral-moments overlay drags (frequency panel): window in canonical MHz.
moments_window_changed = Signal(float, float)
moments_cutoff_changed = Signal(float) # new cutoff fraction in [0, 1)
#: A moments-handle drag has ended (mouse released after an actual move):
#: the signal for "recompute with full bootstrap uncertainty now".
moments_drag_finished = Signal()
#: The user clicked the "Open Grouping…" link in the transverse-field
#: grouping-hint bar (see :meth:`set_grouping_hint`); the host opens the
#: grouping dialog for the current run.
grouping_hint_requested = Signal()
#: The user dismissed the grouping-hint bar (the ✕ button); the host records
#: the dismissal so the nudge stays hidden for that run.
grouping_hint_dismissed = Signal()
[docs]
def __init__(self, parent: QWidget | None = None, *, domain: str = "time") -> None:
super().__init__(parent)
#: Background tasks owned by this panel (GLE export compiles). Shut
#: down via :meth:`shutdown_workers` from the main window's closeEvent.
self._tasks = TaskRunner(self)
self._domain = "frequency" if str(domain).strip().lower() == "frequency" else "time"
#: Host-injected counts-first re-reduction hook for display bunching
#: (see :meth:`set_counts_rebunch_provider`). ``None`` outside a
#: MainWindow (standalone panels fall back to the curve-level rebin).
self._counts_rebunch_provider: (
Callable[[MuonDataset, int], tuple[np.ndarray, np.ndarray, np.ndarray] | None] | None
) = None
self._current_time_view_mode = "fb_asymmetry"
self._available_time_view_modes = ["fb_asymmetry"]
self._default_canvas_min_height = 360
self._subplot_canvas_height_per_axis = 220
self._current_frequency_x_unit = "frequency_mhz"
#: Frequency x-axis transform mode: ``"absolute"`` (unit-converted data),
#: ``"shift"`` (x − x₀ in the selected unit), or ``"relative_ppm"``
#: ((x − x₀)/x₀ × 1e6, dimensionless). Replaces the old "X relative to
#: ref. field" checkbox, which only offset the limit boxes.
self._frequency_axis_mode = "absolute"
#: Reference selector: ``"run"`` (each dataset shifts by its OWN applied
#: field, so overlaid TF spectra align at zero) or ``"common"`` (every
#: dataset shifts by the shared spin value).
self._frequency_reference_mode = "run"
#: The ACTIVE dataset's resolved reference (MHz). Drives the limit boxes,
#: moments overlay, fit-range span, view-window inversion, and Larmor
#: marker — all active-dataset concepts. Per-dataset overlay *data* uses
#: each dataset's own reference instead (see
#: :meth:`_convert_frequency_axis_for_display`).
self._frequency_reference_mhz: float | None = None
#: The user-entered "Common" reference (MHz); authoritative in Common
#: mode. Seeded once from the first field-bearing dataset so switching to
#: Common starts from a sensible value, then owned by the spin.
self._frequency_common_reference_mhz: float | None = None
#: Guards the missing-field fallback note so it is logged once per mode.
self._frequency_missing_reference_noted = False
#: True when the active frequency dataset is a muoniated-radical
#: correlation spectrum, whose x-axis is a hyperfine coupling (A_µ, MHz)
#: and must not be field-converted by the MHz/G/T selector.
self._frequency_axis_is_correlation = False
self._frequency_correlation_x_label = "Muon hyperfine coupling Aμ (MHz)"
#: Field-unit view stashed on entry to a correlation spectrum and
#: restored when leaving it, so the user's MHz/G/T + relative choice
#: survives the excursion.
self._frequency_axis_unit_before_correlation = "frequency_mhz"
self._frequency_axis_mode_before_correlation = "absolute"
self._frequency_reference_mode_before_correlation = "run"
self._frequency_x_limits_by_unit: dict[str, tuple[float, float]] = {}
#: Per-mode y windows for unit-area density views (session-local): only
#: populated when the density Jacobian differs between two modes, so a
#: revisited mode restores its exact y window instead of re-quantizing.
self._frequency_y_limits_by_unit: dict[str, tuple[float, float]] = {}
#: Optional ``(run_number, time_us, signal)`` diamagnetic-fit overlay for
#: the time view; only drawn when the displayed run matches ``run_number``.
self._diamagnetic_overlay: tuple[int | None, np.ndarray, np.ndarray] | None = None
#: Waterfall overlay state (single-axis overlay path only). When enabled
#: each overlaid trace is shifted by ``i * Δ`` at draw time; the source
#: arrays are never mutated. ``_waterfall_offset`` is ``None`` for auto
#: spacing (:func:`auto_waterfall_delta`) or a user-entered manual Δ.
#: (Enablement itself lives on the checkbox — see
#: :meth:`is_waterfall_enabled` — mirroring the overlay pattern.)
self._waterfall_offset: float | None = None
#: Record of the *last actually drawn* stack, written only by the
#: ``plot_datasets`` overlay draw and cleared by every other render
#: entry point (see :meth:`_clear_waterfall_render_record`). The export
#: path reads offsets from here — never from the checkbox — so a
#: grouped/MaxEnt/single view can never stamp stale offsets.
self._waterfall_stacked = False
self._waterfall_drawn_offsets: dict[int, float] = {}
self._waterfall_resolved_delta: float = 0.0
#: Single-slot ``(frame identity, Δ)`` cache for the AUTO spacing. A
#: zoom schedules a decimation re-render of the same content through
#: ``plot_datasets``; without this the auto Δ would re-resolve from the
#: zoomed window and the stack would re-space under the user
#: mid-inspection. Self-invalidates on any content change (the
#: identity includes runs, axis, view mode, and the waterfall state).
self._waterfall_auto_delta_cache: tuple[tuple, float] | None = None
#: Raw-value saturation-sentinel mask aligned with the overlay's
#: concatenated ``_last_plot_*`` arrays (waterfall offsets move drawn
#: values across the ±100 % threshold, so sentinels must be classified
#: on the un-offset values). ``None`` outside the overlay draw.
self._last_plot_sentinel_mask: np.ndarray | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
try:
self._figure, self._canvas, self._nav_toolbar = create_canvas(
layout="tight", toolbar=True, parent=self
)
self._canvas.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Preferred,
)
self._canvas.setMinimumHeight(self._default_canvas_min_height)
self._canvas_host = QWidget(self)
self._canvas_host.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Preferred,
)
self._canvas_host_layout = QVBoxLayout(self._canvas_host)
self._canvas_host_layout.setContentsMargins(0, 0, 0, 0)
self._canvas_host_layout.setSpacing(0)
self._canvas_host_layout.addWidget(self._canvas)
self._canvas_scroll_area = QScrollArea(self)
self._canvas_scroll_area.setWidget(self._canvas_host)
self._canvas_scroll_area.setWidgetResizable(True)
self._canvas_scroll_area.setFrameShape(QScrollArea.Shape.NoFrame)
self._canvas_scroll_area.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self._canvas_scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self._canvas_scroll_area.verticalScrollBar().setObjectName("plotScroll")
self._ax = self._figure.add_subplot(111)
style_figure(self._figure)
style_axes(self._ax)
self._nav_toolbar.hide()
self._axis_limit_callback_ids: list[tuple[object, int, int]] = []
self._syncing_limits_from_axes = False
self._connect_axis_limit_callbacks([self._ax])
default_x_label, default_y_label = self._default_axis_labels()
self._ax.set_xlabel(default_x_label)
self._ax.set_ylabel(default_y_label)
self._default_x_axis_label_color = self._x_axis_label_color(self._ax)
self._default_x_axis_tick_color = self._x_axis_tick_color(self._ax)
# Fit-range interaction state.
self._fit_x_min: float | None = None
self._fit_x_max: float | None = None
self._fit_span_artists: list[object] = []
self._fit_min_handles: list[object] = []
self._fit_max_handles: list[object] = []
self._active_fit_handle: str | None = None
self._active_fit_axis = None
self._drag_started = False
# Spectral-moments overlay state (frequency panel only). The window
# is held in canonical absolute MHz; the cutoff line sits at
# ``peak · cutoff_fraction`` in amplitude.
self._moments_window_mhz: tuple[float, float] | None = None
self._moments_cutoff_fraction: float = 0.0
self._moments_peak_amp: float | None = None
self._moments_overlay_visible: bool = False
self._moments_span_artists: list[object] = []
self._moments_cutoff_artists: list[object] = []
self._active_moments_handle: str | None = None
# Add plot limit controls toolbar
self._create_limit_controls()
self._sync_navigation_buttons()
layout.addLayout(self._limit_toolbar)
self._projection_bar = ProjectionChipBar()
self._projection_bar.selection_changed.connect(self._on_projection_selection_changed)
nav_row = QHBoxLayout()
nav_row.setContentsMargins(4, 0, 4, 0)
nav_row.setSpacing(4)
# The label combo applies to both panels: it labels overlaid traces
# (multiple runs in the time panel; multiple runs' spectra in the
# frequency panel) by run / temperature / field / custom column.
nav_row.addWidget(QLabel("Label:"))
nav_row.addWidget(self._label_field_combo)
nav_row.addWidget(self._time_view_label)
nav_row.addWidget(self._time_view_combo)
nav_row.addWidget(self._log_counts_checkbox)
nav_row.addWidget(self._overlay_checkbox)
nav_row.addWidget(self._waterfall_checkbox)
nav_row.addWidget(self._waterfall_delta_field)
nav_row.addStretch()
nav_row.addWidget(self._projection_bar)
nav_row.addSpacing(4)
_nav_qss = build_nav_button_qss()
self._pan_btn = QPushButton("Pan")
self._pan_btn.setCheckable(True)
self._pan_btn.setMaximumWidth(60)
self._pan_btn.setStyleSheet(_nav_qss)
self._pan_btn.clicked.connect(self._on_pan_button_clicked)
nav_row.addWidget(self._pan_btn)
self._zoom_btn = QPushButton("Zoom")
self._zoom_btn.setCheckable(True)
self._zoom_btn.setMaximumWidth(60)
self._zoom_btn.setStyleSheet(_nav_qss)
self._zoom_btn.clicked.connect(self._on_zoom_button_clicked)
nav_row.addWidget(self._zoom_btn)
layout.addLayout(nav_row)
layout.addWidget(install_rrf_controls(self))
self._plot_header = self._create_plot_header()
layout.addWidget(self._plot_header)
self._grouping_hint_bar = self._create_grouping_hint_bar()
layout.addWidget(self._grouping_hint_bar)
layout.addWidget(self._canvas_scroll_area)
self._plot_footer = self._create_plot_footer()
layout.addWidget(self._plot_footer)
self._has_mpl = True
# Store current dataset for rebunching
self._current_dataset = None
self._current_datasets: list[MuonDataset] = []
self._limits_initialized = False
# Signature of the content the current first-paint frame was computed
# for. First-paint auto-framing is one-shot per identity: it re-fires
# when the displayed content changes (a different run, axis, or
# view-mode) so a freshly loaded or switched dataset frames to itself
# instead of inheriting the previous view's — or a persisted
# cross-session — limits, but not on incidental redraws (bunching, fit
# overlays, annotations) that must preserve a user's manual zoom.
self._framed_identity: tuple | None = None
# Set once the user takes explicit control of the limits (edits a
# limit field, or opens a project whose saved view is restored).
# While set, a content switch keeps the current limits instead of
# reframing — so the "compare the same window across a run series"
# workflow survives switching runs. Auto-framing never sets it, so an
# untouched view reframes to each newly shown dataset. Cleared by
# clear() (a blank/new view starts fresh).
self._limits_user_locked = False
# Whether the most recent plot_dataset/plot_datasets draw first-paint
# framed the axes (as opposed to preserving an existing frame). Read
# by the frequency render path so it restores caller-preserved limits
# only when the draw did NOT reframe — a genuine first paint keeps its
# freshly computed smart framing instead of stale preserved defaults.
self._last_draw_reframed = False
self._current_polarization_axis: str | None = None
# Ordered projection specs ({"label", "tint"}) and the per-label tint
# lookup used for frame-tinting subplots; driven by the chip bar.
self._projection_specs: list[dict] = []
self._tint_by_label: dict[str, str] = {}
self._selected_projection_labels: list[str] = []
# Which stacked subplot is the active single-fit target (multi-view).
self._fit_target_projection: str | None = None
self._fit_target_artists: list = []
self._y_limits_by_polarization: dict[str, tuple[float, float]] = {}
self._subplot_axes_by_polarization: dict[str, object] = {}
self._vector_subplot_datasets: dict[str, list[MuonDataset]] = {}
self._grouped_time_subplot_datasets: list[MuonDataset] = []
# Tracks the intended stacked-subplot count independently of the
# _subplot_axes_by_polarization dict, which is briefly empty while
# plot_vector_subplots/plot_grouped_time_domain_subplots rebuild axes.
# resizeEvent reads this so it doesn't reset the scroll area during
# that window.
self._stacked_axis_count: int = 1
# Store fit curve data to persist across redraws
self._fit_curve = None # (t_fit, y_fit, label) for single fits
self._fit_curve_run_number = None
self._fit_curves = {} # {run_number: (t_fit, y_fit, label)} for global fits
self._fit_curves_by_key: dict[tuple[int, str | None], tuple] = {}
# Per-fit additive component curves for shading.
self._fit_components = None # list[(name, y_component)] for single fit
self._fit_components_by_run = {} # {run_number: list[(name, y_component)]}
self._fit_components_by_key: dict[tuple[int, str | None], list[tuple[str, object]]] = {}
# Per-run fit metadata for export headers.
self._fit_metadata: dict[int, dict] = {} # {run_number: {formula, chi2, ...}}
self._fit_metadata_by_key: dict[tuple[int, str | None], dict] = {}
# Interactive plot labels (text annotations).
self._default_annotations: list[dict] = []
self._annotations_by_group: dict[str, list[dict]] = {}
self._annotations: list[dict] = self._default_annotations
self._active_annotation_idx: int | None = None
self._annotation_drag_started = False
# Persistent frequency-domain decorations that must survive a replot
# (the show-time render clears the axis and recomputes the x-label, so
# markers/labels drawn straight onto the axis are otherwise wiped —
# see set_custom_x_axis_label / add_persistent_frequency_marker).
self._custom_x_axis_label: str | None = None
self._persistent_frequency_markers: list[dict] = []
# Cached arrays from the most recently plotted analysis dataset.
self._last_plot_time = None
self._last_plot_asymmetry = None
self._last_plot_error = None
self._last_low_count_mask = None
self._decimation_enabled = True
self._decimation_applied_for_current_view = False
self._decimation_points_shown = 0
self._decimation_points_total = 0
#: Chip Text artist per axis id; cleared on every view reset.
self._decimation_chip_artists: dict[int, object] = {}
self._max_render_points_per_trace = 4000
self._viewport_refresh_pending = False
self._viewport_refresh_in_progress = False
self._canvas.mpl_connect("button_press_event", self._on_canvas_button_press)
self._canvas.mpl_connect("motion_notify_event", self._on_canvas_motion_notify)
self._canvas.mpl_connect("button_release_event", self._on_canvas_button_release)
self._canvas.mpl_connect("draw_event", self._on_canvas_draw_event)
except ImportError:
layout.addWidget(QLabel("matplotlib not installed — plotting disabled"))
self._has_mpl = False
def _create_limit_controls(self) -> None:
"""Create inline controls for adjusting plot limits and plot options."""
self._limit_toolbar = QVBoxLayout()
self._limit_toolbar.setSpacing(2)
self._limit_toolbar.setContentsMargins(4, 4, 4, 4)
# ── Row 0: axis range fields + auto-scale buttons ─────────────────
self._axis_controls = AxisLimitControls(
field_width=76,
show_units=True,
auto_checked=False,
# A grouped-count FFT magnitude scales with the event count and
# legitimately exceeds the historical ±1e6 guard on a
# high-statistics run; the time-domain panels keep the tight
# range as an input-sanity guard.
y_value_range=(-1e12, 1e12) if self._is_frequency_plot_panel() else None,
)
self._x_min = self._axis_controls.x_min
self._x_max = self._axis_controls.x_max
self._y_min = self._axis_controls.y_min
self._y_max = self._axis_controls.y_max
self._x_unit_label = self._axis_controls.x_unit_label
self._y_unit_label = self._axis_controls.y_unit_label
self._auto_x_btn = self._axis_controls.auto_x_btn
self._auto_y_btn = self._axis_controls.auto_y_btn
self._x_unit_label.setText("MHz" if self._is_frequency_plot_panel() else "µs")
self._y_unit_label.setText("a.u." if self._is_frequency_plot_panel() else "%")
self._auto_x_btn.clicked.connect(self._on_auto_x_button_clicked)
self._auto_y_btn.clicked.connect(self._on_auto_y_button_clicked)
self._limit_toolbar.addWidget(self._axis_controls)
# Apply limit changes immediately from text field edits. A manual edit is
# an explicit override, so it disables that axis's Auto toggle — otherwise
# the next autoscale refresh would clobber the typed value back to the
# data extent (e.g. an FFT X-max set past the dominant peak).
self._x_min.editingFinished.connect(self._on_x_limit_field_edited)
self._x_max.editingFinished.connect(self._on_x_limit_field_edited)
self._y_min.editingFinished.connect(self._on_y_limit_field_edited)
self._y_max.editingFinished.connect(self._on_y_limit_field_edited)
# Bunching has a single user-facing control — the toolbar "Bunch:"
# spinbox (mainwindow), which drives this panel via set_bunch_factor().
# This hidden spinbox is NOT a second UI path: it is the canonical
# backing store for the panel's bunch value, read by get/set_bunch_factor
# and persisted in project state (the "bunch_factor" key). It is kept as
# a (hidden) QSpinBox rather than a plain int so the existing value/
# signal API and saved-project schema stay stable. Never show it.
self._bunch_factor = QSpinBox()
self._bunch_factor.setRange(1, 1000)
self._bunch_factor.setValue(1)
self._bunch_factor.setMaximumWidth(60)
self._bunch_factor.valueChanged.connect(self._on_bunch_changed)
self._bunch_factor.hide()
# Legend label-field preferences. Initialised unconditionally (not only on
# the matplotlib-available path) so the label combo can be built even in a
# headless/no-mpl panel. Preferences can be scoped per Data Group.
self._active_label_group_id: str | None = None
self._default_label_field: str = "run"
self._label_field_by_group: dict[str, str] = {}
#: User-defined data-browser custom columns offered as legend labels, as
#: ``(display_label, "custom:<id>")`` pairs pushed in by the host (see
#: :meth:`set_custom_label_fields`). Their per-run values are read straight
#: from ``dataset.metadata["custom_fields"]``.
self._custom_label_fields: list[tuple[str, str]] = []
#: Host-provided ``run_number -> {column-id: value}`` map (see
#: :meth:`set_custom_values_by_run`). Derived datasets such as averaged
#: FFT spectra do not carry the source run's ``custom_fields`` inline, so
#: this is the fallback that lets a custom-column label still resolve for
#: them by run number — the frequency-view analogue of the built-in
#: fields' ``run.metadata`` fallback.
self._custom_values_by_run: dict[int, dict[str, str]] = {}
# Label and Overlay widgets are created here but placed in the nav row below.
self._label_field_combo = QComboBox()
self._rebuild_label_field_combo()
self._label_field_combo.setMaximumWidth(140)
self._label_field_combo.currentIndexChanged.connect(self._on_label_field_changed)
self._time_view_label = QLabel("Time View:")
self._time_view_combo = QComboBox()
self._time_view_combo.setMaximumWidth(160)
self._time_view_combo.currentIndexChanged.connect(self._on_time_view_mode_changed)
self.set_time_view_modes(self._available_time_view_modes, self._current_time_view_mode)
self._time_view_label.hide()
self._time_view_combo.hide()
# Log-count diagnostic: a log-y toggle that applies only on the
# raw-counts view. A pure muon-decay histogram is a straight line on a
# log count axis, so a mis-placed t0, a wrong background level, or
# high-rate deadtime curvature jump out without a fit.
self._log_counts_enabled = False
self._log_counts_checkbox = QCheckBox("Log scale")
self._log_counts_checkbox.setChecked(False)
self._log_counts_checkbox.setToolTip(
"Plot raw counts on a logarithmic y-axis — a pure decay is a "
"straight line, so t0, background and deadtime deviations are "
"obvious. Non-positive bins are dropped (log undefined)."
)
self._log_counts_checkbox.toggled.connect(self._on_log_counts_toggled)
self._log_counts_checkbox.hide()
self._overlay_checkbox = QCheckBox("Overlay")
self._overlay_checkbox.setChecked(False)
self._overlay_checkbox.toggled.connect(self.overlay_toggled.emit)
self._overlay_checkbox.toggled.connect(self._sync_waterfall_controls_enabled)
# Waterfall: a modifier on the single-axis overlay path that stacks each
# trace by i*Δ. Only usable while Overlay is checked, so it tracks the
# overlay box's enabled state. The manual-Δ field reuses the shared
# FloatLimitField (structural harness forbids a bespoke *LimitField);
# blank text means auto spacing.
self._waterfall_checkbox = QCheckBox("Waterfall")
self._waterfall_checkbox.setChecked(False)
self._waterfall_checkbox.setEnabled(False)
self._waterfall_checkbox.setToolTip(
"Stack overlaid traces vertically by a uniform per-trace offset so "
"they are cleanly resolved. Available only while Overlay is on."
)
self._waterfall_checkbox.toggled.connect(self._on_waterfall_checkbox_toggled)
self._waterfall_delta_field = FloatLimitField(
0.0, decimals=3, minimum_width=56, maximum_width=88, value_range=(0.0, 1e6)
)
self._waterfall_delta_field.set_unset("Auto")
self._waterfall_delta_field.setEnabled(False)
self._waterfall_delta_field.setToolTip(
"Manual waterfall spacing Δ between traces (blank = automatic)."
)
self._waterfall_delta_field.editingFinished.connect(self._on_waterfall_delta_edited)
# ── Row 1: frequency-specific controls (surfaceAlt tinted second row) ──
if self._is_frequency_plot_panel():
row1_widget = QWidget()
row1_widget.setObjectName("plotFrequencyRow")
# Scope to the container: a bare QWidget selector would cascade
# the border/background onto every child label and combo.
row1_widget.setStyleSheet(
f"QWidget#plotFrequencyRow {{ background-color: {tokens.SURFACE_ALT};"
f" border-top: 1px solid {tokens.BORDER}; }}"
)
row1 = QHBoxLayout(row1_widget)
row1.setContentsMargins(4, 3, 4, 3)
row1.setSpacing(8)
row1.addWidget(QLabel("X Units:"))
self._frequency_x_unit_combo = QComboBox()
self._frequency_x_unit_combo.addItem("Frequency (MHz)", userData="frequency_mhz")
self._frequency_x_unit_combo.addItem("Field (G)", userData="field_gauss")
self._frequency_x_unit_combo.addItem("Field (T)", userData="field_tesla")
self._frequency_x_unit_combo.currentIndexChanged.connect(
self._on_frequency_x_unit_changed
)
row1.addWidget(self._frequency_x_unit_combo)
row1.addWidget(QLabel("Axis:"))
self._frequency_axis_mode_combo = QComboBox()
self._frequency_axis_mode_combo.addItem("Absolute", userData="absolute")
self._frequency_axis_mode_combo.addItem("Shift (x − x₀)", userData="shift")
self._frequency_axis_mode_combo.addItem("Relative shift (ppm)", userData="relative_ppm")
self._frequency_axis_mode_combo.setToolTip(
"Absolute: the measured frequency/field. Shift (x − x₀): each "
"spectrum minus its reference field, in the selected unit. "
"Relative shift (ppm): (x − x₀)/x₀ in parts per million."
)
self._frequency_axis_mode_combo.currentIndexChanged.connect(
self._on_frequency_axis_mode_changed
)
row1.addWidget(self._frequency_axis_mode_combo)
row1.addWidget(QLabel("Ref.:"))
self._frequency_reference_mode_combo = QComboBox()
self._frequency_reference_mode_combo.addItem("Run field", userData="run")
self._frequency_reference_mode_combo.addItem("Common", userData="common")
self._frequency_reference_mode_combo.setToolTip(
"Run field: each spectrum shifts by its own applied field, so "
"spectra measured at different fields overlay aligned at zero. "
"Common: every spectrum shifts by the value in the box."
)
self._frequency_reference_mode_combo.setEnabled(False)
self._frequency_reference_mode_combo.currentIndexChanged.connect(
self._on_frequency_reference_mode_changed
)
row1.addWidget(self._frequency_reference_mode_combo)
self._frequency_reference_spin = NoScrollDoubleSpinBox()
self._frequency_reference_spin.setRange(0.0, 1_000_000.0)
self._frequency_reference_spin.setDecimals(2)
self._frequency_reference_spin.setSuffix(" G")
self._frequency_reference_spin.setValue(0.0)
# Set the mono field font *before* sizing, so field_width_for
# measures the font the spin actually renders in (mirrors alc_panel).
self._frequency_reference_spin.setFont(mono_font(11.0))
self._frequency_reference_spin.setMinimumWidth(
field_width_for(8, self._frequency_reference_spin)
)
self._frequency_reference_spin.setEnabled(False)
self._frequency_reference_spin.editingFinished.connect(
self._on_frequency_reference_spin_committed
)
# Arrow clicks and wheel steps fire valueChanged but not
# editingFinished until focus leaves, so without this the plot
# ignores stepping — commit through a short single-shot debounce
# (the fourier_panel settings-debounce idiom). editingFinished
# (Enter / focus-out) still commits immediately; the commit is
# idempotent so both paths firing for one edit is harmless.
# Parented so it dies with the panel.
self._frequency_reference_debounce = QTimer(self)
self._frequency_reference_debounce.setSingleShot(True)
self._frequency_reference_debounce.setInterval(300)
self._frequency_reference_debounce.timeout.connect(
self._on_frequency_reference_spin_committed
)
self._frequency_reference_spin.valueChanged.connect(
self._on_frequency_reference_spin_stepped
)
row1.addWidget(self._frequency_reference_spin)
row1.addStretch()
self._limit_toolbar.addWidget(row1_widget)
def _is_frequency_plot_panel(self) -> bool:
"""Return True when this panel is dedicated to frequency-domain viewing."""
return self._domain == "frequency"
def _create_plot_header(self) -> QWidget:
"""Return the title strip shown above the canvas."""
widget = QWidget()
widget.setObjectName("plotHeaderStrip")
widget.setStyleSheet(
f"QWidget#plotHeaderStrip {{ background-color: {tokens.SURFACE_ALT};"
f" border-bottom: 1px solid {tokens.BORDER}; }}"
)
row = QHBoxLayout(widget)
row.setContentsMargins(10, 6, 10, 6)
row.setSpacing(8)
self._header_title_label = QLabel()
title_font = self._header_title_label.font()
title_font.setPointSizeF(11.0)
title_font.setWeight(title_font.Weight.DemiBold)
self._header_title_label.setFont(title_font)
self._header_title_label.setStyleSheet(f"color: {tokens.TEXT};")
row.addWidget(self._header_title_label, 1)
self._header_meta_label = QLabel()
self._header_meta_label.setFont(mono_font(10.5))
self._header_meta_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
self._header_meta_label.setStyleSheet(f"color: {tokens.TEXT_MUTED};")
row.addWidget(self._header_meta_label)
self._update_plot_header()
return widget
def _create_grouping_hint_bar(self) -> QWidget:
"""Return the (hidden) transverse-field grouping-nudge bar.
A dismissable amber banner shown above the canvas when the host detects
that the current run is transverse-field but still on a grouping that
washes out the precession (see :meth:`set_grouping_hint`). The message
carries an "Open Grouping…" link (``grouping_hint_requested``) and a ✕
dismiss button (``grouping_hint_dismissed``); the host owns the policy —
this widget only renders whatever text it is handed.
"""
# This is a full-width strip flush under the plot header (a bottom border
# continuing ``plotHeaderStrip``), not the rounded padded chip
# ``styles.widgets.make_warning_banner`` produces — so it styles the
# shared ``WARN_BANNER_*`` tokens directly rather than reusing that
# helper. It uses the same amber tokens so the two stay in visual step.
bar = QFrame()
bar.setObjectName("groupingHintBar")
bar.setStyleSheet(
f"#groupingHintBar {{ background-color: {tokens.WARN_BANNER_BG};"
f" border-bottom: 1px solid {tokens.WARN}; }}"
)
row = QHBoxLayout(bar)
row.setContentsMargins(10, 5, 4, 5)
row.setSpacing(4)
self._grouping_hint_label = QLabel("")
self._grouping_hint_label.setWordWrap(True)
self._grouping_hint_label.setTextFormat(Qt.TextFormat.RichText)
self._grouping_hint_label.setStyleSheet(f"color: {tokens.WARN_BANNER_TEXT};")
# The link is handled in-process (open the grouping dialog), never as a
# browser navigation.
self._grouping_hint_label.setOpenExternalLinks(False)
self._grouping_hint_label.linkActivated.connect(
lambda _href: self.grouping_hint_requested.emit()
)
row.addWidget(self._grouping_hint_label, 1)
self._grouping_hint_dismiss_btn = QPushButton("✕")
self._grouping_hint_dismiss_btn.setToolTip("Dismiss")
self._grouping_hint_dismiss_btn.setFixedWidth(char_width(3))
self._grouping_hint_dismiss_btn.setStyleSheet(
"QPushButton { border: none; background: transparent; padding: 0 4px;"
f" color: {tokens.WARN_BANNER_TEXT}; }}"
f"QPushButton:hover {{ background-color: {tokens.SURFACE_HI}; border-radius: 3px; }}"
)
self._grouping_hint_dismiss_btn.clicked.connect(self.grouping_hint_dismissed.emit)
row.addWidget(self._grouping_hint_dismiss_btn)
bar.hide()
return bar
[docs]
def set_grouping_hint(self, text: str | None) -> None:
"""Show or hide the transverse-field grouping-nudge bar.
``text`` may contain rich text with an ``<a href=…>Open Grouping…</a>``
link; a falsy value hides the bar. Idempotent and safe to call on a
matplotlib-less panel (it simply no-ops).
"""
bar = getattr(self, "_grouping_hint_bar", None)
if bar is None:
return
if text:
self._grouping_hint_label.setText(str(text))
bar.show()
else:
self._grouping_hint_label.clear()
bar.hide()
def _create_plot_footer(self) -> QWidget:
"""Return the control bar shown below the canvas."""
widget = QWidget()
widget.setObjectName("plotFooterStrip")
widget.setStyleSheet(
f"QWidget#plotFooterStrip {{ background-color: {tokens.SURFACE_ALT};"
f" border-top: 1px solid {tokens.BORDER}; }}"
)
row = QHBoxLayout(widget)
row.setContentsMargins(8, 4, 8, 4)
row.setSpacing(6)
self._add_label_btn = QPushButton("Add Annotation")
self._add_label_btn.setCheckable(True)
min_btn_width = self._add_label_btn.fontMetrics().horizontalAdvance("Add Annotation") + 32
self._add_label_btn.setMinimumWidth(min_btn_width)
row.addWidget(self._add_label_btn)
row.addStretch()
# One export button hosting a menu (no second export button, per the
# workflow-visualisation brief): GLE export and a plain-text data
# export that skips the GLE folder/script/compile.
self._export_gle_btn = QPushButton("Export…")
self._export_gle_btn.setEnabled(False)
self._export_menu = QMenu(self._export_gle_btn)
self._export_menu.addAction("Export to GLE…", self.export_plots_to_gle)
self._export_menu.addAction("Export plotted data (text)…", self.export_plotted_data_as_text)
self._export_gle_btn.setMenu(self._export_menu)
row.addWidget(self._export_gle_btn)
row.addWidget(QLabel("Format:"))
self._gle_format_combo = QComboBox()
self._gle_format_combo.addItems(["PDF", "EPS"])
self._gle_format_combo.setEnabled(False)
row.addWidget(self._gle_format_combo)
return widget
def _update_plot_header(self) -> None:
"""Refresh the title-strip labels from the current plot state."""
if not hasattr(self, "_header_title_label"):
return
self._header_title_label.setText(self._header_title_text())
def _header_title_text(self) -> str:
"""Return the left-side text for the plot title strip."""
time_view = getattr(self, "_current_time_view_mode", "fb_asymmetry")
if self._domain == "frequency":
domain = "Fourier spectrum"
multi_suffix = "runs overlaid"
elif time_view == "groups":
domain = "Grouped time-domain"
multi_suffix = "runs"
elif time_view == "raw_counts":
domain = "Raw counts"
multi_suffix = "runs"
else:
domain = "Time-domain F-B asymmetry"
multi_suffix = "runs highlighted"
datasets = getattr(self, "_current_datasets", [])
if not datasets:
return domain
if len(datasets) == 1:
return f"{domain} — {datasets[0].run_label}"
# The grouped view stacks one run's *detector groups*, not runs (F12):
# count them as groups and name the run when they all share one.
if time_view == "groups":
source_runs = {self._dataset_source_run(ds) for ds in datasets}
source_runs.discard(None)
suffix = f"{len(datasets)} groups"
if len(source_runs) == 1:
suffix += f" (run {next(iter(source_runs))})"
return f"{domain} — {suffix}"
return f"{domain} — {len(datasets)} {multi_suffix}"
@staticmethod
def _dataset_source_run(dataset: object) -> int | None:
"""Physical source run for a (possibly synthetic) grouped-member dataset."""
meta = getattr(dataset, "metadata", {}) or {}
for key in ("source_run_number", "run_number"):
raw = meta.get(key)
if raw is not None:
try:
return int(raw)
except (TypeError, ValueError):
continue
run = getattr(dataset, "run_number", None)
try:
return int(run) if run is not None else None
except (TypeError, ValueError):
return None
def _set_canvas_minimum_height_for_axes(self, axis_count: int) -> None:
"""Scale the canvas height so stacked subplot views scroll vertically."""
if not hasattr(self, "_canvas"):
return
count = max(1, int(axis_count))
# Set _stacked_axis_count before clearing _subplot_axes_by_polarization
# so that any resize events that fire while the dict is temporarily empty
# still see the correct intended count.
self._stacked_axis_count = count
height = max(self._default_canvas_min_height, self._subplot_canvas_height_per_axis * count)
self._canvas.setMinimumHeight(height)
self._sync_canvas_scroll_geometry(axis_count=count, target_height=height)
self._canvas.updateGeometry()
def _sync_canvas_scroll_geometry(
self,
*,
axis_count: int | None = None,
target_height: int | None = None,
) -> None:
"""Keep the canvas sized correctly for single-axis fill vs stacked-axis scrolling."""
if (
not hasattr(self, "_canvas")
or not hasattr(self, "_canvas_host")
or not hasattr(self, "_canvas_scroll_area")
):
return
count = max(
1,
int(axis_count)
if axis_count is not None
else max(1, len(getattr(self, "_subplot_axes_by_polarization", {}))),
)
height = (
int(target_height)
if target_height is not None
else max(self._default_canvas_min_height, int(self._canvas.minimumHeight()))
)
stacked_mode = count > 1
self._canvas_scroll_area.setWidgetResizable(not stacked_mode)
self._canvas_scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
if stacked_mode
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
if stacked_mode:
viewport = self._canvas_scroll_area.viewport()
viewport_width = viewport.width() if viewport is not None else self.width()
viewport_height = viewport.height() if viewport is not None else 0
effective_height = max(height, int(viewport_height))
self._canvas_host.setFixedWidth(max(1, int(viewport_width)))
self._canvas_host.setFixedHeight(effective_height)
else:
self._canvas_host.setMinimumHeight(height)
self._canvas_host.setMaximumHeight(16777215)
self._canvas_host.setMinimumWidth(0)
self._canvas_host.setMaximumWidth(16777215)
self._canvas_host.updateGeometry()
def _default_axis_labels(self) -> tuple[str, str]:
"""Return fallback axis labels for this panel domain."""
if self._is_frequency_plot_panel():
return self._display_x_label(), r"$|F|$ (arb.)"
return "Time (µs)", "Asymmetry (%)"
def _mhz_per_gauss(self) -> float:
"""Return the frequency equivalent of one Gauss in MHz.
Routed through the shared ``core.fourier.units`` converter so the
Gauss↔MHz constant lives in exactly one place.
"""
return float(gauss_to_mhz(1.0))
def _frequency_limit_mode_key(
self,
*,
unit: str | None = None,
mode: str | None = None,
) -> str:
"""Return a stable storage key for frequency x-limit display modes.
The dimensionless ppm axis is unit-independent, so it keys on the mode
alone; absolute/shift axes key on ``unit:mode`` so each unit + transform
keeps its own stashed window.
"""
resolved_mode = self._frequency_axis_mode if mode is None else str(mode)
if resolved_mode == "relative_ppm":
return "relative_ppm"
resolved_unit = self._current_frequency_x_unit if unit is None else str(unit)
return f"{resolved_unit}:{resolved_mode}"
def _is_frequency_shift_mode(self) -> bool:
"""Return True when the frequency axis shows a reference-shifted quantity."""
return self._frequency_axis_mode in ("shift", "relative_ppm")
def _frequency_reference_for_dataset(self, dataset: MuonDataset | None) -> float | None:
"""Return the applied-field reference frequency in MHz for *dataset*.
Delegates the dataset-metadata-before-run-metadata field lookup to the
shared core resolver and converts the resulting Gauss value to MHz; the
unit conversion is the GUI-only part of this method.
"""
if dataset is None:
return None
field_gauss = reference_field_gauss(getattr(dataset, "run", None), dataset)
if field_gauss is None:
return None
return field_gauss * self._mhz_per_gauss()
def _resolve_reference_mhz_for_dataset(self, dataset: MuonDataset | None) -> float | None:
"""Return the reference (MHz) that *dataset*'s DATA should shift about.
``"run"`` reference mode uses the dataset's own applied field (the
headline: different-field spectra align at zero), falling back to the
common value when the dataset carries no field. ``"common"`` mode uses
the shared spin value for every dataset. Returns ``None`` when no usable
reference exists (the caller draws the trace untransformed and notes it).
"""
if self._frequency_reference_mode == "common":
return self._frequency_common_reference_mhz
run_reference = self._frequency_reference_for_dataset(dataset)
if run_reference is not None:
return run_reference
return self._frequency_common_reference_mhz
def _active_reference_mhz(self) -> float:
"""Return the active dataset's resolved reference in MHz (0 when unset)."""
return float(self._frequency_reference_mhz or 0.0)
def _display_frequency_reference(self, *, unit: str | None = None) -> float:
"""Return the active-dataset frequency reference in the requested unit."""
reference_mhz = self._frequency_reference_mhz
if reference_mhz is None:
return 0.0
resolved_unit = self._current_frequency_x_unit if unit is None else str(unit)
field_unit = _FREQUENCY_X_UNIT_FIELD.get(resolved_unit, "mhz")
return float(convert_field_unit(reference_mhz, "mhz", field_unit))
def _display_x_label(self) -> str:
"""Return the x-axis label for the current display mode/unit."""
if not self._is_frequency_plot_panel():
return "Time (µs)"
if self._frequency_axis_is_correlation:
return self._frequency_correlation_x_label
if self._frequency_axis_mode == "relative_ppm":
return relative_shift_axis_label()
field_unit = _FREQUENCY_X_UNIT_FIELD.get(self._current_frequency_x_unit, "mhz")
if self._frequency_axis_mode == "shift":
return shift_axis_label(field_unit)
return {
"field_gauss": "Field (G)",
"field_tesla": "Field (T)",
}.get(self._current_frequency_x_unit, "Frequency (MHz)")
def _display_x_unit_suffix(self) -> str:
"""Return the compact unit suffix for the x-limit controls."""
if not self._is_frequency_plot_panel():
return "µs"
if self._frequency_axis_mode == "relative_ppm":
return "ppm"
return {
"field_gauss": "G",
"field_tesla": "T",
}.get(self._current_frequency_x_unit, "MHz")
def _display_y_unit_suffix(self, y_label: str | None = None) -> str:
"""Return the compact unit suffix for the y-limit controls.
Derived from the dataset's own y-label so the calibrated percent scale
("%", "%²"), the phase-spectrum degrees ("deg"), the unit-area field
distribution ("1/MHz"), and any residual arbitrary-unit derived modes
("a.u.") each read correctly.
"""
if not self._is_frequency_plot_panel():
return "%"
text = str(y_label or "").strip().lower()
if "1/g" in text:
return "1/G"
if "1/t" in text:
return "1/T"
if "1/mhz" in text or "(1/" in text:
return "1/MHz"
if "deg" in text:
return "deg"
if "%²" in text or "%^2" in text:
return "%²"
if "%" in text:
return "%"
return "a.u."
def _note_missing_reference(self, mode: str) -> None:
"""Log the missing-field fallback once, so a trace is never dropped silently."""
if self._frequency_missing_reference_noted:
return
self._frequency_missing_reference_noted = True
_LOGGER.info(
"Frequency %s axis: no applied-field reference for a displayed "
"spectrum; drawing it untransformed (absolute) instead.",
mode,
)
def _convert_frequency_axis_for_display(
self, x_values, dataset: MuonDataset | None = None
) -> np.ndarray:
"""Convert canonical MHz axis data into the current display mode/unit.
Per dataset: in a shift mode each trace shifts about *its own* resolved
reference (``dataset``), so overlaid spectra measured at different fields
line up at zero. ``dataset=None`` falls back to the active dataset's
reference (used by single-trace and fit-curve conversions). A dataset
with no usable reference in a shift mode is drawn untransformed (absolute
display) with a one-time note rather than dropped or crashed.
"""
arr = np.asarray(x_values, dtype=float)
if not self._is_frequency_plot_panel() or self._frequency_axis_is_correlation:
return arr
field_unit = _FREQUENCY_X_UNIT_FIELD.get(self._current_frequency_x_unit, "mhz")
if self._frequency_axis_mode == "absolute":
if field_unit == "mhz":
return arr
return np.asarray(convert_field_unit(arr, "mhz", field_unit), dtype=float)
reference_mhz = (
self._active_reference_mhz()
if dataset is None
else self._resolve_reference_mhz_for_dataset(dataset)
)
if self._frequency_axis_mode == "relative_ppm":
if reference_mhz is None or reference_mhz <= 0.0:
self._note_missing_reference("relative")
# No valid reference: fall back to the absolute display value.
if field_unit == "mhz":
return arr
return np.asarray(convert_field_unit(arr, "mhz", field_unit), dtype=float)
return np.asarray(relative_shift_ppm(arr, float(reference_mhz)), dtype=float)
# Shift (x − x₀) mode.
if reference_mhz is None:
self._note_missing_reference("shift")
reference_mhz = 0.0
shifted = arr - float(reference_mhz)
if field_unit == "mhz":
return shifted
return np.asarray(convert_field_unit(shifted, "mhz", field_unit), dtype=float)
@staticmethod
def _is_unit_area_dataset(dataset: MuonDataset | None) -> bool:
"""Return whether *dataset* is a unit-area (field-distribution) spectrum."""
if dataset is None:
return False
metadata = getattr(dataset, "metadata", None)
return (
isinstance(metadata, dict)
and metadata.get("fourier_display_normalisation") == "unit_area"
)
def _density_factor_for_unit(self, dataset: MuonDataset | None, x_unit: str) -> float:
"""Return the dν/dB density Jacobian for *dataset* under x-unit token *x_unit*.
A unit-area spectrum is canonically p(ν) in 1/MHz; when the x axis
displays a field unit the density is rescaled by the constant
``dν/dB = 1 / convert(1 MHz → unit)`` so the on-screen curve integrates
to 1 per displayed x unit (1/G, 1/T). Everything else — calibrated
spectra, the canonical MHz axis, the correlation axis, and any
unknown/dimensionless unit token (defensive; a merged relative-ppm mode
resolves here to the factor-1 ``(1/MHz)`` fallback) — returns 1.
"""
if (
not self._is_frequency_plot_panel()
or self._frequency_axis_is_correlation
or not self._is_unit_area_dataset(dataset)
):
return 1.0
field_unit = _FREQUENCY_X_UNIT_FIELD.get(str(x_unit), "mhz")
if field_unit == "mhz":
return 1.0
per_unit = float(convert_field_unit(1.0, "mhz", field_unit))
if not np.isfinite(per_unit) or per_unit <= 0.0:
return 1.0
return 1.0 / per_unit
def _frequency_density_display_factor(self, dataset: MuonDataset | None) -> float:
"""The density Jacobian for *dataset* under the CURRENT x display unit."""
return self._density_factor_for_unit(dataset, self._current_frequency_x_unit)
def _convert_frequency_values_for_display(
self, values, dataset: MuonDataset | None
) -> np.ndarray:
"""Companion of :meth:`_convert_frequency_axis_for_display` for y data.
Applied at every y-render site that draws frequency-spectrum values
(line, ±1σ band, fit overlays, autoscale frames, exports) so the
displayed density and its uncertainties all carry the same Jacobian; a
factor of 1 returns the input array unscaled (no copy).
"""
arr = np.asarray(values, dtype=float)
factor = self._frequency_density_display_factor(dataset)
if factor == 1.0:
return arr
return arr * factor
def _density_display_ylabel(self, dataset: MuonDataset | None, y_label: str) -> str:
"""Return *y_label* adjusted to the displayed unit for unit-area spectra."""
if (
not self._is_unit_area_dataset(dataset)
or not self._is_frequency_plot_panel()
or self._frequency_axis_is_correlation
):
return y_label
field_unit = _FREQUENCY_X_UNIT_FIELD.get(self._current_frequency_x_unit, "mhz")
return _DENSITY_YLABELS.get(field_unit, y_label)
def _convert_frequency_axis_limit_to_control_value(self, value: float) -> float:
"""Convert one plotted axis x-limit into the toolbar control value.
The plotted axis is now always the display axis (shift/ppm data is
genuinely transformed, not offset behind the scenes), so the control
value equals the axis value in every mode — an identity mapping.
"""
return float(value)
def _convert_frequency_control_value_to_axis_limit(self, value: float) -> float:
"""Convert one toolbar x-limit value into the plotted axis value (identity)."""
return float(value)
def _convert_display_limit_between_units(
self,
value: float,
*,
from_unit: str,
to_unit: str,
) -> float:
"""Convert one x-limit value between frequency-view display units."""
if from_unit == to_unit:
return float(value)
source = _FREQUENCY_X_UNIT_FIELD.get(from_unit)
target = _FREQUENCY_X_UNIT_FIELD.get(to_unit)
if source is None or target is None:
return float(value)
return float(convert_field_unit(value, source, target))
def _convert_display_limit_to_canonical_mhz(
self,
value: float,
*,
unit: str,
mode: str,
reference_mhz: float | None = None,
) -> float:
"""Convert one displayed x value (active-dataset frame) back to canonical MHz.
Defaults to the ACTIVE dataset's reference — the limit boxes, moments
overlay, fit-range span, and view window are all per-active-run
concepts. An explicit *reference_mhz* overrides it, so a reference
change can convert the same window through the OLD and NEW references
deterministically (see :meth:`_apply_frequency_reference_change`).
"""
ref = self._active_reference_mhz() if reference_mhz is None else float(reference_mhz)
if mode == "relative_ppm":
if ref <= 0.0:
return float(value)
return ref * (1.0 + float(value) / 1.0e6)
canonical = self._convert_display_limit_between_units(
value, from_unit=unit, to_unit="frequency_mhz"
)
if mode == "shift":
canonical += ref
return canonical
def _convert_canonical_mhz_to_display_limit(
self,
value: float,
*,
unit: str,
mode: str,
reference_mhz: float | None = None,
) -> float:
"""Convert one canonical absolute MHz value into the current display mode.
*reference_mhz* overrides the active-dataset reference exactly as in
:meth:`_convert_display_limit_to_canonical_mhz`.
"""
ref = self._active_reference_mhz() if reference_mhz is None else float(reference_mhz)
if mode == "relative_ppm":
if ref <= 0.0:
return float(value)
return (float(value) - ref) / ref * 1.0e6
display_value = float(value)
if mode == "shift":
display_value -= ref
return self._convert_display_limit_between_units(
display_value,
from_unit="frequency_mhz",
to_unit=unit,
)
def _set_view_limits_fields(
self, x_min: float, x_max: float, y_min: float, y_max: float
) -> None:
"""Update toolbar-backed view-limit fields without drawing."""
self._set_limit_field_value(self._x_min, float(x_min))
self._set_limit_field_value(self._x_max, float(x_max))
self._set_limit_field_value(self._y_min, float(y_min))
self._set_limit_field_value(self._y_max, float(y_max))
[docs]
def set_frequency_axis_mode(self, mode: str) -> None:
"""Set the frequency x-axis transform mode programmatically.
*mode* is one of ``"absolute"``, ``"shift"``, or ``"relative_ppm"``.
"""
if not self._is_frequency_plot_panel():
return
resolved = str(mode) if str(mode) in _FREQUENCY_AXIS_MODES else "absolute"
self._switch_frequency_axis_display(mode=resolved)
self._sync_frequency_axis_controls()
[docs]
def frequency_axis_mode(self) -> str:
"""Return the current frequency x-axis transform mode."""
return self._frequency_axis_mode
[docs]
def is_frequency_axis_relative_to_reference(self) -> bool:
"""Return whether the frequency x axis is a reference-shifted axis.
True for both shift modes (``"shift"`` and ``"relative_ppm"``); the
phase-estimation window logic narrows around the reference whenever the
view is reference-shifted, so this stays a single predicate.
"""
return self._is_frequency_shift_mode()
def _on_frequency_axis_mode_changed(self, _index: int) -> None:
"""Apply the axis-mode combo selection with a single redraw."""
if not self._is_frequency_plot_panel() or not hasattr(self, "_frequency_axis_mode_combo"):
return
new_mode = str(self._frequency_axis_mode_combo.currentData() or "absolute")
self._switch_frequency_axis_display(mode=new_mode)
self._sync_frequency_axis_controls()
def _on_frequency_reference_mode_changed(self, _index: int) -> None:
"""Apply the reference-mode combo selection (run field / common)."""
if not self._is_frequency_plot_panel() or not hasattr(
self, "_frequency_reference_mode_combo"
):
return
new_mode = str(self._frequency_reference_mode_combo.currentData() or "run")
if new_mode not in _FREQUENCY_REFERENCE_MODES:
new_mode = "run"
old_reference = self._frequency_reference_mhz
self._frequency_reference_mode = new_mode
# Re-resolve the active reference under the new mode, then re-anchor the
# view and redraw so the shift-space data and the limit boxes follow.
self._frequency_missing_reference_noted = False
self._set_frequency_reference_from_dataset(self._current_dataset)
self._sync_frequency_axis_controls()
self._apply_frequency_reference_change(old_reference)
def _on_frequency_reference_spin_stepped(self, _value: float) -> None:
"""Debounce arrow/wheel steps on the reference spin into a commit.
``valueChanged`` also fires for programmatic ``setValue`` calls, but the
only such writer (:meth:`_set_frequency_reference_from_dataset`) wraps
its write in a ``QSignalBlocker`` and stops any pending debounce, so a
dataset sync can never trigger a commit from here.
"""
if not hasattr(self, "_frequency_reference_debounce"):
return
self._frequency_reference_debounce.start()
def _on_frequency_reference_spin_committed(self) -> None:
"""Apply a user-supplied common-reference field (Enter, focus-out, or debounce)."""
if not hasattr(self, "_frequency_reference_spin"):
return
if hasattr(self, "_frequency_reference_debounce"):
self._frequency_reference_debounce.stop()
value_gauss = float(self._frequency_reference_spin.value())
# Idempotent: the debounce and editingFinished can both fire for one
# edit — skip when the value already matches within the spin's 2-dp
# resolution, so the second arrival cannot double-redraw.
if self._frequency_common_reference_mhz is not None:
current_gauss = self._frequency_common_reference_mhz / self._mhz_per_gauss()
if abs(value_gauss - current_gauss) < 0.005:
return
old_reference = self._frequency_reference_mhz
self._frequency_common_reference_mhz = value_gauss * self._mhz_per_gauss()
if self._frequency_reference_mode == "common":
self._frequency_reference_mhz = self._frequency_common_reference_mhz
self._frequency_missing_reference_noted = False
self._apply_frequency_reference_change(old_reference)
def _apply_frequency_reference_change(self, old_reference_mhz: float | None) -> None:
"""Re-anchor the shift-space view after the effective reference changed.
The plotted data re-shifts by the new reference, so holding the old
x-window would show DIFFERENT physical frequencies — with a zoomed
window the trace exits the view entirely and the change reads as "does
nothing" or "loses traces". Instead the current window is converted to
canonical absolute MHz with the OLD active reference and back into
display space with the NEW one, so the view keeps showing the same
physical frequencies; the per-``unit:mode`` limit stash entry is
invalidated because its stored window belonged to the old reference.
In Run-field mode the per-trace references differ, so the window can
only follow one anchor — the ACTIVE dataset's reference (the same
anchor the limit boxes and view-window inversion use); overlay members
re-anchor relative to it. Called only when the reference state changed;
in absolute mode the reference is display-irrelevant, so this is a
no-op (no window work, no redraw).
"""
if not self._is_frequency_plot_panel() or not self._is_frequency_shift_mode():
return
old_ref = float(old_reference_mhz or 0.0)
new_ref = self._active_reference_mhz()
if old_ref != new_ref:
unit = self._current_frequency_x_unit
mode = self._frequency_axis_mode
x_min, x_max, y_min, y_max = self.get_view_limits()
canonical_lo = self._convert_display_limit_to_canonical_mhz(
float(x_min), unit=unit, mode=mode, reference_mhz=old_ref
)
canonical_hi = self._convert_display_limit_to_canonical_mhz(
float(x_max), unit=unit, mode=mode, reference_mhz=old_ref
)
new_x_min = self._convert_canonical_mhz_to_display_limit(
canonical_lo, unit=unit, mode=mode, reference_mhz=new_ref
)
new_x_max = self._convert_canonical_mhz_to_display_limit(
canonical_hi, unit=unit, mode=mode, reference_mhz=new_ref
)
# The stashed window for this unit:mode belonged to the old
# reference; drop it so a later mode round-trip restores the
# re-anchored window (re-stashed on switch-away), not the stale one.
self._frequency_x_limits_by_unit.pop(self._frequency_limit_mode_key(), None)
self._set_view_limits_fields(new_x_min, new_x_max, y_min, y_max)
# Redraw even when the active reference is unchanged: a Run↔Common
# switch re-shifts overlay members whose own fields differ from the
# active one, so the traces move although the window does not.
if self.has_plot_content():
self._redraw_current_view()
else:
self._apply_axis_labels(*self._default_axis_labels())
self._apply_limits()
[docs]
def get_frequency_view_window_mhz(
self,
*,
reference_dataset: MuonDataset | None = None,
) -> tuple[float, float] | None:
"""Return the current frequency-view x window in canonical absolute MHz.
The window is inverted through the ACTIVE dataset's resolved reference —
the phase-estimation window this feeds is a per-active-run concept, so an
explicit *reference_dataset* (when the caller knows the active run)
overrides the stored active reference.
"""
if not self._is_frequency_plot_panel() or not self._has_mpl:
return None
x_min, x_max, _y_min, _y_max = self.get_view_limits()
# Temporarily adopt the caller's reference dataset (if any) so the
# active-reference-based inversion resolves the intended run.
reference_mhz = self._frequency_reference_mhz
if reference_dataset is not None:
dataset_reference = self._resolve_reference_mhz_for_dataset(reference_dataset)
if dataset_reference is not None:
reference_mhz = dataset_reference
saved_reference = self._frequency_reference_mhz
self._frequency_reference_mhz = reference_mhz
try:
lo = self._convert_display_limit_to_canonical_mhz(
float(x_min), unit=self._current_frequency_x_unit, mode=self._frequency_axis_mode
)
hi = self._convert_display_limit_to_canonical_mhz(
float(x_max), unit=self._current_frequency_x_unit, mode=self._frequency_axis_mode
)
finally:
self._frequency_reference_mhz = saved_reference
return (lo, hi) if lo <= hi else (hi, lo)
def _switch_frequency_axis_display(
self,
*,
unit: str | None = None,
mode: str | None = None,
force: bool = False,
) -> None:
"""Switch frequency-axis display unit/mode with a single redraw.
Each ``unit:mode`` pair keeps its own stashed x-window, so switching back
and forth restores each view's limits. When no stash exists, the current
window is converted through canonical MHz into the new mode.
"""
if not self._is_frequency_plot_panel():
return
old_unit = self._current_frequency_x_unit
old_mode = self._frequency_axis_mode
new_unit = old_unit if unit is None else str(unit)
new_mode = old_mode if mode is None else str(mode)
if new_mode not in _FREQUENCY_AXIS_MODES:
new_mode = "absolute"
if not force and new_unit == old_unit and new_mode == old_mode:
return
current_x_min, current_x_max, current_y_min, current_y_max = self.get_view_limits()
old_key = self._frequency_limit_mode_key(unit=old_unit, mode=old_mode)
self._frequency_x_limits_by_unit[old_key] = (float(current_x_min), float(current_x_max))
new_key = self._frequency_limit_mode_key(unit=new_unit, mode=new_mode)
if new_key in self._frequency_x_limits_by_unit:
new_x_min, new_x_max = self._frequency_x_limits_by_unit[new_key]
else:
canonical_min = self._convert_display_limit_to_canonical_mhz(
current_x_min,
unit=old_unit,
mode=old_mode,
)
canonical_max = self._convert_display_limit_to_canonical_mhz(
current_x_max,
unit=old_unit,
mode=old_mode,
)
new_x_min = self._convert_canonical_mhz_to_display_limit(
canonical_min,
unit=new_unit,
mode=new_mode,
)
new_x_max = self._convert_canonical_mhz_to_display_limit(
canonical_max,
unit=new_unit,
mode=new_mode,
)
# A displayed unit-area density is rescaled by the dν/dB Jacobian when
# the x unit changes; the y-limit fields track it so the view window
# stays on the same physical slice of the distribution. Mirroring the
# x machinery above, a per-mode stash restores a previously visited
# mode's y window exactly (the 3-decimal limit fields would otherwise
# quantize a converted-back value); an unvisited mode converts by the
# factor ratio. Keyed off the same label-driving dataset as the axis
# labels; for calibrated spectra both factors are 1 and the y window
# carries through untouched (no stash entry).
new_y_min, new_y_max = float(current_y_min), float(current_y_max)
density_dataset = (
self._current_datasets[0] if self._current_datasets else self._current_dataset
)
factor_old = self._density_factor_for_unit(density_dataset, old_unit)
factor_new = self._density_factor_for_unit(density_dataset, new_unit)
if factor_new != factor_old:
self._frequency_y_limits_by_unit[old_key] = (new_y_min, new_y_max)
if new_key in self._frequency_y_limits_by_unit:
new_y_min, new_y_max = self._frequency_y_limits_by_unit[new_key]
elif factor_old > 0.0:
ratio = factor_new / factor_old
new_y_min *= ratio
new_y_max *= ratio
self._current_frequency_x_unit = new_unit
self._frequency_axis_mode = new_mode
self._set_view_limits_fields(new_x_min, new_x_max, new_y_min, new_y_max)
if self.has_plot_content():
self._redraw_current_view()
else:
self._apply_axis_labels(*self._default_axis_labels())
self._apply_limits()
def _current_axis_labels(self) -> tuple[str, str]:
"""Return the axis labels for the currently plotted datasets."""
dataset = self._current_datasets[0] if self._current_datasets else self._current_dataset
return self._axis_labels_for_dataset(dataset, self._current_polarization_axis)
def _apply_axis_labels(self, x_label: str, y_label: str) -> None:
"""Apply axis labels and keep the limit-unit helpers in sync."""
# A pinned custom x-label wins over the recomputed one so it is not wiped
# by the show-time replot (see set_custom_x_axis_label).
if self._custom_x_axis_label is not None:
x_label = self._custom_x_axis_label
self._ax.set_xlabel(x_label)
self._ax.set_ylabel(y_label)
self._apply_x_axis_decimation_indicator(self._ax)
if hasattr(self, "_x_unit_label"):
self._x_unit_label.setText(self._display_x_unit_suffix())
if hasattr(self, "_y_unit_label"):
self._y_unit_label.setText(self._display_y_unit_suffix(y_label))
def _x_axis_label_color(self, ax) -> str:
"""Return the current x-axis label color for ``ax``."""
xaxis = getattr(ax, "xaxis", None)
label = getattr(xaxis, "label", None)
color_getter = getattr(label, "get_color", None)
if callable(color_getter):
return str(color_getter())
return "black"
def _x_axis_tick_color(self, ax) -> str:
"""Return the current x-axis tick-label color for ``ax``."""
xaxis = getattr(ax, "xaxis", None)
ticklabels_getter = getattr(xaxis, "get_ticklabels", None)
if callable(ticklabels_getter):
for tick in ticklabels_getter():
color_getter = getattr(tick, "get_color", None)
if callable(color_getter):
return str(color_getter())
return self._x_axis_label_color(ax)
def _reset_decimation_view_state(self) -> None:
"""Reset per-view decimation tracking: flag, chip counters, chip artists.
Removing and clearing the chip artists here (every plot path resets
before drawing) keeps the per-axis dict from accumulating entries for
axes that ``clf()`` has already destroyed.
"""
self._decimation_applied_for_current_view = False
self._decimation_points_shown = 0
self._decimation_points_total = 0
artists = getattr(self, "_decimation_chip_artists", None)
if artists:
for artist in artists.values():
try:
artist.remove()
except (ValueError, NotImplementedError):
pass # the axis was already cleared/destroyed
artists.clear()
@staticmethod
def _format_point_count(count: int) -> str:
"""Human-readable point count for the decimation chip (1.2M, 4.0k, 312)."""
value = float(count)
if value >= 1e6:
return f"{value / 1e6:.1f}M"
if value >= 1e3:
return f"{value / 1e3:.1f}k"
return str(int(value))
_DECIMATION_TOOLTIP = (
"Display decimated for responsiveness — zoom in for full resolution. "
"Fits, transforms and exports always use every point."
)
def _apply_x_axis_decimation_indicator(self, ax) -> None:
"""Maintain the corner chip that flags a display-decimated view.
A small translucent badge ("4.0k of 1.2M pts") in the plot corner —
it disappears once the user zooms in far enough that every visible
point is rendered, which itself teaches how the decimation behaves.
The canvas tooltip carries the full explanation, including that only
the *display* is decimated.
"""
artists = getattr(self, "_decimation_chip_artists", None)
if artists is None:
artists = {}
self._decimation_chip_artists = artists
existing = artists.pop(id(ax), None)
if existing is not None:
try:
existing.remove()
except (ValueError, NotImplementedError):
pass # axis was cleared; the artist is already gone
active = bool(getattr(self, "_decimation_applied_for_current_view", False))
canvas = getattr(self, "_canvas", None)
if not active:
if canvas is not None and hasattr(canvas, "setToolTip"):
canvas.setToolTip("")
return
shown = int(getattr(self, "_decimation_points_shown", 0))
total = int(getattr(self, "_decimation_points_total", 0))
if total > 0 and shown > 0:
chip_text = (
f"{self._format_point_count(shown)} of {self._format_point_count(total)} pts"
)
else:
chip_text = "decimated display"
text_fn = getattr(ax, "text", None)
if callable(text_fn):
artists[id(ax)] = text_fn(
0.99,
0.015,
chip_text,
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=7,
color="white",
zorder=20,
bbox={
"boxstyle": "round,pad=0.35",
"facecolor": "0.25",
"alpha": 0.65,
"edgecolor": "none",
},
)
if canvas is not None and hasattr(canvas, "setToolTip"):
canvas.setToolTip(self._DECIMATION_TOOLTIP)
[docs]
def decimation_chip_text(self, ax=None) -> str | None:
"""Return the decimation chip text on *ax* (None when not displayed)."""
artists = getattr(self, "_decimation_chip_artists", {})
artist = artists.get(id(ax if ax is not None else self._ax))
return str(artist.get_text()) if artist is not None else None
def _on_frequency_x_unit_changed(self, _index: int) -> None:
"""Switch the displayed frequency x-axis between MHz and Gauss."""
if not self._is_frequency_plot_panel() or not hasattr(self, "_frequency_x_unit_combo"):
return
new_unit = str(self._frequency_x_unit_combo.currentData() or "frequency_mhz")
self._switch_frequency_axis_display(unit=new_unit)
[docs]
def active_domain(self) -> str:
"""Return the fixed domain represented by this panel."""
return self._domain
[docs]
def has_plot_content(self) -> bool:
"""Return True when the panel currently holds plotted datasets."""
return bool(self._current_dataset is not None or self._current_datasets)
def _current_navigation_mode(self) -> str:
"""Return active Matplotlib nav mode as ``none``, ``pan``, or ``zoom``."""
toolbar = getattr(self, "_nav_toolbar", None)
if toolbar is None:
return "none"
mode = getattr(toolbar, "mode", None)
if mode is None:
return "none"
name = getattr(mode, "name", None)
if isinstance(name, str):
lowered = name.lower()
if "pan" in lowered:
return "pan"
if "zoom" in lowered:
return "zoom"
return "none"
mode_text = str(mode).strip().lower()
if "pan" in mode_text:
return "pan"
if "zoom" in mode_text:
return "zoom"
return "none"
def _sync_navigation_buttons(self) -> None:
"""Mirror current Matplotlib nav mode on Pan/Zoom toggle buttons."""
if not hasattr(self, "_pan_btn") or not hasattr(self, "_zoom_btn"):
return
mode = self._current_navigation_mode()
self._pan_btn.blockSignals(True)
self._zoom_btn.blockSignals(True)
self._pan_btn.setChecked(mode == "pan")
self._zoom_btn.setChecked(mode == "zoom")
self._pan_btn.blockSignals(False)
self._zoom_btn.blockSignals(False)
def _set_navigation_mode(self, mode: str) -> None:
"""Activate/deactivate Matplotlib pan/zoom mode."""
toolbar = getattr(self, "_nav_toolbar", None)
if toolbar is None:
self._sync_navigation_buttons()
return
target = mode.lower().strip()
current = self._current_navigation_mode()
if target == "pan":
if current == "zoom":
toolbar.zoom()
current = self._current_navigation_mode()
if current != "pan":
toolbar.pan()
elif target == "zoom":
if current == "pan":
toolbar.pan()
current = self._current_navigation_mode()
if current != "zoom":
toolbar.zoom()
else:
if current == "pan":
toolbar.pan()
elif current == "zoom":
toolbar.zoom()
self._sync_navigation_buttons()
def _on_pan_button_clicked(self, checked: bool) -> None:
"""Toggle Matplotlib pan mode from toolbar button."""
self._set_navigation_mode("pan" if checked else "none")
def _on_zoom_button_clicked(self, checked: bool) -> None:
"""Toggle Matplotlib zoom mode from toolbar button."""
self._set_navigation_mode("zoom" if checked else "none")
def _connect_axis_limit_callbacks(self, axes: list[object]) -> None:
"""Attach x/y-limit listeners used to mirror interactive nav updates."""
self._disconnect_axis_limit_callbacks()
self._axis_limit_callback_ids = []
for axis_obj in axes:
callbacks = getattr(axis_obj, "callbacks", None)
if callbacks is None:
continue
connect = getattr(callbacks, "connect", None)
if connect is None:
continue
x_cid = connect("xlim_changed", self._on_axis_limits_changed)
y_cid = connect("ylim_changed", self._on_axis_limits_changed)
self._axis_limit_callback_ids.append((axis_obj, x_cid, y_cid))
def _disconnect_axis_limit_callbacks(self) -> None:
"""Disconnect previously attached x/y-limit listeners."""
callback_ids = getattr(self, "_axis_limit_callback_ids", None)
if not callback_ids:
return
for axis_obj, x_cid, y_cid in callback_ids:
callbacks = getattr(axis_obj, "callbacks", None)
disconnect = getattr(callbacks, "disconnect", None)
if disconnect is None:
continue
for cid in (x_cid, y_cid):
try:
disconnect(cid)
except Exception:
continue
def _set_limit_field_value(self, field: FloatLimitField, value: float) -> None:
"""Set a limit field value without signal churn."""
field.blockSignals(True)
field.setValue(float(value))
field.blockSignals(False)
def _sync_limits_from_axes(self, source_axis: object | None = None) -> bool:
"""Update x/y limit fields from current Matplotlib axis limits."""
if not self._has_mpl or self._syncing_limits_from_axes:
return False
self._syncing_limits_from_axes = True
try:
if self._subplot_axes_by_polarization:
subplot_axes = list(self._subplot_axes_by_polarization.values())
if source_axis is not None and not any(
source_axis is axis for axis in subplot_axes
):
return False
axis_obj = None
if source_axis in self._subplot_axes_by_polarization.values():
axis_obj = source_axis
else:
ordered = self._all_mode_axes_order()
if ordered:
axis_obj = self._subplot_axes_by_polarization.get(ordered[0])
if axis_obj is None:
axis_obj = next(iter(self._subplot_axes_by_polarization.values()), None)
if axis_obj is None or not hasattr(axis_obj, "get_xlim"):
return False
x0, x1 = axis_obj.get_xlim()
self._set_limit_field_value(
self._x_min,
self._convert_frequency_axis_limit_to_control_value(x0),
)
self._set_limit_field_value(
self._x_max,
self._convert_frequency_axis_limit_to_control_value(x1),
)
for axis_key, subplot_axis in self._subplot_axes_by_polarization.items():
if not hasattr(subplot_axis, "get_ylim"):
continue
y0, y1 = subplot_axis.get_ylim()
lo, hi = (float(y0), float(y1)) if y0 <= y1 else (float(y1), float(y0))
self._y_limits_by_polarization[axis_key] = (lo, hi)
current_axis = self._current_polarization_axis
if current_axis in self._subplot_axes_by_polarization:
current_obj = self._subplot_axes_by_polarization[current_axis]
if hasattr(current_obj, "get_ylim"):
y0, y1 = current_obj.get_ylim()
self._set_limit_field_value(self._y_min, y0)
self._set_limit_field_value(self._y_max, y1)
else:
self._sync_y_controls_with_visible_axis()
return True
if not hasattr(self._ax, "get_xlim") or not hasattr(self._ax, "get_ylim"):
return False
if source_axis is not None and source_axis is not self._ax:
return False
x0, x1 = self._ax.get_xlim()
y0, y1 = self._ax.get_ylim()
self._set_limit_field_value(
self._x_min,
self._convert_frequency_axis_limit_to_control_value(x0),
)
self._set_limit_field_value(
self._x_max,
self._convert_frequency_axis_limit_to_control_value(x1),
)
self._set_limit_field_value(self._y_min, y0)
self._set_limit_field_value(self._y_max, y1)
self._cache_current_y_limits_for_axis()
self._emit_view_limits_changed()
return True
finally:
self._syncing_limits_from_axes = False
def _is_reconstruction_view(self) -> bool:
"""True when the panel shows a MaxEnt reconstruction layout.
Reconstruction subplots are keyed ``recon:<run>:<idx>`` /
``recon:combined:<run>``. They are drawn with plain ``ax.plot`` (never
decimated) and ``_redraw_current_view`` cannot rebuild them — it would
fall through to a plain dataset plot — so they must be excluded from the
viewport-refresh path.
"""
return any(
isinstance(key, str) and key.startswith("recon:")
for key in self._subplot_axes_by_polarization
)
def _schedule_viewport_refresh(self) -> bool:
"""Coalesce viewport-triggered density refreshes onto the next event loop turn.
Returns ``True`` when a deferred refresh will genuinely run — either it
was armed now, or one is already pending (a queued refresh covers this
request). Returns ``False`` when a guard bails (no mpl, no datasets, a
refresh already in progress, or a reconstruction view that
``_redraw_current_view`` cannot rebuild), so callers can fall back to a
synchronous draw rather than leave the canvas undrawn.
"""
if (
not self._has_mpl
or not self._current_datasets
or self._viewport_refresh_in_progress
# MaxEnt reconstructions are not decimated and cannot be rebuilt by
# _redraw_current_view; refreshing one would replace it with a plain
# dataset plot. (Pre-existing: limit-field edits also route here.)
or self._is_reconstruction_view()
):
return False
if self._viewport_refresh_pending:
# A queued refresh already covers this request; it will draw once.
return True
self._viewport_refresh_pending = True
QTimer.singleShot(0, self._apply_viewport_refresh)
return True
def _apply_viewport_refresh(self) -> None:
"""Re-render the current view using the latest visible-axis limits."""
if not self._viewport_refresh_pending:
return
self._viewport_refresh_pending = False
if not self._current_datasets or self._viewport_refresh_in_progress:
return
self._viewport_refresh_in_progress = True
try:
self._redraw_current_view()
finally:
self._viewport_refresh_in_progress = False
def _on_axis_limits_changed(self, axis_obj) -> None:
"""Sync limit controls when Matplotlib axes change via pan/zoom."""
synced = self._sync_limits_from_axes(source_axis=axis_obj)
if not synced:
return
# A limit change while a navigation tool (Zoom/Pan) is armed is a genuine
# interactive gesture — a rubber-band zoom or pan drag can only happen in
# that mode. That is the user taking control of the view, exactly like a
# manual limit-field edit, so drop the persistent Auto toggles: otherwise
# the next redraw's ``_apply_auto_limits_if_enabled`` reframes the gesture
# straight back to the data extent (the reported friction). Programmatic
# limit changes from auto-framing or field edits run with nav mode
# ``"none"`` and are deliberately left alone. The lock itself is set at
# gesture end (``_on_canvas_button_release`` with a nav tool armed), so
# a completed zoom/pan holds its window across later run/polarization
# switches exactly like a typed limit; re-enabling Auto X/Y is the
# explicit escape hatch that releases it.
if self._current_navigation_mode() != "none":
self._clear_auto_limit_toggles()
if not self._viewport_refresh_in_progress:
self._schedule_viewport_refresh()
def _clear_auto_limit_toggles(self) -> None:
"""Un-check Auto X/Y without re-entering their ``clicked`` handlers.
``setChecked(False)`` does not emit ``clicked`` (only ``toggled``), so
this clears the persistent toggles without re-running the auto path —
the same pattern used by ``_on_x_limit_field_edited``. Both axes are
cleared even for a constrained (x- or y-only) zoom, since the limit
callback cannot tell which axis moved; "zoom turns off auto" is the
simplest mental model and the un-zoomed axis simply keeps its limits.
"""
if self._auto_x_btn.isChecked():
self._auto_x_btn.setChecked(False)
if self._auto_y_btn.isChecked():
self._auto_y_btn.setChecked(False)
def _on_canvas_draw_event(self, _event) -> None:
"""Keep nav buttons and limit controls aligned after Matplotlib redraws."""
self._sync_navigation_buttons()
if self._current_navigation_mode() != "none":
self._sync_limits_from_axes()
def _on_limit_fields_edited(self) -> None:
"""Apply edited limits and refresh display density for the new viewport."""
# An explicit limit edit is the user taking control: hold these limits
# across later content switches rather than reframing over them.
self._limits_user_locked = True
self._apply_limits(schedule_viewport_refresh=True)
def _on_x_limit_field_edited(self) -> None:
"""Apply a manual X-limit edit, disabling Auto X so it is not overwritten.
``setChecked(False)`` does not emit ``clicked``, so this does not re-enter
the auto path; it only clears the persistent toggle.
"""
if self._auto_x_btn.isChecked():
self._auto_x_btn.setChecked(False)
self._on_limit_fields_edited()
def _on_y_limit_field_edited(self) -> None:
"""Apply a manual Y-limit edit, disabling Auto Y so it is not overwritten."""
if self._auto_y_btn.isChecked():
self._auto_y_btn.setChecked(False)
self._on_limit_fields_edited()
def _active_label_field_key(self) -> str:
"""Return the label field that should currently be shown/used.
The per-group preference when a Data Group is active, otherwise the
default. This is the stored *intent*, which the combo targets on every
rebuild — so a saved custom column that is not yet offered (e.g. just
after project load) is selected the moment the host pushes it in.
"""
if self._active_label_group_id is None:
return self._default_label_field
return self._label_field_by_group.get(
str(self._active_label_group_id), self._default_label_field
)
def _rebuild_label_field_combo(self) -> None:
"""Populate the label-field combo with the built-ins plus custom columns.
Targets the active label-field intent (not the transient combo selection)
so adding/removing/renaming custom columns — or restoring a project whose
saved label is a not-yet-offered custom column — lands on the right entry.
"""
combo = self._label_field_combo
target = self._active_label_field_key()
blocker = QSignalBlocker(combo)
combo.clear()
for display, key in _LABEL_FIELDS:
combo.addItem(display, userData=key)
for display, key in self._custom_label_fields:
combo.addItem(display, userData=key)
idx = combo.findData(target)
if idx < 0:
idx = combo.findData("run")
if idx >= 0:
combo.setCurrentIndex(idx)
del blocker
[docs]
def set_custom_label_fields(self, fields: list[tuple[str, str]]) -> None:
"""Set the data-browser custom columns offered as legend-label options.
``fields`` is a list of ``(display_label, "custom:<id>")`` pairs. The
combo is rebuilt (selection preserved) and, if the active label field is
a custom column whose label changed, the plot is redrawn so the legend
tracks the rename.
"""
normalized = [(str(label), str(key)) for label, key in fields]
if normalized == self._custom_label_fields:
return
active_is_custom = self._is_custom_field(self._label_field_combo.currentData())
self._custom_label_fields = normalized
self._rebuild_label_field_combo()
if active_is_custom and self._has_mpl and self._current_datasets:
self._redraw_current_view()
[docs]
def set_custom_values_by_run(self, values_by_run: dict[int, dict[str, str]]) -> None:
"""Set the ``run_number -> {column-id: value}`` custom-label fallback.
Datasets that do not carry ``custom_fields`` inline — averaged FFT
spectra copy their metadata from a per-group source that has none — can
still resolve a custom-column label from this host-provided map, keyed by
run number. Redraws when a custom field is active so the change is
reflected immediately (e.g. a value edited after the spectrum was drawn).
"""
normalized = {
int(run): {str(k): str(v) for k, v in fields.items()}
for run, fields in values_by_run.items()
if isinstance(fields, dict)
}
if normalized == self._custom_values_by_run:
return
self._custom_values_by_run = normalized
active_is_custom = self._is_custom_field(self._label_field_combo.currentData())
if active_is_custom and self._has_mpl and self._current_datasets:
self._redraw_current_view()
def _is_custom_field(self, field: object) -> bool:
"""Whether *field* names a custom data-browser column.
Most custom columns use ``custom:<hash>`` ids, but the special Angle
column uses the bare id ``"angle"`` — so a prefix test alone misses it
and its label would wrongly fall through to the generic-metadata branch.
A bare id is recognised via the offered custom-field list.
"""
if not isinstance(field, str):
return False
if field.startswith("custom:"):
return True
return any(field == key for _, key in self._custom_label_fields)
def _is_valid_label_field(self, field: object) -> bool:
"""Whether ``field`` is a selectable label key (built-in or custom column).
Any ``custom:`` key is accepted even before its column is pushed in, so a
saved selection survives project load regardless of restore ordering; an
unknown custom column simply falls back to the run label until resolved.
"""
if not isinstance(field, str):
return False
if self._is_custom_field(field):
return True
return field in {key for _, key in _LABEL_FIELDS}
def _is_restorable_label_field(self, field: object) -> bool:
"""Accept a saved label key even if its custom column is not yet offered.
Restore can run before ``set_custom_label_fields`` pushes the project's
custom columns in, so a bare custom id (e.g. the Angle column's
``"angle"``) would fail :meth:`_is_valid_label_field` and be silently
reset to ``run``. Any non-empty string is preserved as a pending intent;
:meth:`_rebuild_label_field_combo` selects it once the column is offered
and otherwise degrades to the run label at render time.
"""
return isinstance(field, str) and field != ""
def _custom_field_value(self, dataset: MuonDataset, field: str) -> str | None:
"""Resolve a ``custom:<id>`` label key to a dataset's stored text.
Prefers the dataset's own inline ``custom_fields`` and falls back to the
host-provided per-run map (:meth:`set_custom_values_by_run`) so derived
datasets that carry no inline custom fields — e.g. averaged FFT spectra —
still resolve the column by run number.
"""
fields = dataset.metadata.get("custom_fields")
if isinstance(fields, dict):
value = fields.get(field)
if value is not None and str(value) != "":
return str(value)
run_number = getattr(dataset, "run_number", None)
if run_number is not None:
mapped = self._custom_values_by_run.get(int(run_number))
if isinstance(mapped, dict):
value = mapped.get(field)
if value is not None and str(value) != "":
return str(value)
return None
def _dataset_label_for(self, dataset: MuonDataset) -> str:
"""Return the legend label for *dataset* using the selected label field."""
field = self._label_field_combo.currentData()
if field == "run":
return str(dataset.run_label)
if self._is_custom_field(field):
value = self._custom_field_value(dataset, field)
return value if value is not None else str(dataset.run_label)
run = dataset.run
val = dataset.metadata.get(field)
if val is None and run is not None:
val = run.metadata.get(field)
if val is None:
return str(dataset.run_label)
if field == "field":
try:
return f"{float(val):.1f} G"
except (ValueError, TypeError):
pass
elif field == "temperature":
try:
return f"{float(val):.2f} K"
except (ValueError, TypeError):
pass
return str(val)
def _on_label_field_changed(self) -> None:
"""Re-draw the current plot using the newly selected label field."""
field = self._label_field_combo.currentData()
if field is None:
field = "run"
if self._active_label_group_id is None:
self._default_label_field = str(field)
else:
self._label_field_by_group[str(self._active_label_group_id)] = str(field)
if not self._has_mpl or not self._current_datasets:
return
self._redraw_current_view()
def _normalize_time_view_mode(self, mode: object) -> str:
"""Normalize a stored time-view token to a supported internal key.
Unknown tokens (including ``integral_scan``, which renders in its own
central workspace panel rather than here) fall back to
``fb_asymmetry``.
"""
token = str(mode or "").strip().lower().replace(" ", "_")
if token in {"groups", "group", "individual_groups", "grouped", "grouped_counts"}:
return "groups"
if token in {"raw_counts", "raw", "raw_count"}:
return "raw_counts"
return "fb_asymmetry"
def _time_view_display_text(self, mode: str) -> str:
"""Return user-facing label for a time-view mode."""
normalized = self._normalize_time_view_mode(mode)
for label, key in _TIME_VIEW_FIELDS:
if key == normalized:
return label
return "FB Asymmetry"
def _on_time_view_mode_changed(self, _index: int) -> None:
"""Propagate explicit time-view changes from the main plot toolbar."""
if not hasattr(self, "_time_view_combo"):
return
mode = self._normalize_time_view_mode(self._time_view_combo.currentData())
if mode == self._current_time_view_mode:
return
self._current_time_view_mode = mode
self._refresh_log_counts_visibility()
self.time_view_changed.emit(mode)
[docs]
def current_time_view_mode(self) -> str:
"""Return current main time-domain plot mode."""
return self._current_time_view_mode
[docs]
def set_time_view_modes(
self,
modes: list[str],
current_mode: str | None = None,
) -> None:
"""Update the explicit time-domain view selector."""
if not hasattr(self, "_time_view_combo"):
return
cleaned: list[str] = []
for mode in modes:
normalized = self._normalize_time_view_mode(mode)
if normalized not in cleaned:
cleaned.append(normalized)
if not cleaned:
cleaned = ["fb_asymmetry"]
selected = self._normalize_time_view_mode(
self._current_time_view_mode if current_mode is None else current_mode
)
if selected not in cleaned:
selected = cleaned[0]
self._available_time_view_modes = list(cleaned)
self._current_time_view_mode = selected
self._time_view_combo.blockSignals(True)
self._time_view_combo.clear()
for mode in cleaned:
self._time_view_combo.addItem(self._time_view_display_text(mode), mode)
idx = self._time_view_combo.findData(selected)
if idx < 0:
idx = 0
self._time_view_combo.setCurrentIndex(idx)
self._time_view_combo.blockSignals(False)
self._time_view_combo.setEnabled(len(cleaned) > 1)
self._refresh_log_counts_visibility()
[docs]
def set_current_time_view_mode(self, mode: str, *, emit_signal: bool = False) -> None:
"""Select the active time-domain view mode."""
if not hasattr(self, "_time_view_combo"):
return
normalized = self._normalize_time_view_mode(mode)
if normalized not in self._available_time_view_modes:
normalized = self._available_time_view_modes[0]
idx = self._time_view_combo.findData(normalized)
if idx < 0:
idx = 0
previous = self._time_view_combo.blockSignals(not emit_signal)
self._time_view_combo.setCurrentIndex(idx)
self._time_view_combo.blockSignals(previous)
self._current_time_view_mode = normalized
self._refresh_log_counts_visibility()
def _refresh_log_counts_visibility(self) -> None:
"""Show the log-scale toggle only on the raw-counts view."""
checkbox = getattr(self, "_log_counts_checkbox", None)
if checkbox is None:
return
checkbox.setVisible(self._current_time_view_mode == "raw_counts")
def _log_counts_active(self) -> bool:
"""True when the log-count diagnostic should apply to the render."""
return bool(
getattr(self, "_log_counts_enabled", False)
and self._current_time_view_mode == "raw_counts"
)
def _on_log_counts_toggled(self, checked: bool) -> None:
"""Persist and apply the log-count diagnostic scale."""
self._log_counts_enabled = bool(checked)
self._redraw_current_view()
def _apply_log_counts_scale(self) -> None:
"""Switch the raw-count subplots to a log y-axis (diagnostic view).
Applied after the linear limits so the axis is converted from already
positive count limits — matplotlib then autoscales to the positive data
and silently drops the non-positive (e.g. empty) bins.
"""
if not self._has_mpl or not self._log_counts_active():
return
axes = list(getattr(self, "_subplot_axes_by_polarization", {}).values())
if not axes:
return
for ax in axes:
ax.set_yscale("log")
ax.relim()
ax.autoscale(axis="y")
self._canvas.draw_idle()
[docs]
def is_overlay_enabled(self) -> bool:
"""Return whether multi-selection overlays are currently enabled."""
if not self._has_mpl:
return True
return bool(getattr(self, "_overlay_checkbox", None).isChecked())
[docs]
def set_overlay_enabled(self, enabled: bool, *, emit_signal: bool = False) -> None:
"""Set overlay mode from state restore or external UI events."""
if not self._has_mpl or not hasattr(self, "_overlay_checkbox"):
return
checkbox = self._overlay_checkbox
previous = checkbox.blockSignals(not emit_signal)
checkbox.setChecked(bool(enabled))
checkbox.blockSignals(previous)
self._sync_waterfall_controls_enabled(checkbox.isChecked())
[docs]
def is_waterfall_enabled(self) -> bool:
"""Return whether waterfall stacking is currently enabled."""
if not self._has_mpl or not hasattr(self, "_waterfall_checkbox"):
return False
return bool(self._waterfall_checkbox.isChecked())
[docs]
def set_waterfall_enabled(self, enabled: bool, *, emit_signal: bool = False) -> None:
"""Set waterfall mode from state restore or external UI events."""
if not self._has_mpl or not hasattr(self, "_waterfall_checkbox"):
return
checkbox = self._waterfall_checkbox
previous = checkbox.blockSignals(not emit_signal)
checkbox.setChecked(bool(enabled))
checkbox.blockSignals(previous)
self._sync_waterfall_controls_enabled(self._overlay_checkbox.isChecked())
[docs]
def waterfall_offset(self) -> float | None:
"""Return the manual waterfall Δ, or ``None`` when spacing is automatic."""
return self._waterfall_offset
[docs]
def set_waterfall_offset(self, offset: float | None, *, emit_signal: bool = False) -> None:
"""Set the manual waterfall Δ (``None`` for auto), updating the field."""
if not self._has_mpl or not hasattr(self, "_waterfall_delta_field"):
self._waterfall_offset = None if offset is None else float(offset)
return
self._waterfall_offset = None if offset is None else float(offset)
field = self._waterfall_delta_field
previous = field.blockSignals(True)
if self._waterfall_offset is None:
field.set_unset("Auto")
else:
field.setValue(self._waterfall_offset)
field.blockSignals(previous)
if emit_signal:
self.waterfall_changed.emit()
def _sync_waterfall_controls_enabled(self, overlay_enabled: bool) -> None:
"""Enable the waterfall controls only while overlay mode is active.
Turning overlay off unchecks waterfall (a stacked view makes no sense
for a single displayed trace) and disables its controls; the manual-Δ
field additionally follows the waterfall checkbox.
"""
if not self._has_mpl or not hasattr(self, "_waterfall_checkbox"):
return
overlay_on = bool(overlay_enabled)
self._waterfall_checkbox.setEnabled(overlay_on)
if not overlay_on and self._waterfall_checkbox.isChecked():
previous = self._waterfall_checkbox.blockSignals(True)
self._waterfall_checkbox.setChecked(False)
self._waterfall_checkbox.blockSignals(previous)
self._waterfall_delta_field.setEnabled(overlay_on and self._waterfall_checkbox.isChecked())
def _on_waterfall_checkbox_toggled(self, checked: bool) -> None:
"""Handle a user toggle of the Waterfall checkbox."""
self._waterfall_delta_field.setEnabled(bool(checked) and self._overlay_checkbox.isChecked())
self.waterfall_changed.emit()
def _on_waterfall_delta_edited(self) -> None:
"""Adopt the manual-Δ field's value (blank = auto) and re-render."""
text = self._waterfall_delta_field.text().strip()
if not text:
self._waterfall_offset = None
else:
value = float(self._waterfall_delta_field.value())
# A non-positive manual spacing is meaningless; treat it as auto.
self._waterfall_offset = value if value > 0.0 else None
if self._waterfall_offset is None:
previous = self._waterfall_delta_field.blockSignals(True)
self._waterfall_delta_field.set_unset("Auto")
self._waterfall_delta_field.blockSignals(previous)
self.waterfall_changed.emit()
[docs]
def set_active_label_group(self, group_id: str | None) -> None:
"""Switch legend label-field context between ungrouped and Data Group views."""
if not self._has_mpl:
return
normalized_group_id = None if group_id is None else str(group_id)
if normalized_group_id == self._active_label_group_id:
return
current_field = self._label_field_combo.currentData()
if current_field is None:
current_field = "run"
# Persist the outgoing context before switching.
if self._active_label_group_id is None:
self._default_label_field = str(current_field)
else:
self._label_field_by_group[str(self._active_label_group_id)] = str(current_field)
self._active_label_group_id = normalized_group_id
if normalized_group_id is None:
self._annotations = self._default_annotations
else:
self._annotations = self._annotations_by_group.setdefault(normalized_group_id, [])
self._active_annotation_idx = None
self._annotation_drag_started = False
target_field = self._default_label_field
if normalized_group_id is not None:
target_field = self._label_field_by_group.get(
normalized_group_id, self._default_label_field
)
idx = self._label_field_combo.findData(target_field)
if idx < 0:
idx = self._label_field_combo.findData("run")
target_field = "run"
if idx < 0:
return
self._label_field_combo.blockSignals(True)
self._label_field_combo.setCurrentIndex(idx)
self._label_field_combo.blockSignals(False)
if self._active_label_group_id is None:
self._default_label_field = str(target_field)
else:
self._label_field_by_group[str(self._active_label_group_id)] = str(target_field)
if self._current_datasets:
self._redraw_current_view()
def _serialize_annotations(self, annotations: list[dict]) -> list[dict[str, object]]:
"""Return serializable annotation payload from in-memory annotation dicts."""
return [{"x": ann["x"], "y": ann["y"], "text": ann["text"]} for ann in annotations]
def _deserialize_annotations(self, payload: object) -> list[dict]:
"""Return in-memory annotation dicts from serialized annotation payload."""
restored: list[dict] = []
if not isinstance(payload, list):
return restored
for ann in payload:
if not isinstance(ann, dict):
continue
try:
restored.append(
{
"x": float(ann.get("x", 0.0)),
"y": float(ann.get("y", 0.0)),
"text": str(ann.get("text", "")),
"artist": None,
}
)
except (TypeError, ValueError):
continue
return restored
[docs]
def set_counts_rebunch_provider(
self,
provider: Callable[[MuonDataset, int], tuple[np.ndarray, np.ndarray, np.ndarray] | None]
| None,
) -> None:
"""Install the counts-first re-reduction hook for display bunching.
``provider(dataset, factor)`` re-runs the dataset's recorded reduction
(deadtime/background corrections included) with the panel's bunch
factor multiplied onto the grouping ``bunching_factor``, returning
``(time, asymmetry, error)`` on the percent scale — or ``None`` when
counts-first bunching does not apply (no raw histograms, non-fixed
binning, period-combined curves), in which case
:meth:`get_analysis_dataset` falls back to the curve-level rebin.
"""
self._counts_rebunch_provider = provider
def _counts_first_rebunched(
self, dataset: MuonDataset, bunch_factor: int
) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
"""Counts-first bunched arrays via the injected hook, or ``None``.
Rebinning the already-formed asymmetry curve merges the σ = 100 %
no-information sentinels of one-sided raw bins (F·B = 0) into every
block's quadrature, inflating late-time errors on sparse data; when
the run still carries its raw histograms the provider redoes the
reduction on the counts instead.
"""
provider = self._counts_rebunch_provider
run = getattr(dataset, "run", None)
if provider is None or run is None or not getattr(run, "histograms", None):
return None
result = provider(dataset, int(bunch_factor))
if result is None:
return None
time, asymmetry, error = result
time = np.asarray(time, dtype=float)
asymmetry = np.asarray(asymmetry, dtype=float)
error = np.asarray(error, dtype=float)
if time.size == 0 or asymmetry.size != time.size or error.size != time.size:
return None
return time, asymmetry, error
[docs]
def get_analysis_dataset(self, dataset: MuonDataset | None) -> MuonDataset | None:
"""Return the dataset that should be used for plotting and fitting.
When the bunch factor is 1, the original dataset is returned. For a
larger bunch factor, a rebinned MuonDataset copy is returned with the
same metadata and run association. Runs that still carry raw
histograms are re-reduced counts-first through the host-injected
provider; histogram-less curves (co-added data, G±R combinations)
keep the curve-level :func:`rebin`.
"""
if dataset is None:
return None
if self._is_frequency_plot_panel() or self._is_frequency_domain_dataset(dataset):
return dataset
bunch_factor = self._bunch_factor.value()
if bunch_factor <= 1:
return dataset
rebunched = self._counts_first_rebunched(dataset, bunch_factor)
if rebunched is not None:
time, asymmetry, error = rebunched
else:
time, asymmetry, error = rebin(
dataset.time,
dataset.asymmetry,
dataset.error,
bunch_factor,
)
if time.size == 0 or asymmetry.size == 0 or error.size == 0:
return dataset
return MuonDataset(
time=time,
asymmetry=asymmetry,
error=error,
metadata=dict(dataset.metadata),
run=dataset.run,
)
def _raw_fit_seed_range(self, datasets: list[MuonDataset | None]) -> tuple[float, float] | None:
"""Default fit-range seed from the *untransformed* analysis time axes.
The RRF display trims the filter-edge region off the drawn arrays;
seeding the fit range from those would silently exclude raw early-time
bins from fits that always consume raw data. The seed is returned in the
canonical storage unit (time µs, or canonical frequency MHz) — NOT the
display unit — because ``_fit_x_min``/``_fit_x_max`` are stored canonical
and the drawing helpers convert them to the display axis themselves.
Applying the display transform here would store a shift/field value as if
it were canonical MHz.
"""
lows: list[float] = []
highs: list[float] = []
for dataset in datasets:
base = self.get_analysis_dataset(dataset)
if base is None:
continue
tt = np.asarray(base.time, dtype=float)
tt = tt[np.isfinite(tt)]
if tt.size:
lows.append(float(tt.min()))
highs.append(float(tt.max()))
if not lows:
return None
return min(lows), max(highs)
def _is_frequency_domain_dataset(self, dataset: MuonDataset | None) -> bool:
"""Return True when *dataset* carries frequency-domain plot metadata."""
if dataset is None:
return False
return str(dataset.metadata.get("plot_domain", "")).strip().lower() == "frequency"
def _axis_labels_for_dataset(
self,
dataset: MuonDataset | None,
axis_key: str | None,
) -> tuple[str, str]:
"""Return axis labels, preferring explicit metadata for special plots."""
if self._is_frequency_plot_panel():
y_label = r"$|F|$ (arb.)"
if dataset is not None and isinstance(dataset.metadata, dict):
raw_y_label = dataset.metadata.get("y_label")
if isinstance(raw_y_label, str) and raw_y_label.strip():
y_label = raw_y_label
# The metadata label stays canonical (1/MHz); a unit-area density is
# relabelled per the displayed x unit at display time only.
y_label = self._density_display_ylabel(dataset, y_label)
return self._display_x_label(), y_label
if dataset is not None and isinstance(dataset.metadata, dict):
x_label = dataset.metadata.get("x_label")
y_label = dataset.metadata.get("y_label")
if (
isinstance(x_label, str)
and x_label.strip()
and isinstance(y_label, str)
and y_label.strip()
):
return x_label, y_label
return "Time (µs)", self._polarization_ylabel(axis_key)
def _has_plottable_samples(self, dataset: MuonDataset | None) -> bool:
"""Return True when *dataset* contains at least one aligned sample."""
if dataset is None:
return False
return (
np.asarray(dataset.time).size > 0
and np.asarray(dataset.asymmetry).size > 0
and np.asarray(dataset.error).size > 0
)
def _visible_plot_indices(self, time: np.ndarray, mask: np.ndarray) -> np.ndarray:
"""Return indices inside the current viewport before any density reduction."""
indices = np.flatnonzero(np.asarray(mask, dtype=bool))
if indices.size <= 0:
return indices
visible_indices = indices
if self._limits_initialized:
x_lo = float(self._x_min.value())
x_hi = float(self._x_max.value())
if self._is_frequency_plot_panel():
x_lo = self._convert_frequency_control_value_to_axis_limit(x_lo)
x_hi = self._convert_frequency_control_value_to_axis_limit(x_hi)
lo, hi = (x_lo, x_hi) if x_lo <= x_hi else (x_hi, x_lo)
visible_mask = (
np.asarray(mask, dtype=bool)
& np.isfinite(time)
& (np.asarray(time, dtype=float) >= lo)
& (np.asarray(time, dtype=float) <= hi)
)
candidate = np.flatnonzero(visible_mask)
if candidate.size > 0:
visible_indices = candidate
return visible_indices
def _decimated_plot_indices(
self,
time: np.ndarray,
mask: np.ndarray,
values: np.ndarray | None = None,
*,
visible_indices: np.ndarray | None = None,
) -> np.ndarray:
"""Return bounded sample indices for display-only errorbar rendering.
Time-domain scatter uses a uniform stride — an unbiased visual sample
of noisy data (a min-max envelope would exaggerate the noise).
Frequency-domain spectra use min-max bucketing on *values* instead:
a stride can drop a narrow spectral peak entirely, and the peaks are
the physics. Callers that already computed the viewport's
``visible_indices`` pass them in to avoid a second full-array scan.
"""
if visible_indices is None:
visible_indices = self._visible_plot_indices(time, mask)
if visible_indices.size <= 0:
return visible_indices
if not self.decimation_enabled():
return visible_indices
max_points = max(1, int(getattr(self, "_max_render_points_per_trace", 4000)))
if visible_indices.size <= max_points:
return visible_indices
if values is not None and self._is_frequency_plot_panel():
return self._minmax_bucket_indices(visible_indices, np.asarray(values), max_points)
stride = max(1, int(np.ceil(visible_indices.size / float(max_points))))
sampled = visible_indices[::stride]
if sampled.size == 0:
return visible_indices[:1]
if sampled[-1] != visible_indices[-1]:
sampled = np.append(sampled, visible_indices[-1])
return sampled
@staticmethod
def _minmax_bucket_indices(
visible_indices: np.ndarray,
values: np.ndarray,
max_points: int,
) -> np.ndarray:
"""Keep the min and max sample of each display bucket (extrema survive).
Vectorised: the visible values are NaN-padded into a
``(n_buckets, bucket_len)`` grid and reduced per row — a Python loop
over ~2000 buckets would cost tens of ms per trace per pan tick.
"""
n = int(visible_indices.size)
n_buckets = max(1, max_points // 2)
bucket_len = -(-n // n_buckets) # ceil; >= 2 because n > max_points
pad = n_buckets * bucket_len - n
y = np.asarray(values, dtype=float)[visible_indices]
padded = np.concatenate([y, np.full(pad, np.nan)]) if pad else y
grid = padded.reshape(n_buckets, bucket_len)
nan_mask = np.isnan(grid)
# Real values always beat the ±inf sentinels, so argmin/argmax can
# never select padding except in all-NaN rows, which are dropped.
mins = np.argmin(np.where(nan_mask, np.inf, grid), axis=1)
maxs = np.argmax(np.where(nan_mask, -np.inf, grid), axis=1)
valid = ~nan_mask.all(axis=1)
offsets = np.arange(n_buckets, dtype=np.int64) * bucket_len
keep = np.concatenate(
[
np.asarray([0, n - 1], dtype=np.int64),
(offsets + mins)[valid],
(offsets + maxs)[valid],
]
)
return visible_indices[np.unique(keep)]
def _plot_errorbar_masked(
self,
ax,
time: np.ndarray,
asymmetry: np.ndarray,
error: np.ndarray,
mask: np.ndarray,
**kwargs,
) -> int:
"""Plot a masked errorbar series using bounded display density."""
visible_indices = self._visible_plot_indices(time, mask)
indices = self._decimated_plot_indices(
time, mask, values=asymmetry, visible_indices=visible_indices
)
if indices.size <= 0:
return 0
if self.decimation_enabled() and indices.size < visible_indices.size:
self._decimation_applied_for_current_view = True
# Per-view totals feed the corner chip ("4.0k of 1.2M pts").
self._decimation_points_shown += int(indices.size)
self._decimation_points_total += int(visible_indices.size)
ax.errorbar(
time[indices],
asymmetry[indices],
yerr=error[indices],
**kwargs,
)
return int(indices.size)
def _plot_frequency_line_masked(
self,
ax,
freq: np.ndarray,
values: np.ndarray,
error: np.ndarray,
mask: np.ndarray,
*,
color: str | None,
label: str,
linewidth: float = 1.2,
) -> int:
"""Plot one frequency-domain spectrum as a line with a shaded ±1σ band.
Every reference muSR package (WiMDA, musrview, Mantid) draws spectra
as lines, not the time-domain errorbar-dots idiom, so this is the
frequency-panel counterpart of :meth:`_plot_errorbar_masked` — it
shares the same bounded-density decimation (min-max bucketing on
*values*, applied before drawing) but renders a solid line instead of
point markers. A translucent ±1σ band replaces per-point error bars
when *error* carries any positive finite value; the common
un-averaged all-zero-error case draws no band. The line is drawn
first so an unresolved ``color=None`` (matplotlib's auto-cycle) is
pinned to the *resolved* colour actually used, keeping the band and
line the same hue rather than each independently advancing the
property cycle.
"""
visible_indices = self._visible_plot_indices(freq, mask)
indices = self._decimated_plot_indices(
freq, mask, values=values, visible_indices=visible_indices
)
if indices.size <= 0:
return 0
if self.decimation_enabled() and indices.size < visible_indices.size:
self._decimation_applied_for_current_view = True
# Per-view totals feed the corner chip ("4.0k of 1.2M pts").
self._decimation_points_shown += int(indices.size)
self._decimation_points_total += int(visible_indices.size)
x = freq[indices]
y = values[indices]
err = error[indices]
(line,) = ax.plot(
x,
y,
"-",
color=color,
linewidth=linewidth,
label=label,
zorder=2.5,
)
# Presence is decided from the full masked error array (not just the
# decimated samples drawn), matching "the dataset's error array has
# any positive finite values" — a decimated bucket could otherwise
# miss the one point that carries a real error.
has_band = bool(np.any(np.isfinite(error[mask]) & (error[mask] > 0.0)))
if has_band:
finite_err = np.isfinite(err)
band_err = np.where(finite_err, err, 0.0)
ax.fill_between(
x,
y - band_err,
y + band_err,
color=line.get_color(),
alpha=0.25,
linewidth=0,
zorder=1.5,
label="_nolegend_",
)
return int(indices.size)
def _draw_expected_larmor_marker(self, dataset: MuonDataset) -> None:
"""Mark the expected γ_μ·B Larmor position on a single displayed spectrum.
Single-run frequency view only — an overlay would need one marker per
member, which is noise rather than signal, so this is only called from
:meth:`plot_dataset`. Absent on the correlation (hyperfine-coupling)
axis, where an applied-field Larmor line is not meaningful, and absent
when the run/dataset metadata carries no applied field.
Built by hand and attached with ``add_artist`` — the same idiom
``draw_zero_line`` uses in ``styles/plots.py`` — rather than
``axvline``, so the marker can never perturb ``dataLim``/autoscale
regardless of when it is drawn relative to ``_apply_limits``. The x
position runs the expected line through the SAME per-dataset display
transform the spectrum uses (``_convert_frequency_axis_for_display`` with
this dataset), so in a shift mode it lands at ~0 for the run's own field
and in absolute mode at the field-converted line — always co-located with
the plotted spectrum.
"""
if not self._has_mpl or self._frequency_axis_is_correlation:
return
field_gauss = reference_field_gauss(getattr(dataset, "run", None), dataset)
if field_gauss is None or field_gauss <= 0.0:
return
expected_mhz = float(gauss_to_mhz(field_gauss))
if not np.isfinite(expected_mhz) or expected_mhz <= 0.0:
return
x = float(self._convert_frequency_axis_for_display(np.array([expected_mhz]), dataset)[0])
if not np.isfinite(x):
return
from matplotlib import lines as mlines
transform = self._ax.get_xaxis_transform(which="grid")
marker = mlines.Line2D(
[x, x],
[0.0, 1.0],
transform=transform,
color=tokens.TEXT_MUTED,
alpha=0.6,
linestyle="--",
linewidth=1.0,
zorder=1,
)
marker.set_label("_nolegend_")
self._ax.add_artist(marker)
self._ax.annotate(
r"$\gamma_\mu \cdot B$",
xy=(x, 0.96),
xycoords=transform,
ha="left",
va="top",
fontsize=8,
color=tokens.TEXT_MUTED,
alpha=0.6,
zorder=1,
)
def _set_frequency_reference_from_dataset(self, dataset: MuonDataset | None) -> None:
"""Resolve the ACTIVE dataset's reference for the current reference mode.
Run mode: the active dataset's own applied field (fall back to the common
value when it carries none). Common mode: the shared spin value is
authoritative and is NOT overwritten from the dataset — so browsing to a
different run cannot clobber the user's common reference. The common value
is seeded once from the first field-bearing dataset so switching to Common
starts from a sensible number.
"""
if not self._is_frequency_plot_panel():
return
self._update_correlation_axis_state(dataset)
dataset_reference = self._frequency_reference_for_dataset(dataset)
if self._frequency_common_reference_mhz is None and dataset_reference is not None:
self._frequency_common_reference_mhz = dataset_reference
if self._frequency_reference_mode == "common":
self._frequency_reference_mhz = self._frequency_common_reference_mhz
else:
self._frequency_reference_mhz = (
dataset_reference
if dataset_reference is not None
else self._frequency_common_reference_mhz
)
# Reflect the common value in the spin (disabled outside Common mode).
# The blocker keeps this programmatic write from firing valueChanged
# (and thus the step-debounce), and any debounce already pending from a
# user step is cancelled — a dataset sync must never commit a reference.
if hasattr(self, "_frequency_reference_spin"):
if hasattr(self, "_frequency_reference_debounce"):
self._frequency_reference_debounce.stop()
gauss = (
self._frequency_common_reference_mhz / self._mhz_per_gauss()
if self._frequency_common_reference_mhz is not None
else 0.0
)
with QSignalBlocker(self._frequency_reference_spin):
self._frequency_reference_spin.setValue(gauss)
def _update_correlation_axis_state(self, dataset: MuonDataset | None) -> None:
"""Lock the field-unit selector when *dataset* is a correlation spectrum.
A muoniated-radical correlation spectrum's x-axis is the muon hyperfine
coupling A_µ (MHz), not γ_µ·B, so the MHz/G/T field selector and the
applied-field reference are meaningless: they are forced to a plain MHz
absolute axis and disabled, and the axis keeps its own label from the
dataset metadata. On entry the user's unit + axis/reference mode is
stashed and on exit it is restored, so toggling the correlation mode does
not silently discard (or persist) the wrong field-unit view.
(Note: the frequency panel shows a single averaged spectrum at a time, so
keying off the active dataset is sufficient; mixed correlation/FFT
overlays are not produced by the current pipeline.)
"""
is_correlation = bool(
dataset is not None
and isinstance(dataset.metadata, dict)
and dataset.metadata.get("correlation_axis") is True
)
if is_correlation:
raw_label = dataset.metadata.get("x_label")
if isinstance(raw_label, str) and raw_label.strip():
self._frequency_correlation_x_label = raw_label
was_correlation = self._frequency_axis_is_correlation
if is_correlation == was_correlation:
return # No transition; nothing to stash, restore, or re-sync.
if is_correlation:
# Entering: stash the field-unit view and force a plain MHz axis.
self._frequency_axis_unit_before_correlation = self._current_frequency_x_unit
self._frequency_axis_mode_before_correlation = self._frequency_axis_mode
self._frequency_reference_mode_before_correlation = self._frequency_reference_mode
self._frequency_axis_is_correlation = True
self._current_frequency_x_unit = "frequency_mhz"
self._frequency_axis_mode = "absolute"
else:
# Leaving: restore the stashed field-unit view.
self._frequency_axis_is_correlation = False
self._current_frequency_x_unit = self._frequency_axis_unit_before_correlation
self._frequency_axis_mode = self._frequency_axis_mode_before_correlation
self._frequency_reference_mode = self._frequency_reference_mode_before_correlation
self._sync_frequency_axis_controls()
def _sync_frequency_axis_controls(self) -> None:
"""Push the current unit / axis-mode / reference state onto the widgets.
Keeps the unit combo, the axis-mode combo, the reference-mode combo, and
the reference spin coherent with the backing state, and disables them per
the mode: the unit combo is meaningless in ppm mode; the reference
selector applies only in shift modes; the common spin applies only in
Common + shift mode. Everything is disabled while a correlation (coupling)
axis is shown.
"""
is_correlation = self._frequency_axis_is_correlation
is_shift = self._is_frequency_shift_mode()
is_ppm = self._frequency_axis_mode == "relative_ppm"
if hasattr(self, "_frequency_x_unit_combo"):
with QSignalBlocker(self._frequency_x_unit_combo):
idx = self._frequency_x_unit_combo.findData(self._current_frequency_x_unit)
if idx >= 0:
self._frequency_x_unit_combo.setCurrentIndex(idx)
# ppm is dimensionless, so the unit selector is inert there.
self._frequency_x_unit_combo.setEnabled(not is_correlation and not is_ppm)
if hasattr(self, "_frequency_axis_mode_combo"):
with QSignalBlocker(self._frequency_axis_mode_combo):
idx = self._frequency_axis_mode_combo.findData(self._frequency_axis_mode)
if idx >= 0:
self._frequency_axis_mode_combo.setCurrentIndex(idx)
self._frequency_axis_mode_combo.setEnabled(not is_correlation)
if hasattr(self, "_frequency_reference_mode_combo"):
with QSignalBlocker(self._frequency_reference_mode_combo):
idx = self._frequency_reference_mode_combo.findData(self._frequency_reference_mode)
if idx >= 0:
self._frequency_reference_mode_combo.setCurrentIndex(idx)
self._frequency_reference_mode_combo.setEnabled(not is_correlation and is_shift)
if hasattr(self, "_frequency_reference_spin"):
self._frequency_reference_spin.setEnabled(
not is_correlation and is_shift and self._frequency_reference_mode == "common"
)
[docs]
def update_frequency_reference(self, dataset: MuonDataset | None) -> None:
"""Public entry-point: sync the reference field from *dataset* metadata.
Call this whenever the active dataset changes so the Reference spinbox
shows the correct value even before an FFT has been computed.
"""
self._set_frequency_reference_from_dataset(dataset)
def _render_empty_plot_state(self, *, alpha_text: str | None = None) -> None:
"""Render an empty but valid plot state when no plottable data is available."""
self._last_plot_time = None
self._last_plot_asymmetry = None
self._last_plot_error = None
self._last_low_count_mask = None
self._clear_waterfall_render_record()
self._reset_decimation_view_state()
self._fit_x_min = None
self._fit_x_max = None
if self._is_frequency_plot_panel():
self._frequency_reference_mhz = None
self._ax.clear()
style_axes(self._ax)
self._apply_axis_labels(
*self._axis_labels_for_dataset(None, self._current_polarization_axis)
)
self._set_alpha_label(alpha_text)
self._draw_annotations()
self._draw_fit_range_artists()
self._update_export_enabled()
self._canvas.draw_idle()
[docs]
def get_fit_dataset(self, dataset: MuonDataset | None) -> MuonDataset | None:
"""Return *dataset* restricted to the currently selected fit range."""
if dataset is None:
return None
t_min, t_max = self.get_fit_range()
if t_min is None or t_max is None:
return dataset
return dataset.time_range(t_min, t_max)
[docs]
def get_fit_range(self) -> tuple[float | None, float | None]:
"""Return the active fit range as (x_min, x_max)."""
if self._fit_x_min is None or self._fit_x_max is None:
return None, None
return float(self._fit_x_min), float(self._fit_x_max)
[docs]
def get_view_limits(self) -> tuple[float, float, float, float]:
"""Return the currently displayed x/y limits from the toolbar fields."""
if not self._has_mpl:
return 0.0, 10.0, -30.0, 30.0
return (
float(self._x_min.value()),
float(self._x_max.value()),
float(self._y_min.value()),
float(self._y_max.value()),
)
[docs]
def view_reframed_on_last_draw(self) -> bool:
"""Whether the most recent plot draw first-paint framed the axes.
Lets a caller that re-applies preserved limits after a draw (the
frequency render path) tell a same-content recompute — which must keep
the user's window — apart from a genuine first paint, whose freshly
computed smart framing must survive rather than be overwritten by stale
preserved defaults.
"""
return bool(self._last_draw_reframed)
[docs]
def set_view_limits(self, x_min: float, x_max: float, y_min: float, y_max: float) -> None:
"""Apply x/y limits through the existing toolbar-backed controls."""
if not self._has_mpl:
return
self._set_view_limits_fields(x_min, x_max, y_min, y_max)
self._apply_limits(schedule_viewport_refresh=True)
[docs]
def get_bunch_factor(self) -> int:
"""Return the currently configured plot-panel bunch factor."""
if not self._has_mpl:
return 1
return int(self._bunch_factor.value())
[docs]
def decimation_enabled(self) -> bool:
"""Return whether display-only plot decimation is enabled."""
return bool(getattr(self, "_decimation_enabled", True))
[docs]
def set_decimation_enabled(self, enabled: bool, *, redraw: bool = True) -> None:
"""Enable or disable display-only plot decimation."""
self._decimation_enabled = bool(enabled)
if redraw and getattr(self, "_current_datasets", None):
self._redraw_current_view()
[docs]
def set_bunch_factor(self, value: int, *, emit_signal: bool = True) -> None:
"""Set the plot-panel bunch factor and optionally emit the normal signal."""
if not self._has_mpl:
return
bunch_factor = max(1, int(value))
if emit_signal:
self._bunch_factor.setValue(bunch_factor)
return
previous = self._bunch_factor.blockSignals(True)
self._bunch_factor.setValue(bunch_factor)
self._bunch_factor.blockSignals(previous)
self._redraw_current_view()
def _redraw_current_view(self) -> None:
"""Redraw using the active single- or multi-dataset context."""
if self._grouped_time_subplot_datasets:
self.plot_grouped_time_domain_subplots(self._grouped_time_subplot_datasets)
return
if self._current_polarization_axis == "ALL" and self._vector_subplot_datasets:
self.plot_vector_subplots(self._vector_subplot_datasets)
return
# Preserve explicit multi-dataset views (for example, after
# plot_datasets()) even if the overlay checkbox is currently off.
if len(self._current_datasets) > 1:
self.plot_datasets(self._current_datasets)
elif self._current_dataset is not None:
self.plot_dataset(self._current_dataset)
elif self._current_datasets:
self.plot_dataset(self._current_datasets[-1])
[docs]
def set_custom_x_axis_label(self, label: str | None) -> None:
"""Pin (or clear) a custom x-axis label that survives replots.
A replot recomputes and overwrites the x-axis label, so a caller that
wants a bespoke label — for example a Fourier annotation workflow
labelling a hyperfine-coupling axis — pins it here. It is re-applied
after every render (including the show-time replot) instead of being
wiped. Pass ``None`` to restore the computed label.
"""
self._custom_x_axis_label = str(label) if label is not None else None
if not self._has_mpl:
return
if self.has_plot_content():
self._redraw_current_view()
else:
# No data yet: reflect the pinned/cleared label immediately so a
# caller that annotates before plotting still sees it.
self._apply_axis_labels(*self._default_axis_labels())
self._canvas.draw_idle()
[docs]
def add_persistent_frequency_marker(self, freq_mhz: float, label: str = "") -> None:
"""Pin a vertical frequency-line marker that survives replots.
``freq_mhz`` is a canonical (absolute) frequency in MHz; it is
display-unit converted at draw time so the marker tracks the active axis
unit. The marker is recreated on every render, so — unlike a one-off
``axvline`` drawn straight onto the axis — it is not wiped by the
show-time replot. Use for Fourier line labels (ν₁, ν₂, A_µ, γ_μ·B).
"""
freq = float(freq_mhz)
if not np.isfinite(freq):
return
self._persistent_frequency_markers.append({"freq_mhz": freq, "label": str(label or "")})
if not self._has_mpl:
return
if self.has_plot_content():
self._redraw_current_view()
else:
self._draw_persistent_frequency_markers()
self._canvas.draw_idle()
[docs]
def clear_persistent_frequency_markers(self) -> None:
"""Remove every pinned frequency-line marker and redraw."""
if not self._persistent_frequency_markers:
return
self._persistent_frequency_markers = []
if self._has_mpl and self.has_plot_content():
self._redraw_current_view()
def _emit_view_limits_changed(self) -> None:
"""Broadcast the current view limits to external UI owners."""
if not self._has_mpl:
return
self.view_limits_changed.emit(*self.get_view_limits())
def _set_alpha_label(self, text: str | None) -> None:
"""Update the header metadata label with alpha / unit info."""
if hasattr(self, "_header_meta_label"):
self._header_meta_label.setText(text or "")
def _axis_canonical_key(self, axis_text: str | None) -> str | None:
"""Normalize display/projection axis text to its identity key.
The canonical EMU vector aliases (``px``/``x`` → ``P_x`` …) and the
``ALL`` sentinel are recognised; any other non-empty label passes
through unchanged so transverse-field projection labels survive.
Returns ``None`` only for an empty/absent axis.
"""
if axis_text is None:
return None
raw = str(axis_text).strip()
if not raw:
return None
token = raw.lower().replace(" ", "")
token = token.replace("ₓ", "x").replace("ᵧ", "y").replace("ᶻ", "z")
token = token.replace("_", "")
if token in {"all", "pall"}:
return "ALL"
if token in {"px", "x"}:
return "P_x"
if token in {"py", "y"}:
return "P_y"
if token in {"pz", "z"}:
return "P_z"
return raw
[docs]
def get_current_polarization_axis(self) -> str | None:
"""Return current polarization selector key (``P_*`` or ``ALL``)."""
return self._current_polarization_axis
def _axis_key_for_dataset(
self,
dataset: MuonDataset | None,
*,
axis_override: str | None = None,
) -> str | None:
"""Return canonical polarization axis for *dataset* when available."""
if axis_override is not None:
return self._axis_canonical_key(axis_override)
if dataset is None:
return None
run = getattr(dataset, "run", None)
grouping = getattr(run, "grouping", None)
if isinstance(grouping, dict):
axis = self._axis_canonical_key(grouping.get("vector_axis"))
if axis is not None and axis != "ALL":
return axis
grouping_meta = dataset.metadata.get("grouping")
if isinstance(grouping_meta, dict):
axis = self._axis_canonical_key(grouping_meta.get("vector_axis"))
if axis is not None and axis != "ALL":
return axis
return None
@staticmethod
def _encode_fit_storage_key(run_number: int, axis_key: str | None) -> str:
"""Encode ``(run_number, axis_key)`` to a serialisable key string."""
return f"{int(run_number)}|{axis_key or ''}"
def _decode_fit_storage_key(self, value: object) -> tuple[int, str | None] | None:
"""Decode serialised fit key values."""
if not isinstance(value, str) or "|" not in value:
return None
run_token, axis_token = value.split("|", 1)
try:
run_number = int(run_token)
except (TypeError, ValueError):
return None
axis_key = self._axis_canonical_key(axis_token) if axis_token else None
if axis_key == "ALL":
axis_key = None
return run_number, axis_key
def _fit_storage_key_for_dataset(
self,
dataset: MuonDataset | None,
*,
axis_override: str | None = None,
) -> tuple[int, str | None] | None:
"""Return axis-aware fit storage key for *dataset*."""
if dataset is None:
return None
try:
run_number = int(dataset.run_number)
except (TypeError, ValueError):
return None
axis_key = self._axis_key_for_dataset(dataset, axis_override=axis_override)
return run_number, axis_key
@staticmethod
def _is_raw_counts_dataset(dataset: MuonDataset | None) -> bool:
"""True for grouped datasets built without lifetime correction.
Stored grouped/count fit overlays are on the lifetime-corrected scale,
so they must not be drawn against raw-count curves (they diverge by
e^(t/τ), ~38× at 8 µs).
"""
return (
dataset is not None
and dataset.metadata.get("grouped_time_domain_lifetime_corrected") is False
)
def _grouped_subplot_axis_key(self, dataset: MuonDataset) -> str:
"""Subplot key for a grouped-time dataset (raw and corrected builds share
a run number but live on different y scales, so raw gets a ``:raw`` suffix)."""
key = str(dataset.run_number)
if self._is_raw_counts_dataset(dataset):
key += ":raw"
return key
def _fit_curve_for_dataset(
self,
dataset: MuonDataset | None,
*,
axis_override: str | None = None,
) -> tuple | None:
"""Return best-matching fit curve payload for *dataset*."""
if self._is_raw_counts_dataset(dataset):
return None
storage_key = self._fit_storage_key_for_dataset(dataset, axis_override=axis_override)
if storage_key is not None:
fit_data = self._fit_curves_by_key.get(storage_key)
if fit_data is not None:
return fit_data
run_number, axis_key = storage_key
if axis_key is not None:
has_axis_specific_fit = any(
key_run == run_number and key_axis is not None
for key_run, key_axis in self._fit_curves_by_key
)
if has_axis_specific_fit:
return None
fit_data = self._fit_curves_by_key.get((run_number, None))
if fit_data is not None:
return fit_data
fit_data = self._fit_curves.get(run_number)
if fit_data is not None:
return fit_data
if self._fit_curve is not None and self._fit_curve_run_number == run_number:
return self._fit_curve
return None
def _fit_components_for_dataset(
self,
dataset: MuonDataset | None,
*,
axis_override: str | None = None,
) -> list[tuple[str, object]]:
"""Return best-matching additive component curves for *dataset*."""
if self._is_raw_counts_dataset(dataset):
return []
storage_key = self._fit_storage_key_for_dataset(dataset, axis_override=axis_override)
if storage_key is not None:
components = self._fit_components_by_key.get(storage_key)
if components:
return list(components)
run_number, axis_key = storage_key
if axis_key is not None:
has_axis_specific_components = any(
key_run == run_number and key_axis is not None
for key_run, key_axis in self._fit_components_by_key
)
if has_axis_specific_components:
return []
components = self._fit_components_by_key.get((run_number, None))
if components:
return list(components)
components = self._fit_components_by_run.get(run_number)
if components:
return list(components)
if self._fit_components and self._fit_curve_run_number == run_number:
return list(self._fit_components)
return []
def _fit_metadata_for_dataset(
self,
dataset: MuonDataset | None,
*,
axis_override: str | None = None,
) -> dict:
"""Return best-matching fit metadata for *dataset*."""
storage_key = self._fit_storage_key_for_dataset(dataset, axis_override=axis_override)
if storage_key is None:
return {}
meta = self._fit_metadata_by_key.get(storage_key)
if isinstance(meta, dict):
return meta
run_number, axis_key = storage_key
if axis_key is not None:
has_axis_specific_meta = any(
key_run == run_number and key_axis is not None
for key_run, key_axis in self._fit_metadata_by_key
)
if has_axis_specific_meta:
return {}
meta = self._fit_metadata_by_key.get((run_number, None))
if isinstance(meta, dict):
return meta
meta = self._fit_metadata.get(run_number)
return meta if isinstance(meta, dict) else {}
def _active_y_axis(self) -> str | None:
"""The axis whose y-limits the manual controls reflect and drive.
In the stacked multi-subplot view that is the selected (fit-target)
subplot; otherwise it is the single visible polarization axis.
"""
if self._subplot_axes_by_polarization:
return self.fit_target_projection()
return self._current_polarization_axis
def _cache_current_y_limits_for_axis(self) -> None:
"""Store current y-limits under the active (focused) axis, if any."""
axis = self._active_y_axis()
if axis is None or axis == "ALL":
return
y0 = float(self._y_min.value())
y1 = float(self._y_max.value())
lo, hi = (y0, y1) if y0 <= y1 else (y1, y0)
self._y_limits_by_polarization[axis] = (lo, hi)
def _restore_y_limits_for_axis(self, axis: str | None) -> None:
"""Restore the y-limit controls to *axis*'s cached (or live) limits."""
if axis is None or axis == "ALL":
return
limits = self._y_limits_by_polarization.get(axis)
if limits is None:
# No cached value yet — reflect the subplot's actual current limits.
ax = self._subplot_axes_by_polarization.get(axis)
if ax is not None and hasattr(ax, "get_ylim"):
try:
limits = ax.get_ylim()
except Exception:
limits = None
if limits is None:
return
self._y_min.setValue(float(limits[0]))
self._y_max.setValue(float(limits[1]))
def _axis_for_selection(self, labels: list[str]) -> str | None:
"""Map a chip selection onto the internal polarization-axis token.
One projection selected → that label (single-subplot mode); more than
one → the ``"ALL"`` sentinel (stacked-subplot mode); none → ``None``.
"""
if len(labels) > 1:
return "ALL"
return labels[0] if labels else None
def _on_projection_selection_changed(self, labels: list[str]) -> None:
"""Handle a projection chip-selection change from the header chip bar."""
self._selected_projection_labels = list(labels)
axis = self._axis_for_selection(list(labels))
if axis is None:
return
previous_axis = self._current_polarization_axis
if previous_axis != axis:
self._cache_current_y_limits_for_axis()
self._current_polarization_axis = str(axis)
self._restore_y_limits_for_axis(self._current_polarization_axis)
self._sync_y_controls_with_visible_axis()
self._update_y_limit_controls_for_axis(self._current_polarization_axis)
self._apply_limits()
self.polarization_axis_changed.emit(str(axis))
def _update_y_limit_controls_for_axis(self, axis: str | None) -> None:
"""Keep the Y controls enabled, driving the focused (selected) subplot.
In the stacked view, manual Y now applies to the selected (fit-target)
subplot rather than being disabled; auto Y still rescales every
projection subplot.
"""
in_subplots = bool(self._subplot_axes_by_polarization)
manual_tooltip = (
"Y limits apply to the selected subplot — click another subplot to switch."
if in_subplots
else ""
)
auto_tooltip = "Auto Y rescales every projection subplot." if in_subplots else ""
self._y_min.setEnabled(True)
self._y_max.setEnabled(True)
self._y_min.setToolTip(manual_tooltip)
self._y_max.setToolTip(manual_tooltip)
if hasattr(self, "_auto_y_btn"):
self._auto_y_btn.setEnabled(True)
self._auto_y_btn.setToolTip(auto_tooltip)
def _all_mode_axes_order(self) -> list[str]:
"""Return the axis order currently visible in ALL mode.
Prefers the declared projection order, then the canonical vector order,
and finally the subplot dict's own order — so transverse-field
projections order by their preset declaration, not a canonical filter.
"""
if not self._subplot_axes_by_polarization:
return []
spec_order = [str(p["label"]) for p in self._projection_specs]
ordered = [a for a in spec_order if a in self._subplot_axes_by_polarization]
if ordered:
return ordered
ordered = [
axis for axis in ("P_x", "P_y", "P_z") if axis in self._subplot_axes_by_polarization
]
if ordered:
return ordered
return list(self._subplot_axes_by_polarization)
def _sync_y_controls_with_visible_axis(self) -> None:
"""Align the Y controls with the focused (selected) subplot's limits."""
if not self._subplot_axes_by_polarization:
return
# The Y fields reflect the focused (fit-target) subplot, so a global
# auto-Y leaves them showing the selected projection's own range rather
# than a span across all of them.
axis = self._active_y_axis()
if axis in self._subplot_axes_by_polarization:
limits = self._y_limits_by_polarization.get(axis)
if limits is not None:
self._y_min.setValue(float(limits[0]))
self._y_max.setValue(float(limits[1]))
return
# No focused subplot (e.g. nothing selected yet): show a global y-range
# spanning all visible subplot axes.
ranges: list[tuple[float, float]] = []
for axis_key in self._all_mode_axes_order():
limits = self._y_limits_by_polarization.get(axis_key)
if limits is not None:
ranges.append((float(limits[0]), float(limits[1])))
if not ranges:
return
y_lo = min(lo for lo, _ in ranges)
y_hi = max(hi for _, hi in ranges)
self._y_min.setValue(y_lo)
self._y_max.setValue(y_hi)
[docs]
def set_projections(
self,
projections: list[dict],
selected: list[str] | None = None,
) -> None:
"""Show/update the projection chip bar, or hide it when unavailable.
``projections`` is an ordered list of ``{"label", "tint"?}`` dicts;
``selected`` is the subset of labels to show as subplots (defaults to
all). The bar (and any multi-projection behaviour) is suppressed when
fewer than two projections exist.
"""
if not hasattr(self, "_projection_bar"):
return
specs = [dict(p) for p in (projections or []) if p.get("label")]
if len(specs) < 2:
self._cache_current_y_limits_for_axis()
# Was a vector multi-pane (EMU ``ALL`` or stacked transverse
# projections) on screen? ``_vector_subplot_datasets`` is populated
# only by plot_vector_subplots and is the precise indicator — unlike
# ``_current_polarization_axis``, which grouped-time subplots also set
# to a run key, so keying on it would spuriously fire a full grouped
# rebuild from clear(). Capture before reset so we can collapse after.
had_vector_view = bool(self._vector_subplot_datasets)
self._current_polarization_axis = None
self._projection_specs = []
self._tint_by_label = {}
self._selected_projection_labels = []
self._vector_subplot_datasets = {}
self._projection_bar.set_projections([])
self._update_y_limit_controls_for_axis(None)
# Replot only on the vector → non-vector transition. With the vector
# state cleared, _redraw_current_view falls through to the single-pane
# path, collapsing the dual-pane back to one Axes. Skipping this when
# no vector view was active avoids a spurious redraw on the common
# non-vector refresh path (set_projections([]) fires routinely).
if had_vector_view:
self._redraw_current_view()
return
self._projection_specs = specs
self._tint_by_label = {str(p["label"]): str(p["tint"]) for p in specs if p.get("tint")}
labels = [str(p["label"]) for p in specs]
wanted = set(selected) if selected else set(labels)
chosen = [lbl for lbl in labels if lbl in wanted] or list(labels)
self._projection_bar.set_projections(specs, chosen)
self._selected_projection_labels = self._projection_bar.selected_labels()
new_axis = self._axis_for_selection(self._selected_projection_labels)
previous_axis = self._current_polarization_axis
if previous_axis != new_axis:
self._cache_current_y_limits_for_axis()
self._current_polarization_axis = new_axis
if previous_axis != new_axis:
self._restore_y_limits_for_axis(new_axis)
self._sync_y_controls_with_visible_axis()
self._update_y_limit_controls_for_axis(new_axis)
self._apply_limits()
else:
self._update_y_limit_controls_for_axis(new_axis)
[docs]
def selected_projection_labels(self) -> list[str]:
"""Return the projection labels currently selected.
The chip bar is the source of truth once populated; the stored copy is
the fallback during project restore, before the bar has been rebuilt.
"""
bar_selection = self._projection_bar.selected_labels()
return bar_selection or list(self._selected_projection_labels)
# ── stacked-subplot fit target (Step 4) ────────────────────────────────
[docs]
def projection_tint(self, label: str | None) -> str | None:
"""Return the frame/identity tint for a projection label, if any."""
if label is None:
return None
return self._tint_by_label.get(str(label))
[docs]
def fit_target_projection(self) -> str | None:
"""Return the projection whose subplot is the active single-fit target.
Only meaningful in the stacked multi-subplot view; ``None`` otherwise.
"""
if self._fit_target_projection in self._subplot_axes_by_polarization:
return self._fit_target_projection
return None
[docs]
def set_fit_target_projection(self, label: str | None, *, emit: bool = True) -> None:
"""Mark *label*'s subplot as the single-fit target and redraw its box.
The fit target is also the focus for the manual Y-limit controls, so a
change caches the outgoing subplot's limits and surfaces the incoming
subplot's into the Y fields.
"""
if label is not None and label not in self._subplot_axes_by_polarization:
return
changed = label != self._fit_target_projection
if not changed:
return # no-op re-click: skip the tight-bbox recompute and redraw
in_subplots = bool(self._subplot_axes_by_polarization)
if in_subplots:
self._cache_current_y_limits_for_axis() # caches the outgoing target
self._fit_target_projection = label
if in_subplots:
self._restore_y_limits_for_axis(label)
self._update_y_limit_controls_for_axis(self._current_polarization_axis)
self._refresh_fit_target_decoration()
if emit and label is not None:
self.fit_target_projection_changed.emit(str(label))
def _subplot_projection_at_event(self, event) -> str | None:
"""Return the projection whose subplot axes contains the click event."""
inaxes = getattr(event, "inaxes", None)
if inaxes is None:
return None
for label, axis in self._subplot_axes_by_polarization.items():
if axis is inaxes:
return label
return None
def _default_fit_target(self) -> str | None:
"""Pick a sensible default target: the active single axis, else the first."""
order = self._all_mode_axes_order()
if not order:
return None
axis = self._current_polarization_axis
if axis in order:
return axis
return order[0]
def _clear_fit_target_decoration(self) -> None:
for artist in self._fit_target_artists:
try:
artist.remove()
except Exception:
continue
self._fit_target_artists = []
def _refresh_fit_target_decoration(self) -> None:
"""Draw a neutral focus ring + 'fit target' pill on the active subplot.
The ring/pill is *selection* state and is deliberately distinct from the
per-projection frame tint (which encodes projection identity).
"""
if not self._has_mpl:
return
self._clear_fit_target_decoration()
target = self.fit_target_projection()
# Only decorate when there is a genuine choice (two or more subplots).
if target is None or len(self._subplot_axes_by_polarization) < 2:
self._canvas.draw_idle()
return
ax = self._subplot_axes_by_polarization[target]
# Decoration is best-effort chrome — a non-standard axis (e.g. a test
# double) must never break the click/selection path.
if not hasattr(ax, "get_position"):
self._canvas.draw_idle()
return
from matplotlib.patches import FancyBboxPatch
# Enclose the WHOLE subplot — tick labels and y-axis label included — by
# using the axes' tight bbox (display coords) rather than just the data
# area; fall back to the data-area position if no renderer is available.
pos = ax.get_position()
try:
renderer = self._figure.canvas.get_renderer()
tight = ax.get_tightbbox(renderer)
if tight is not None:
pos = tight.transformed(self._figure.transFigure.inverted())
except Exception:
pass
margin = 0.006 # small gap so the ring doesn't touch the labels
ring = FancyBboxPatch(
(pos.x0 - margin, pos.y0 - margin),
pos.width + 2 * margin,
pos.height + 2 * margin,
boxstyle="round,pad=0.002",
transform=self._figure.transFigure,
fill=False,
edgecolor=tokens.TEXT,
linewidth=1.6,
zorder=10,
)
self._figure.add_artist(ring)
self._fit_target_artists.append(ring)
pill = ax.text(
0.985,
0.94,
"fit target",
transform=ax.transAxes,
ha="right",
va="top",
fontsize=8,
color=tokens.SURFACE,
zorder=11,
bbox={"boxstyle": "round,pad=0.25", "facecolor": tokens.TEXT, "edgecolor": "none"},
)
self._fit_target_artists.append(pill)
self._canvas.draw_idle()
def _polarization_ylabel(self, axis_key: str | None) -> str:
"""Return y-axis label for the provided polarization component."""
if axis_key in {"P_x", "P_y", "P_z"}:
suffix = axis_key.split("_", 1)[1]
return rf"$a_0 P_{{{suffix}}}(t)$ (%)"
return "Asymmetry (%)"
def _ensure_single_axis_mode(self) -> None:
"""Recreate a single-axis figure when leaving any multi-axis mode.
The single-dataset path always renders onto exactly one ``self._ax``, so
the postcondition is one tracked axis. Rebuild whenever that does not
already hold: tracked vector subplots, *any* lingering extra axes (e.g. a
dual-pane left behind when a projection was cleared without replotting),
or a stale ``self._ax`` detached from the figure. Relying solely on
``_subplot_axes_by_polarization`` was fragile — clearing the vector state
without clearing that dict still left the panes on the canvas.
"""
already_single = (
not self._subplot_axes_by_polarization
and len(self._figure.axes) == 1
and getattr(self, "_ax", None) in self._figure.axes
)
if already_single:
return
self._disconnect_axis_limit_callbacks()
self._figure.clf()
self._ax = self._figure.add_subplot(111)
style_figure(self._figure)
style_axes(self._ax)
self._subplot_axes_by_polarization = {}
self._vector_subplot_datasets = {}
self._sync_canvas_scroll_geometry(
axis_count=1, target_height=self._default_canvas_min_height
)
def _fit_range_axes(self) -> list[object]:
"""Return axes that should show fit-range handles for the current view."""
if self._subplot_axes_by_polarization:
return list(self._subplot_axes_by_polarization.values())
return [self._ax] if hasattr(self, "_ax") else []
def _clear_fit_range_artists(self) -> None:
"""Remove existing fit-range span and handle artists from all axes."""
for artists in (
getattr(self, "_fit_span_artists", []),
getattr(self, "_fit_min_handles", []),
getattr(self, "_fit_max_handles", []),
):
for artist in artists:
try:
artist.remove()
except NotImplementedError:
continue
except Exception:
continue
self._fit_span_artists = []
self._fit_min_handles = []
self._fit_max_handles = []
def _plot_datasets_on_axis(
self, ax, datasets: list[MuonDataset], axis_key: str | None
) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray | None, np.ndarray | None]:
"""Plot one or more datasets on ``ax`` and return flattened arrays for auto-y."""
# Handoff plot grammar: y = 0 reference line under the data (it is
# excluded from autoscaling, so positive-only data never stretches).
draw_zero_line(ax)
self._rrf_frame_drawn = None
all_times: list[np.ndarray] = []
all_asym: list[np.ndarray] = []
all_err: list[np.ndarray] = []
all_low: list[np.ndarray] = []
period_color_counts: dict[str, int] = {}
for i, dataset in enumerate(datasets):
color = f"C{i % 10}"
period_color = self._period_mode_color_for_dataset(dataset)
if period_color is not None:
variant_idx = period_color_counts.get(period_color, 0)
color = self._period_mode_color_variant(period_color, variant_idx)
period_color_counts[period_color] = variant_idx + 1
analysis_dataset = rrf_display_dataset(self, self.get_analysis_dataset(dataset))
if analysis_dataset is None:
continue
time = self._convert_frequency_axis_for_display(analysis_dataset.time, dataset)
asymmetry = analysis_dataset.asymmetry
error = analysis_dataset.error
low_count_mask = self._low_count_mask_for_dataset(
analysis_dataset,
source_dataset=dataset,
)
finite_mask = np.isfinite(time) & np.isfinite(asymmetry) & np.isfinite(error)
valid_low = finite_mask & low_count_mask
valid_main = finite_mask & ~low_count_mask
if np.any(valid_low):
self._plot_errorbar_masked(
ax,
time,
asymmetry,
error,
valid_low,
fmt=".",
markersize=3,
color="0.6",
ecolor="0.6",
label="_nolegend_",
)
draw_mask = valid_main if np.any(valid_main) else finite_mask
self._plot_errorbar_masked(
ax,
time,
asymmetry,
error,
draw_mask,
fmt=".",
markersize=3,
color=color,
label=self._dataset_label_for(dataset),
)
fit_to_plot = self._fit_curve_for_dataset(dataset, axis_override=axis_key)
fit_to_plot = rrf_display_fit_curve(self, fit_to_plot, analysis_dataset)
if fit_to_plot is not None:
t_fit, y_fit, fit_label = fit_to_plot
fit_color = self._fit_line_color_for_dataset(
dataset,
default_color=color,
variant_index=i,
fit_label=fit_label,
)
ax.plot(t_fit, y_fit, "-", color=fit_color, linewidth=2, label="_nolegend_")
if np.any(finite_mask):
all_times.append(time[finite_mask])
all_asym.append(asymmetry[finite_mask])
all_err.append(error[finite_mask])
all_low.append(low_count_mask[finite_mask])
_, y_label = self._axis_labels_for_dataset(datasets[0] if datasets else None, axis_key)
ax.set_ylabel(y_label)
# Decimation chip: applied by the caller once every axis is drawn —
# the chip counters are still accumulating while subplots render.
rrf_draw_badge(self, ax)
if all_times:
return (
np.concatenate(all_times),
np.concatenate(all_asym),
np.concatenate(all_err),
np.concatenate(all_low),
)
return None, None, None, None
def _projection_subplot_order(
self, datasets_by_axis: dict[str, list[MuonDataset]]
) -> list[str]:
"""Return the subplot order for the projections that have datasets.
Prefers the declared projection order, falling back to the canonical
vector order and finally to the dict's own order.
"""
spec_order = [str(p["label"]) for p in self._projection_specs]
order = [a for a in spec_order if datasets_by_axis.get(a)]
if not order:
order = [a for a in ("P_x", "P_y", "P_z") if datasets_by_axis.get(a)]
if not order:
order = [a for a in datasets_by_axis if datasets_by_axis.get(a)]
return order
def _apply_projection_frame_tint(self, ax, axis_key: str) -> None:
"""Tint a subplot's left rail and y-label with the projection's colour.
This is *projection identity* (chip ↔ subplot), deliberately distinct
from the data trace colour, which encodes run identity in RG mode.
"""
tint = self._tint_by_label.get(str(axis_key))
if not tint:
return
ax.yaxis.label.set_color(tint)
left = ax.spines.get("left") if hasattr(ax.spines, "get") else ax.spines["left"]
if left is not None:
left.set_color(tint)
left.set_linewidth(2.0)
[docs]
def plot_vector_subplots(self, datasets_by_axis: dict[str, list[MuonDataset]]) -> None:
"""Render the selected projections as stacked subplots."""
if not self._has_mpl:
return
order = self._projection_subplot_order(datasets_by_axis)
if not order:
return
self._set_canvas_minimum_height_for_axes(len(order))
self._set_alpha_label(None)
self._grouped_time_subplot_datasets = []
self._reset_decimation_view_state()
self._clear_waterfall_render_record()
self._vector_subplot_datasets = {k: list(v) for k, v in datasets_by_axis.items() if v}
self._current_datasets = list(self._vector_subplot_datasets.get(order[0], []))
self._current_dataset = self._current_datasets[-1] if self._current_datasets else None
self._update_plot_header()
# Stop listening to old axes before clearing the figure; stale callbacks
# can otherwise push default [0, 1] limits into the limit fields.
self._disconnect_axis_limit_callbacks()
self._figure.clf()
style_figure(self._figure)
self._subplot_axes_by_polarization = {}
shared_ax = None
last_arrays = (None, None, None, None)
vector_x_ranges: list[tuple[float, float]] = []
for idx, axis_key in enumerate(order):
ax = self._figure.add_subplot(len(order), 1, idx + 1, sharex=shared_ax)
style_axes(ax)
if shared_ax is None:
shared_ax = ax
self._subplot_axes_by_polarization[axis_key] = ax
self._ax = ax if idx == 0 else self._ax
t, a, e, low = self._plot_datasets_on_axis(
ax, self._vector_subplot_datasets.get(axis_key, []), axis_key
)
self._apply_projection_frame_tint(ax, axis_key)
if axis_key in self._y_limits_by_polarization:
y0, y1 = self._y_limits_by_polarization[axis_key]
ax.set_ylim(y0, y1)
elif t is None:
# All-NaN projection: give it a neutral asymmetry range instead
# of matplotlib's default (0, 1). Not cached in
# _y_limits_by_polarization, so a later render with real data
# auto-scales normally.
ax.set_ylim(*_EMPTY_PROJECTION_YLIM)
if idx == len(order) - 1:
x_label, _ = self._axis_labels_for_dataset(
self._vector_subplot_datasets.get(axis_key, [None])[0],
axis_key,
)
ax.set_xlabel(x_label)
else:
ax.tick_params(labelbottom=False)
if idx == 0:
style_legend(ax.legend())
if t is not None:
last_arrays = (t, a, e, low)
vector_x_ranges.append((float(np.min(t)), float(np.max(t))))
# One chip on the bottom axis, after every subplot's points are
# counted — applying per axis mid-loop would show partial totals.
self._apply_x_axis_decimation_indicator(ax)
self._last_plot_time = last_arrays[0]
self._last_plot_asymmetry = last_arrays[1]
self._last_plot_error = last_arrays[2]
self._last_low_count_mask = last_arrays[3]
self._reframe_if_content_changed()
if (not self._limits_initialized) and self._last_plot_time is not None:
# Route x through _default_x_limits so the shared pad + t0 lower-clamp
# apply here exactly as in the single/overlay views (no pre-t0 band).
x_limits = self._default_x_limits(self._last_plot_time, self._last_plot_asymmetry)
if x_limits is not None:
self._x_min.setValue(x_limits[0])
self._x_max.setValue(x_limits[1])
# Frame each subplot to its own signal so first paint isn't squashed by
# matplotlib autoscaling over sentinel/late-tail points.
self._frame_subplot_axes_to_signal()
self._mark_frame_initialized()
if vector_x_ranges and (self._fit_x_min is None or self._fit_x_max is None):
seed = self._raw_fit_seed_range(
[ds for axis_datasets in datasets_by_axis.values() for ds in axis_datasets]
)
if seed is None:
seed = (
min(lo for lo, _ in vector_x_ranges),
max(hi for _, hi in vector_x_ranges),
)
self._fit_x_min, self._fit_x_max = seed
if self._current_polarization_axis in self._subplot_axes_by_polarization:
y_limits = self._y_limits_by_polarization.get(self._current_polarization_axis)
if y_limits is not None:
self._y_min.setValue(float(y_limits[0]))
self._y_max.setValue(float(y_limits[1]))
else:
self._sync_y_controls_with_visible_axis()
self._update_y_limit_controls_for_axis(self._current_polarization_axis)
self._apply_limits(schedule_viewport_refresh=True)
self._apply_auto_limits_if_enabled()
self._connect_axis_limit_callbacks(list(self._subplot_axes_by_polarization.values()))
# Auto-select a fit target so fitting is never dead-on-arrival; keep the
# prior target when it is still visible. Emit so the fit panel rebinds.
if self._fit_target_projection not in self._subplot_axes_by_polarization:
self.set_fit_target_projection(self._default_fit_target())
else:
self._refresh_fit_target_decoration()
[docs]
def plot_grouped_time_domain_subplots(self, datasets: list[MuonDataset]) -> None:
"""Render grouped time-domain traces as stacked subplots."""
if not self._has_mpl or not datasets:
return
self._set_canvas_minimum_height_for_axes(len(datasets))
self._set_alpha_label(None)
self._disconnect_axis_limit_callbacks()
self._figure.clf()
self._subplot_axes_by_polarization = {}
self._vector_subplot_datasets = {}
self._reset_decimation_view_state()
self._clear_waterfall_render_record()
self._grouped_time_subplot_datasets = list(datasets)
self._current_datasets = list(datasets)
self._current_dataset = datasets[-1]
self._update_plot_header()
self._current_polarization_axis = None
if hasattr(self, "_projection_bar"):
self._projection_bar.hide()
shared_ax = None
last_arrays = (None, None, None, None)
ordered_keys: list[str] = []
grouped_x_ranges: list[tuple[float, float]] = []
for idx, dataset in enumerate(datasets):
# Raw and corrected builds share synthetic run numbers but live on
# very different y scales; qualify the key so pinned y-limits from
# one mode are not applied to the other.
axis_key = self._grouped_subplot_axis_key(dataset)
ordered_keys.append(axis_key)
ax = self._figure.add_subplot(len(datasets), 1, idx + 1, sharex=shared_ax)
style_axes(ax)
if shared_ax is None:
shared_ax = ax
self._subplot_axes_by_polarization[axis_key] = ax
if idx == 0:
self._ax = ax
t, a, e, low = self._plot_datasets_on_axis(ax, [dataset], axis_key)
ax.set_title(str(dataset.run_label), loc="left", fontsize=10)
if axis_key in self._y_limits_by_polarization:
y0, y1 = self._y_limits_by_polarization[axis_key]
ax.set_ylim(y0, y1)
if idx == len(datasets) - 1:
x_label, _ = self._axis_labels_for_dataset(dataset, axis_key)
ax.set_xlabel(x_label)
else:
ax.tick_params(labelbottom=False)
style_legend(ax.legend(loc="upper right"))
if t is not None:
last_arrays = (t, a, e, low)
grouped_x_ranges.append((float(np.min(t)), float(np.max(t))))
# One chip on the bottom axis, after every subplot's points are
# counted — applying per axis mid-loop would show partial totals.
self._apply_x_axis_decimation_indicator(ax)
self._current_polarization_axis = ordered_keys[0] if ordered_keys else None
self._last_plot_time = last_arrays[0]
self._last_plot_asymmetry = last_arrays[1]
self._last_plot_error = last_arrays[2]
self._last_low_count_mask = last_arrays[3]
self._reframe_if_content_changed()
if (not self._limits_initialized) and self._last_plot_time is not None:
# Route x through _default_x_limits so the shared pad + t0 lower-clamp
# apply here exactly as in the single/overlay views (no pre-t0 band).
x_limits = self._default_x_limits(self._last_plot_time, self._last_plot_asymmetry)
if x_limits is not None:
self._x_min.setValue(x_limits[0])
self._x_max.setValue(x_limits[1])
# Frame each group's subplot to its own signal so first paint isn't
# squashed by matplotlib autoscaling over sentinel/late-tail points.
self._frame_subplot_axes_to_signal()
self._mark_frame_initialized()
if grouped_x_ranges and (self._fit_x_min is None or self._fit_x_max is None):
seed = self._raw_fit_seed_range(list(datasets))
if seed is None:
seed = (
min(lo for lo, _ in grouped_x_ranges),
max(hi for _, hi in grouped_x_ranges),
)
self._fit_x_min, self._fit_x_max = seed
self._sync_y_controls_with_visible_axis()
self._update_y_limit_controls_for_axis(self._current_polarization_axis)
self._apply_limits(schedule_viewport_refresh=True)
self._apply_auto_limits_if_enabled()
self._connect_axis_limit_callbacks(list(self._subplot_axes_by_polarization.values()))
self._apply_log_counts_scale()
[docs]
def plot_maxent_reconstruction(
self, datasets: list[MuonDataset], *, combined: bool = False
) -> None:
"""Overlay the MaxEnt time-domain reconstruction on the measured data.
Each dataset carries the observed signal in ``asymmetry`` and the
forward-model reconstruction + weighted residual in
``metadata['maxent_model']`` / ``['maxent_residual']``. Two layouts
share the same data and χ²:
- *per-group* (default): one main axis (data points + model line) above
a residuals strip per group, stacked vertically;
- *combined* (``combined=True``): every group's data+model overlaid on a
single colour-coded axis above one shared residuals strip.
The displayed χ² is the engine's by construction (the residuals are
``(data − model)/σ``), so both layouts report the same total.
"""
if not self._has_mpl or not datasets:
return
self._clear_waterfall_render_record()
if combined:
self._plot_maxent_reconstruction_combined(datasets)
else:
self._plot_maxent_reconstruction_per_group(datasets)
def _plot_maxent_reconstruction_per_group(self, datasets: list[MuonDataset]) -> None:
"""Stacked per-group data+model axes, each above its own residuals strip."""
self._set_canvas_minimum_height_for_axes(len(datasets))
self._set_alpha_label(None)
self._disconnect_axis_limit_callbacks()
self._figure.clf()
self._subplot_axes_by_polarization = {}
self._vector_subplot_datasets = {}
self._grouped_time_subplot_datasets = []
self._current_datasets = list(datasets)
self._current_dataset = datasets[-1]
self._update_plot_header()
self._current_polarization_axis = None
if hasattr(self, "_projection_bar"):
self._projection_bar.hide()
n = len(datasets)
gridspec = self._figure.add_gridspec(2 * n, 1, height_ratios=[3, 1] * n, hspace=0.45)
shared_ax = None
total_chi2 = 0.0
total_obs = 0
last_time = None
for idx, dataset in enumerate(datasets):
time = np.asarray(dataset.time, dtype=float)
data = np.asarray(dataset.asymmetry, dtype=float)
model = np.asarray(dataset.metadata.get("maxent_model", data), dtype=float)
residual = np.asarray(
dataset.metadata.get("maxent_residual", data - model), dtype=float
)
total_chi2 += float(
dataset.metadata.get("maxent_group_chi2", float(np.sum(residual**2)))
)
total_obs += int(dataset.metadata.get("maxent_group_n_obs", residual.size))
ax_main = self._figure.add_subplot(gridspec[2 * idx], sharex=shared_ax)
ax_res = self._figure.add_subplot(gridspec[2 * idx + 1], sharex=ax_main)
if shared_ax is None:
shared_ax = ax_main
if idx == 0:
self._ax = ax_main
style_axes(ax_main)
style_axes(ax_res)
axis_key = f"recon:{dataset.run_number}:{idx}"
self._subplot_axes_by_polarization[axis_key] = ax_main
ax_main.plot(
time, data, ".", markersize=3, color=tokens.PLOT_DATA, label="Data", alpha=0.7
)
ax_main.plot(
time, model, "-", linewidth=1.4, color=tokens.PLOT_FIT, label="Reconstruction"
)
ax_main.set_title(str(dataset.run_label), loc="left", fontsize=10)
ax_main.set_ylabel("Recon. (a.u.)")
ax_main.tick_params(labelbottom=False)
style_legend(ax_main.legend(loc="upper right"))
ax_res.axhline(0.0, color=tokens.PLOT_ZERO_LINE, linewidth=0.8)
ax_res.plot(time, residual, "-", linewidth=0.9, color=tokens.PLOT_DATA)
ax_res.set_ylabel("(d−m)/σ")
if idx == n - 1:
ax_res.set_xlabel("Time (µs)")
else:
ax_res.tick_params(labelbottom=False)
last_time = time
self._current_polarization_axis = next(iter(self._subplot_axes_by_polarization), None)
if total_obs:
chi2_per_n = total_chi2 / float(total_obs)
self._figure.suptitle(
f"MaxEnt reconstruction — χ² = {total_chi2:.1f} ({chi2_per_n:.2f} per point)",
fontsize=10,
)
if last_time is not None and last_time.size:
self._last_plot_time = last_time
self._figure.canvas.draw_idle()
def _plot_maxent_reconstruction_combined(self, datasets: list[MuonDataset]) -> None:
"""All groups' data+model on one colour-coded axis + a shared residuals strip.
Each group keeps a distinct colour (matplotlib's default cycle) shared
between its data points, model line and residual trace, so the combined
fit quality is legible at a glance. The total χ² equals the per-group
layout's by construction.
"""
self._set_canvas_minimum_height_for_axes(2)
self._set_alpha_label(None)
self._disconnect_axis_limit_callbacks()
self._figure.clf()
self._subplot_axes_by_polarization = {}
self._vector_subplot_datasets = {}
self._grouped_time_subplot_datasets = []
self._current_datasets = list(datasets)
self._current_dataset = datasets[-1]
self._update_plot_header()
self._current_polarization_axis = None
if hasattr(self, "_projection_bar"):
self._projection_bar.hide()
gridspec = self._figure.add_gridspec(2, 1, height_ratios=[3, 1], hspace=0.3)
ax_main = self._figure.add_subplot(gridspec[0])
ax_res = self._figure.add_subplot(gridspec[1], sharex=ax_main)
style_axes(ax_main)
style_axes(ax_res)
self._ax = ax_main
axis_key = f"recon:combined:{datasets[0].run_number}"
self._subplot_axes_by_polarization[axis_key] = ax_main
self._current_polarization_axis = axis_key
total_chi2 = 0.0
total_obs = 0
last_time = None
for idx, dataset in enumerate(datasets):
time = np.asarray(dataset.time, dtype=float)
data = np.asarray(dataset.asymmetry, dtype=float)
model = np.asarray(dataset.metadata.get("maxent_model", data), dtype=float)
residual = np.asarray(
dataset.metadata.get("maxent_residual", data - model), dtype=float
)
total_chi2 += float(
dataset.metadata.get("maxent_group_chi2", float(np.sum(residual**2)))
)
total_obs += int(dataset.metadata.get("maxent_group_n_obs", residual.size))
label = str(dataset.metadata.get("group_name", dataset.run_label))
color = f"C{idx % 10}"
ax_main.plot(time, data, ".", markersize=3, color=color, alpha=0.55)
ax_main.plot(time, model, "-", linewidth=1.4, color=color, label=label)
ax_res.plot(time, residual, "-", linewidth=0.8, color=color, alpha=0.85)
last_time = time
ax_main.set_ylabel("Recon. (a.u.)")
ax_main.tick_params(labelbottom=False)
style_legend(ax_main.legend(loc="upper right", title="Group"))
ax_res.axhline(0.0, color=tokens.PLOT_ZERO_LINE, linewidth=0.8)
ax_res.set_ylabel("(d−m)/σ")
ax_res.set_xlabel("Time (µs)")
if total_obs:
chi2_per_n = total_chi2 / float(total_obs)
self._figure.suptitle(
f"MaxEnt reconstruction (combined) — χ² = {total_chi2:.1f} "
f"({chi2_per_n:.2f} per point)",
fontsize=10,
)
if last_time is not None and last_time.size:
self._last_plot_time = last_time
self._figure.canvas.draw_idle()
def _alpha_value_for_dataset(self, dataset: MuonDataset) -> float | None:
"""Return the asymmetry alpha value used for *dataset*, if available."""
run = dataset.run
if run is not None and isinstance(run.grouping, dict):
axis = self._axis_canonical_key(run.grouping.get("vector_axis"))
axis_key = {
"P_x": "alpha_x",
"P_y": "alpha_y",
"P_z": "alpha_z",
}.get(axis)
legacy_axis_key = {
"P_x": "alpha_px",
"P_y": "alpha_py",
"P_z": "alpha_pz",
}.get(axis)
if axis_key is not None:
alpha = run.grouping.get(axis_key, run.grouping.get(legacy_axis_key))
try:
return float(alpha)
except (TypeError, ValueError):
pass
alpha = run.grouping.get("alpha")
try:
return float(alpha)
except (TypeError, ValueError):
pass
alpha = dataset.metadata.get("alpha")
try:
return float(alpha)
except (TypeError, ValueError):
return None
def _single_dataset_alpha_label_text(self, dataset: MuonDataset) -> str | None:
"""Return alpha label text for single-dataset views when available."""
if self._is_frequency_domain_dataset(dataset):
return None
alpha = self._alpha_value_for_dataset(dataset)
if alpha is None:
return None
return f"(alpha = {alpha:.6g})"
def _period_mode_color_for_dataset(self, dataset: MuonDataset) -> str | None:
"""Return WiMDA-like color for selected two-period mode, else None."""
run = getattr(dataset, "run", None)
grouping = getattr(run, "grouping", None)
if not isinstance(grouping, dict):
return None
period_hist = grouping.get("period_histograms")
if not isinstance(period_hist, list) or len(period_hist) != 2:
return None
mode = str(grouping.get("period_mode", PeriodMode.RED))
color_map = {
str(PeriodMode.RED): tokens.PERIOD_RED,
str(PeriodMode.GREEN): tokens.PERIOD_GREEN,
str(PeriodMode.GREEN_MINUS_RED): tokens.PERIOD_DIFF,
str(PeriodMode.GREEN_PLUS_RED): tokens.PERIOD_SUM,
}
return color_map.get(mode)
def _period_mode_color_variant(self, base_color: str, index: int) -> str:
"""Return a deterministic, high-contrast color variant for RG mode.
The first trace uses the mode's base color. Additional traces rotate
through a contrasting palette so selected runs are clearly separable.
"""
if index <= 0:
return base_color
# Okabe-Ito high-contrast overlay ordering (mode-specific exclusions so
# overlays stay distinct from the selected RG base) — centralised in
# styles/plots.py so the palette has a single source of truth.
distinct_palette = period_overlay_palette(base_color)
return distinct_palette[(index - 1) % len(distinct_palette)]
def _fit_line_color_for_dataset(
self,
dataset: MuonDataset,
*,
default_color: str,
variant_index: int = 0,
fit_label: str | None = None,
) -> str:
"""Return the fit-line colour for a dataset.
Preview curves get a fixed accent colour. In grouped time-domain mode each
detector group sits on its own subplot, so there is no fit↔dataset overlay
to disambiguate — the fit uses the canonical red fit colour, which resolves
far better against the (blue) data points than matching the marker colour.
Every other fit matches its data markers so that in overlay mode each fit
visually belongs to its dataset.
"""
if isinstance(fit_label, str) and "preview" in fit_label.lower():
return tokens.PLOT_FIT_PREVIEW
if self._grouped_time_subplot_datasets:
return tokens.PLOT_FIT
return default_color
[docs]
def set_fit_range(self, x_min: float, x_max: float) -> None:
"""Set fit range limits and refresh visual handles."""
self._set_fit_range(x_min, x_max, emit_signal=True, redraw=True)
def _waterfall_active_for(self, datasets: list[MuonDataset]) -> bool:
"""Return True when waterfall stacking applies to this overlay draw.
Only the single-axis overlay path (>1 dataset, overlay on, waterfall on)
stacks; single datasets and the stacked-subplot views never do.
"""
return bool(
self._has_mpl
and self.is_waterfall_enabled()
and self.is_overlay_enabled()
and len(datasets) > 1
)
def _clear_waterfall_render_record(self) -> None:
"""Forget the last overlay draw's waterfall stack.
Called from every render entry point; only the ``plot_datasets``
overlay draw re-populates the record afterwards. The export path
stamps offsets solely from this record, so a view that never drew a
stack (single run, grouped subplots, MaxEnt, vector "ALL") can never
export stale offsets even while the Waterfall checkbox is checked.
"""
self._waterfall_stacked = False
self._waterfall_drawn_offsets = {}
self._waterfall_resolved_delta = 0.0
self._last_plot_sentinel_mask = None
def _resolve_waterfall_delta(
self,
datasets: list[MuonDataset],
traces: list[np.ndarray],
*,
x_arrays: list[np.ndarray] | None = None,
x_window: tuple[float, float] | None = None,
) -> float | None:
"""Return the per-trace waterfall Δ, or ``None`` when stacking is off.
*traces* are the display asymmetry arrays the caller is about to draw
(already reduced through the analysis/RRF pipeline), so auto spacing is
computed from exactly the drawn traces; *x_arrays*/*x_window* restrict
the auto spacing to the samples the render will actually show (see
:func:`auto_waterfall_delta`). Caches the resolved Δ in
``_waterfall_resolved_delta`` for the state readout.
"""
if not self._waterfall_active_for(datasets) or len(traces) < 2:
self._waterfall_resolved_delta = 0.0
return None
if self._waterfall_offset is not None and self._waterfall_offset > 0.0:
delta = float(self._waterfall_offset)
else:
identity = self._current_frame_identity()
cached = self._waterfall_auto_delta_cache
if cached is not None and cached[0] == identity:
# Re-render of the same stacked content (a zoom's decimation
# viewport refresh, a bunch change): keep the plot-time Δ so
# the stack never re-spaces under the user mid-inspection.
delta = cached[1]
else:
delta = auto_waterfall_delta(
[np.asarray(t, dtype=float) for t in traces],
x_arrays=x_arrays,
x_window=x_window,
)
self._waterfall_auto_delta_cache = (identity, delta)
self._waterfall_resolved_delta = delta
return delta
[docs]
def plot_datasets(self, datasets: list[MuonDataset]) -> None:
"""Plot multiple datasets on the same axes with per-dataset colours.
Each dataset is assigned a colour from matplotlib's default cycle
(C0, C1, …). Any stored fit curve for a run is drawn in a matching
style, with high-contrast overrides for period-mode datasets.
Low-count (grey) points are still drawn at reduced opacity.
The axes limits are initialised from the combined data extent on the
first call, then held fixed on subsequent redraws.
Delegates to :meth:`plot_dataset` when *datasets* has exactly one
entry so that the single-dataset code path (limit initialisation,
fit-range handling, etc.) is exercised unchanged.
"""
if not self._has_mpl or not datasets:
return
if len(datasets) == 1:
self.plot_dataset(datasets[0])
return
self._set_canvas_minimum_height_for_axes(1)
self._ensure_single_axis_mode()
self._grouped_time_subplot_datasets = []
self._set_alpha_label(None)
self._reset_decimation_view_state()
self._rrf_frame_drawn = None
self._clear_waterfall_render_record()
self._current_dataset = datasets[-1]
self._current_datasets = list(datasets)
self._update_plot_header()
self._set_frequency_reference_from_dataset(datasets[0])
self._ax.clear()
style_axes(self._ax)
draw_zero_line(self._ax)
all_times: list[np.ndarray] = []
all_asym: list[np.ndarray] = []
all_err: list[np.ndarray] = []
all_low: list[np.ndarray] = []
all_sentinel: list[np.ndarray] = []
period_color_counts: dict[str, int] = {}
# Materialize each dataset's display (analysis + RRF) form once: the
# waterfall Δ and the draw loop below consume this same list, so auto
# spacing is computed from exactly the traces drawn and the analysis
# pipeline runs once per dataset rather than twice.
display_entries: list[tuple[MuonDataset, MuonDataset]] = []
for dataset in datasets:
analysis_dataset = rrf_display_dataset(self, self.get_analysis_dataset(dataset))
if not self._has_plottable_samples(analysis_dataset):
continue
display_entries.append((dataset, analysis_dataset))
# Each trace's display x-axis and y values (density Jacobian applied for
# unit-area spectra), computed once and shared by the Δ resolution, the
# first-paint framing, and the draw loop. The source dataset is passed so
# a shift-mode overlay shifts each trace about ITS OWN reference (the
# alignment point).
display_times = [
self._convert_frequency_axis_for_display(analysis.time, source_dataset)
for source_dataset, analysis in display_entries
]
display_values = [
self._convert_frequency_values_for_display(analysis.asymmetry, dataset)
for dataset, analysis in display_entries
]
display_errors = [
self._convert_frequency_values_for_display(analysis.error, dataset)
for dataset, analysis in display_entries
]
# Decide the x-window this render will show BEFORE resolving Δ, so the
# auto spacing measures only displayed samples (an FFT's long near-zero
# tail otherwise deflates Δ to a no-op — see auto_waterfall_delta).
# First-paint framing is decided here once — from the RAW (un-offset)
# values, whose per-trace waterfall pedestals would corrupt the
# peak-significance baseline — and reused by the framing block below.
self._reframe_if_content_changed()
default_x_limits: tuple[float, float] | None = None
if not self._limits_initialized:
frame_times: list[np.ndarray] = []
frame_raw: list[np.ndarray] = []
for time, raw, err in zip(display_times, display_values, display_errors):
finite = np.isfinite(time) & np.isfinite(raw) & np.isfinite(err)
if np.any(finite):
frame_times.append(time[finite])
frame_raw.append(raw[finite])
if frame_times:
default_x_limits = self._default_x_limits(
np.concatenate(frame_times), np.concatenate(frame_raw)
)
# The Δ window in display-axis units: the fresh first-paint frame when
# one was computed, else the current (persisted/manual) limit fields.
if default_x_limits is not None:
window_lo, window_hi = default_x_limits
else:
window_lo, window_hi = float(self._x_min.value()), float(self._x_max.value())
if self._is_frequency_plot_panel():
window_lo = self._convert_frequency_control_value_to_axis_limit(window_lo)
window_hi = self._convert_frequency_control_value_to_axis_limit(window_hi)
# Render-time waterfall offset: each drawn trace is stacked by i*Δ.
# None when waterfall is off (offsets collapse to 0). Source arrays are
# untouched; the per-dataset offsets actually drawn are recorded so the
# export path mirrors this render exactly.
waterfall_delta = self._resolve_waterfall_delta(
datasets,
display_values,
x_arrays=display_times,
x_window=(window_lo, window_hi),
)
self._waterfall_stacked = waterfall_delta is not None
for i, (dataset, analysis_dataset) in enumerate(display_entries):
color = f"C{i % 10}"
period_color = self._period_mode_color_for_dataset(dataset)
if period_color is not None:
variant_idx = period_color_counts.get(period_color, 0)
color = self._period_mode_color_variant(period_color, variant_idx)
period_color_counts[period_color] = variant_idx + 1
time = display_times[i]
raw_asymmetry = display_values[i]
offset = 0.0 if waterfall_delta is None else float(i * waterfall_delta)
if waterfall_delta is not None:
self._waterfall_drawn_offsets[id(dataset)] = offset
# Offset is a display transform only: shift a copy, never the source.
asymmetry = raw_asymmetry + offset if offset else raw_asymmetry
error = display_errors[i]
low_count_mask = self._low_count_mask_for_dataset(
analysis_dataset,
source_dataset=dataset,
)
finite_mask = np.isfinite(time) & np.isfinite(asymmetry) & np.isfinite(error)
if self._is_frequency_plot_panel():
# Line + error-band idiom (see plot_dataset); no expected-Larmor
# marker here — one per overlaid member would be noise, not signal.
self._plot_frequency_line_masked(
self._ax,
time,
asymmetry,
error,
finite_mask,
color=color,
label=self._dataset_label_for(dataset),
)
else:
valid_low = finite_mask & low_count_mask
valid_main = finite_mask & ~low_count_mask
if np.any(valid_low):
self._plot_errorbar_masked(
self._ax,
time,
asymmetry,
error,
valid_low,
fmt=".",
markersize=3,
color="0.6",
ecolor="0.6",
label="_nolegend_",
)
draw_mask = valid_main if np.any(valid_main) else finite_mask
self._plot_errorbar_masked(
self._ax,
time,
asymmetry,
error,
draw_mask,
fmt=".",
markersize=3,
color=color,
label=self._dataset_label_for(dataset),
)
# Faint per-trace baseline hairline at each trace's shifted zero,
# so a stacked curve reads against its own reference. Time domain
# only — spectra already sit on their baseline.
if waterfall_delta is not None:
draw_zero_line(self._ax, offset, linewidth=0.6, alpha=0.6, zorder=1.4)
# Overlay fit curve in same colour; excluded from legend by "_" prefix.
fit_to_plot = self._fit_curve_for_dataset(dataset)
fit_to_plot = rrf_display_fit_curve(self, fit_to_plot, analysis_dataset)
if fit_to_plot is not None:
t_fit, y_fit, fit_label = fit_to_plot
fit_color = self._fit_line_color_for_dataset(
dataset,
default_color=color,
variant_index=i,
fit_label=fit_label,
)
y_fit_display = self._convert_frequency_values_for_display(y_fit, dataset)
y_fit_shifted = y_fit_display + offset if offset else y_fit_display
self._ax.plot(
t_fit, y_fit_shifted, "-", color=fit_color, linewidth=2, label="_nolegend_"
)
if np.any(finite_mask):
all_times.append(time[finite_mask])
all_asym.append(asymmetry[finite_mask])
all_err.append(error[finite_mask])
all_low.append(low_count_mask[finite_mask])
# Saturation sentinels are classified on the RAW values: an
# offset can push a stacked trace past ±100 % (or pull a genuine
# sentinel inside it), so the displayed-value threshold misfires.
all_sentinel.append(
np.abs(raw_asymmetry[finite_mask]) >= _ASYMMETRY_SATURATION_PERCENT
)
x_label, y_label = self._axis_labels_for_dataset(
datasets[0], self._current_polarization_axis
)
self._apply_axis_labels(x_label, y_label)
self._draw_annotations()
if all_times:
self._last_plot_time = np.concatenate(all_times)
self._last_plot_asymmetry = np.concatenate(all_asym)
self._last_plot_error = np.concatenate(all_err)
self._last_low_count_mask = np.concatenate(all_low)
self._last_plot_sentinel_mask = np.concatenate(all_sentinel)
style_legend(self._ax.legend())
# First-paint framing was decided (and the default x-frame computed
# from the raw concatenation) before Δ resolution above, so the Δ
# window and the frame shown are one decision, made once.
# Same one-view-behind guard as plot_dataset: a reframe moves the
# axes after a draw decimated for the previous content's viewport.
reframed = not self._limits_initialized
if not self._limits_initialized:
a_all = self._last_plot_asymmetry
e_all = self._last_plot_error
# X first: time panels span the full data; frequency panels frame
# the dominant non-DC peak (high-TF Larmor lines sit far above the
# DC peak that would otherwise dominate the full-Nyquist view).
if default_x_limits is not None:
self._x_min.setValue(default_x_limits[0])
self._x_max.setValue(default_x_limits[1])
# Y framed to the signal within that window, ignoring ±100 %
# saturation sentinels and the error-divergent tail. Fall back to
# the raw envelope only when no good-bin points exist.
y_limits = self._signal_y_limits_from_last_plot()
if y_limits is None:
y_min = float((a_all - e_all).min())
y_max = float((a_all + e_all).max())
ypad = (y_max - y_min) * 0.05
y_limits = (y_min - ypad, y_max + ypad)
self._y_min.setValue(y_limits[0])
self._y_max.setValue(y_limits[1])
self._mark_frame_initialized()
# Set fit range to span all datasets (raw axes — see _raw_fit_seed_range).
if self._fit_x_min is None or self._fit_x_max is None:
seed = self._raw_fit_seed_range(list(datasets))
if seed is None:
seed = (
float(self._last_plot_time.min()),
float(self._last_plot_time.max()),
)
self._fit_x_min, self._fit_x_max = seed
else:
self._last_plot_time = None
self._last_plot_asymmetry = None
self._last_plot_error = None
self._last_low_count_mask = None
self._clear_waterfall_render_record()
self._fit_x_min = None
self._fit_x_max = None
reframed = False
self._last_draw_reframed = reframed
self._draw_fit_range_artists()
self._apply_limits(schedule_viewport_refresh=reframed)
self._apply_auto_limits_if_enabled()
self._update_export_enabled()
self._connect_axis_limit_callbacks([self._ax])
[docs]
def set_diamagnetic_overlay(
self,
time_us: np.ndarray | None,
signal: np.ndarray | None,
*,
run_number: int | None = None,
) -> None:
"""Set (or clear) the diamagnetic-fit curve drawn on the time-domain view.
Passing ``None`` for the arrays clears it. The overlay is tagged with
*run_number* and only drawn while that run is displayed, so it never
lingers on an unrelated run; it is redrawn on the next
:meth:`plot_dataset` call for a time-domain panel.
"""
if time_us is None or signal is None:
self._diamagnetic_overlay = None
else:
self._diamagnetic_overlay = (
None if run_number is None else int(run_number),
np.asarray(time_us, dtype=float),
np.asarray(signal, dtype=float),
)
if (
self._has_mpl
and self._current_dataset is not None
and not self._is_frequency_plot_panel()
):
self.plot_dataset(self._current_dataset)
def _overlay_diamagnetic_fit(self, analysis_dataset: MuonDataset | None = None) -> None:
"""Draw the stored diamagnetic-fit curve on the time-domain axes.
Only drawn when the overlay's run matches the displayed dataset, so a
fit from a previous run never shows on an unrelated one. When the
display is in the rotating frame the curve passes through the same
demodulation pipeline as the data.
"""
if (
not self._has_mpl
or self._diamagnetic_overlay is None
or self._is_frequency_plot_panel()
):
return
overlay_run, time_us, signal = self._diamagnetic_overlay
shown = rrf_display_fit_curve(self, (time_us, signal, "Diamagnetic fit"), analysis_dataset)
if shown is not None:
time_us, signal, _ = shown
current_run = (
int(self._current_dataset.run_number)
if self._current_dataset is not None and self._current_dataset.run_number is not None
else None
)
if overlay_run is not None and overlay_run != current_run:
return
finite = np.isfinite(time_us) & np.isfinite(signal)
if not np.any(finite):
return
self._ax.plot(
time_us[finite],
signal[finite],
"-",
color=tokens.WARN,
linewidth=1.5,
alpha=0.9,
label="Diamagnetic fit",
)
def _overlay_fourier_imag(self, dataset: MuonDataset, time: np.ndarray) -> None:
"""Overlay the imaginary quadrature for the Real+Imag display mode.
The averaged spectrum carries the imag channel in
``metadata["fourier_imag"]``; the primary trace is the real part.
"""
if not self._has_mpl or not isinstance(dataset.metadata, dict):
return
imag = dataset.metadata.get("fourier_imag")
if imag is None:
return
imag_arr = np.asarray(imag, dtype=float)
if imag_arr.size != np.asarray(time).size:
return
finite = np.isfinite(time) & np.isfinite(imag_arr)
if not np.any(finite):
return
self._ax.plot(
np.asarray(time)[finite],
imag_arr[finite],
"-",
color="C1",
linewidth=1.2,
alpha=0.85,
label="Imag",
)
[docs]
def plot_dataset(self, dataset: MuonDataset) -> None:
"""Plot a dataset, optionally rebinned according to the bunch factor.
The input dataset is stored unchanged as the current dataset. If the
bunch factor is greater than 1, temporary rebinned arrays are created
for plotting. The source dataset itself is never mutated.
"""
if not self._has_mpl:
return
self._set_canvas_minimum_height_for_axes(1)
self._ensure_single_axis_mode()
self._grouped_time_subplot_datasets = []
self._reset_decimation_view_state()
self._rrf_frame_drawn = None
self._clear_waterfall_render_record()
# Store the original dataset
self._current_dataset = dataset
self._current_datasets = [dataset]
self._update_plot_header()
self._set_frequency_reference_from_dataset(dataset)
analysis_dataset = rrf_display_dataset(self, self.get_analysis_dataset(dataset))
if not self._has_plottable_samples(analysis_dataset):
self._render_empty_plot_state(alpha_text=self._single_dataset_alpha_label_text(dataset))
return
time = self._convert_frequency_axis_for_display(analysis_dataset.time, dataset)
asymmetry = self._convert_frequency_values_for_display(analysis_dataset.asymmetry, dataset)
error = self._convert_frequency_values_for_display(analysis_dataset.error, dataset)
low_count_mask = self._low_count_mask_for_dataset(
analysis_dataset,
source_dataset=dataset,
)
self._last_plot_time = time
self._last_plot_asymmetry = asymmetry
self._last_plot_error = error
self._last_low_count_mask = low_count_mask
self._ax.clear()
style_axes(self._ax)
draw_zero_line(self._ax)
finite_mask = np.isfinite(time) & np.isfinite(asymmetry) & np.isfinite(error)
point_color = self._period_mode_color_for_dataset(dataset)
if self._is_frequency_plot_panel():
# Frequency spectra render as a line with an error band (the
# WiMDA/musrview/Mantid idiom), not time-domain errorbar dots. The
# low-count grey split is a raw-counts concept and is always inert
# here (``_low_count_mask_for_dataset`` returns all-False for a
# frequency panel), so there is only ever one trace to draw.
self._plot_frequency_line_masked(
self._ax,
time,
asymmetry,
error,
finite_mask,
color=point_color,
label=self._dataset_label_for(dataset),
)
self._draw_expected_larmor_marker(dataset)
else:
valid_low = finite_mask & low_count_mask
valid_main = finite_mask & ~low_count_mask
if np.any(valid_low):
self._plot_errorbar_masked(
self._ax,
time,
asymmetry,
error,
valid_low,
fmt=".",
markersize=3,
color="0.6",
ecolor="0.6",
label="_nolegend_",
)
draw_mask = valid_main if np.any(valid_main) else finite_mask
self._plot_errorbar_masked(
self._ax,
time,
asymmetry,
error,
draw_mask,
fmt=".",
markersize=3,
color=point_color,
ecolor=point_color,
label=self._dataset_label_for(dataset),
)
self._overlay_fourier_imag(dataset, time)
self._overlay_diamagnetic_fit(analysis_dataset)
x_label, y_label = self._axis_labels_for_dataset(dataset, self._current_polarization_axis)
self._apply_axis_labels(x_label, y_label)
self._set_alpha_label(self._single_dataset_alpha_label_text(dataset))
# Re-plot fit curve if it exists (check both single and global fits)
fit_to_plot = self._fit_curve_for_dataset(dataset)
fit_to_plot = rrf_display_fit_curve(self, fit_to_plot, analysis_dataset)
if fit_to_plot is not None:
t_fit, y_fit, fit_label = fit_to_plot
fit_color = self._fit_line_color_for_dataset(
dataset,
default_color=tokens.PLOT_FIT,
fit_label=fit_label,
)
self._ax.plot(
t_fit,
# Fit curves are stored on the canonical footing; a unit-area
# density view rescales them by the same displayed Jacobian as
# the spectrum itself.
self._convert_frequency_values_for_display(y_fit, dataset),
"-",
color=fit_color,
linewidth=2,
label=fit_label,
)
self._draw_annotations()
style_legend(self._ax.legend())
# Initialize limits once per content; preserve user-set limits on redraw.
# Re-arm when the displayed run/axis/view-mode changed so a switched or
# freshly loaded dataset frames to itself instead of inheriting stale
# (incl. persisted cross-session) limits.
self._reframe_if_content_changed()
# A reframe moves the axes AFTER the draw above, whose decimation was
# clipped to the PREVIOUS content's viewport — a switched dataset would
# otherwise render one view behind (fresh limits over data sampled for
# the old window, the new run's line missing entirely). Remember to
# re-decimate once the new limits are applied; a same-content redraw
# keeps its viewport and skips the extra pass.
reframed = not self._limits_initialized
if not self._limits_initialized:
# X first: time panels span the full data; frequency panels frame the
# dominant non-DC peak so high-TF Larmor lines aren't squashed off the
# full-Nyquist view by the DC peak.
x_limits = self._default_x_limits(time, asymmetry)
if x_limits is not None:
self._x_min.setValue(x_limits[0])
self._x_max.setValue(x_limits[1])
# Y framed to the signal within that window, ignoring ±100 % saturation
# sentinels and the error-divergent late-time tail. Fall back to the raw
# envelope only when no good-bin points exist.
y_limits = self._signal_y_limits_from_last_plot()
if y_limits is None:
y_min = float((asymmetry - error).min())
y_max = float((asymmetry + error).max())
y_padding = (y_max - y_min) * 0.05
y_limits = (y_min - y_padding, y_max + y_padding)
self._y_min.setValue(y_limits[0])
self._y_max.setValue(y_limits[1])
self._mark_frame_initialized()
if self._fit_x_min is None or self._fit_x_max is None:
seed = self._raw_fit_seed_range([dataset])
if seed is None:
# Fall back to the canonical (untransformed) analysis axis, not
# the display-converted ``time`` — the seed is stored canonical.
canonical = np.asarray(analysis_dataset.time, dtype=float)
canonical = canonical[np.isfinite(canonical)]
if canonical.size:
seed = (float(canonical.min()), float(canonical.max()))
else:
seed = (float(time.min()), float(time.max()))
self._fit_x_min, self._fit_x_max = seed
self._last_draw_reframed = reframed
self._draw_fit_range_artists()
# Apply the limits; a reframe re-decimates for the window just applied.
self._apply_limits(schedule_viewport_refresh=reframed)
self._apply_auto_limits_if_enabled()
self._update_export_enabled()
self._connect_axis_limit_callbacks([self._ax])
def _apply_limits(self, *, schedule_viewport_refresh: bool = False) -> None:
"""Apply the specified axis limits to the plot."""
if not self._has_mpl:
return
x0 = float(self._x_min.value())
x1 = float(self._x_max.value())
if self._is_frequency_plot_panel():
x0 = self._convert_frequency_control_value_to_axis_limit(x0)
x1 = self._convert_frequency_control_value_to_axis_limit(x1)
y0 = float(self._y_min.value())
y1 = float(self._y_max.value())
# Matplotlib warns on identical x-limits; expand degenerate ranges slightly.
if np.isclose(x0, x1):
pad = max(1e-9, abs(x0) * 1e-6)
x0 -= pad
x1 += pad
if self._subplot_axes_by_polarization:
self._draw_fit_range_artists()
self._syncing_limits_from_axes = True
try:
focused_axis = self._active_y_axis()
for axis_key, axis_obj in self._subplot_axes_by_polarization.items():
axis_obj.set_xlim(x0, x1)
# Manual Y applies only to the focused (selected) subplot.
if focused_axis == axis_key:
lo, hi = (y0, y1) if y0 <= y1 else (y1, y0)
self._y_limits_by_polarization[axis_key] = (lo, hi)
limits = self._y_limits_by_polarization.get(axis_key)
if limits is not None:
axis_obj.set_ylim(float(limits[0]), float(limits[1]))
# A non-focused subplot with no cached limit keeps its own
# (auto-scaled) y-range — manual Y never bleeds across
# subplots.
finally:
self._syncing_limits_from_axes = False
# Try to defer the rasterisation onto the coalesced viewport refresh:
# on a reframe (schedule_viewport_refresh=True) the immediate draw
# would be replaced one event-loop turn later by _redraw_current_view
# with correct decimation, so skip it when a deferred pass will run.
deferred = (
schedule_viewport_refresh
and not self._viewport_refresh_in_progress
and self._schedule_viewport_refresh()
)
if not deferred:
self._canvas.draw()
self._emit_view_limits_changed()
return
self._ax.set_xlim(x0, x1)
self._ax.set_ylim(y0, y1)
self._cache_current_y_limits_for_axis()
self._draw_fit_range_artists()
# See the subplot path above: defer the draw to the coalesced viewport
# refresh when one will genuinely run, else rasterise synchronously now.
deferred = (
schedule_viewport_refresh
and not self._viewport_refresh_in_progress
and self._schedule_viewport_refresh()
)
if not deferred:
self._canvas.draw()
self._emit_view_limits_changed()
def _current_frame_identity(self) -> tuple:
"""Signature of what is currently plotted, for first-paint reframing.
Captures the dimensions that change the natural axis scale — which runs
are shown, the polarization axis, the time-view mode (asymmetry vs raw
counts vs groups), and the stacked subplot set. Incidental redraws
(bunching, fit overlays, annotation edits) leave every component
unchanged, so the frame — and any manual zoom layered on top of it — is
preserved; a genuine content switch changes at least one component and
re-arms first-paint framing. The frequency x-unit is deliberately *not*
included: switching MHz↔field is a coordinate transform that converts
the existing limits in place, not a content change that should reframe.
Must be called after the plot path has set the current dataset/axis/
view-mode state so the signature reflects what is about to be drawn.
"""
runs: list[int] = []
for dataset in self._current_datasets or ():
try:
runs.append(int(dataset.run_number))
except (TypeError, ValueError, AttributeError):
runs.append(id(dataset))
return (
tuple(sorted(runs)),
self._current_polarization_axis,
getattr(self, "_current_time_view_mode", None),
self._is_frequency_plot_panel(),
tuple(sorted(self._subplot_axes_by_polarization)),
# Waterfall stacking rescales the natural y-extent (each trace adds
# i*Δ), so toggling it — or editing the manual Δ — must re-arm
# first-paint framing or the stack draws outside stale limits.
self.is_waterfall_enabled(),
self._waterfall_offset,
)
def _reframe_if_content_changed(self) -> None:
"""Re-arm first-paint auto-framing when the plotted content identity changed.
Resets the one-shot ``_limits_initialized`` latch so the per-path
first-paint block recomputes data-derived limits for the new content.
A no-op when the identity is unchanged, so manual zoom and persisted
same-content limits survive incidental redraws, and a no-op once the
user has taken explicit control of the limits (see
``_limits_user_locked``), so a deliberate window survives run switches.
"""
if self._limits_user_locked:
return
if self._current_frame_identity() != self._framed_identity:
self._limits_initialized = False
def _mark_frame_initialized(self) -> None:
"""Record that the current content has been first-paint framed."""
self._limits_initialized = True
self._framed_identity = self._current_frame_identity()
def _apply_auto_limits_if_enabled(self) -> None:
"""Re-apply persistent auto-limit toggles after a dataset redraw."""
if not self._has_mpl:
return
if self._auto_x_btn.isChecked():
self._auto_x_limits()
if self._auto_y_btn.isChecked():
self._auto_y_limits()
def _on_auto_x_button_clicked(self, checked: bool) -> None:
"""Apply auto X immediately when the toggle is enabled."""
if checked:
# Turning an Auto toggle ON is the explicit "always follow the data"
# escape hatch: release any manual frame lock so reframing resumes.
self._limits_user_locked = False
self._auto_x_limits()
def _on_auto_y_button_clicked(self, checked: bool) -> None:
"""Apply auto Y immediately when the toggle is enabled."""
if checked:
self._limits_user_locked = False
self._auto_y_limits()
def _draw_persistent_frequency_markers(self) -> None:
"""Redraw the pinned frequency-line markers on the active axis.
Vertical marker + optional label for each pinned frequency (stored in
canonical MHz). Built with ``add_artist`` + a ``get_xaxis_transform``
idiom — identical to :meth:`_draw_expected_larmor_marker` — so the
marker spans the axes height, never perturbs ``dataLim``/autoscale, and
(unlike a one-off ``axvline``) is recreated on every render, surviving
the show-time replot. The x position is display-unit converted so it
tracks the active frequency/field axis unit.
"""
if not self._has_mpl or not self._persistent_frequency_markers:
return
from matplotlib import lines as mlines
transform = self._ax.get_xaxis_transform(which="grid")
for marker in self._persistent_frequency_markers:
x = float(self._convert_frequency_axis_for_display(np.array([marker["freq_mhz"]]))[0])
if not np.isfinite(x):
continue
line = mlines.Line2D(
[x, x],
[0.0, 1.0],
transform=transform,
color=tokens.ACCENT,
alpha=0.7,
linestyle="--",
linewidth=1.0,
zorder=1,
)
line.set_label("_nolegend_")
self._ax.add_artist(line)
label = str(marker.get("label") or "")
if label:
self._ax.annotate(
label,
xy=(x, 0.96),
xycoords=transform,
ha="left",
va="top",
fontsize=8,
color=tokens.ACCENT,
zorder=5,
)
def _draw_annotations(self) -> None:
"""Recreate annotation artists on the active axis."""
rrf_draw_badge(self, self._ax)
self._draw_persistent_frequency_markers()
for ann in self._annotations:
artist = self._ax.text(
ann["x"],
ann["y"],
ann["text"],
fontsize=9,
bbox={
"boxstyle": "round,pad=0.2",
"facecolor": tokens.SURFACE,
"edgecolor": tokens.BORDER,
"alpha": 0.95,
},
zorder=5,
)
ann["artist"] = artist
def _default_x_limits(
self, time: np.ndarray, asymmetry: np.ndarray
) -> tuple[float, float] | None:
"""Initial x-limits (in control-value units) for a freshly plotted dataset.
Time panels frame the full data span. Frequency panels frame the dominant
non-DC spectral peak when one stands out, so high-transverse-field Larmor
lines are visible on first paint instead of being squashed off-screen by a
full-Nyquist view dominated by the DC peak.
"""
finite = np.isfinite(time)
if not np.any(finite):
return None
t = time[finite]
x_min = float(np.min(t))
x_max = float(np.max(t))
# Shift/ppm axes are already reference-centred: the physics of interest
# sits near 0, so frame the transformed data extent directly (the
# absolute-mode peak/field/centred heuristics assume an un-shifted axis
# and are skipped). Make the window symmetric about 0 when 0 lies inside
# the extent, so a paramagnetic shift reads at a glance.
if self._is_frequency_plot_panel() and self._is_frequency_shift_mode():
pad = (x_max - x_min) * 0.05 if x_max > x_min else max(abs(x_min) * 0.05, 1e-6)
lower = x_min - pad
upper = x_max + pad
if lower <= 0.0 <= upper:
half = max(abs(lower), abs(upper))
lower, upper = -half, half
return (lower, upper)
# Correlation-axis frequency mode is a coupling coordinate, not a Larmor
# spectrum, so dominant-peak framing is not meaningful there.
if self._is_frequency_plot_panel() and not self._frequency_axis_is_correlation:
centered = self._frequency_centered_window(time, asymmetry)
if centered is not None:
# High-TF case: the line is unresolvable in a from-zero view
# (0.18 MHz wide at 813 MHz is sub-pixel on a [0, 1.2 GHz]
# axis), so frame a window AROUND it instead — WiMDA's
# reference-field ± offsets view, derived from the data.
x_min, x_max = centered
else:
peak_upper = self._frequency_peak_upper_bound(time, asymmetry)
field_upper = self._frequency_field_upper_bound(time)
# Combine by max(): data evidence must never be framed out by the
# field expectation (muonium / radical lines sit far above γ_μ·B),
# and the expected Larmor region must never be framed out by a
# lone lower-frequency line (e.g. a strong background peak).
candidates = [
upper
for upper in (peak_upper, field_upper)
if upper is not None and upper > x_min
]
if candidates:
# Keep the data's own lower edge (an rfft spectrum starts at
# the DC bin); only pull the upper edge in to frame the peak.
# Injecting an absolute 0 here would misbehave on
# reference-relative / field axes.
x_max = max(candidates)
if x_max <= x_min:
pad = max(abs(x_min) * 0.05, 1e-6)
else:
pad = (x_max - x_min) * 0.05
lower = x_min - pad
upper = x_max + pad
# Time panels: the displayed time axis is t0-referenced (muon arrival at
# t=0), so never frame into the empty pre-t0 band — clamp the lower edge
# to t0 whether the data merely pads below zero or actually carries
# pre-t0 bins. Skipped on frequency axes (0 is not a meaningful edge
# there; the DC-bin lower edge is handled above) and in the degenerate
# all-negative case (keep the data visible rather than invert the range).
if not self._is_frequency_plot_panel() and upper > 0.0:
lower = max(lower, 0.0)
return (
self._convert_frequency_axis_limit_to_control_value(lower),
self._convert_frequency_axis_limit_to_control_value(upper),
)
def _frequency_field_upper_bound(self, display_freqs: np.ndarray) -> float | None:
"""Upper display-axis bound framing the field-expected Larmor line, or ``None``.
The peak heuristic is structurally blind to exactly the cases where a
first look matters most: a weak line that does not clear the prominence
threshold, and a low-field line hiding inside the excluded DC region
(a 20 G line at 0.27 MHz sits below the 2 % DC cut of a ~30 MHz span).
When the run carries an applied field, γ_μ·B says where to look —
WiMDA frames its FFT plot around the reference field the same way
(``WiMDA_Main.pas`` ``UseShiftRange``). The 1.5× headroom puts the
expected line at two-thirds of the axis. Skipped on the
reference-relative axis, where the line sits at ~0 and an upper bound
derived from it would collapse the window, and when the expected line
is at/off the Nyquist edge (the full span already frames it).
"""
if self.is_frequency_axis_relative_to_reference():
return None
# A multi-run overlay can span a field series; frame to the HIGHEST
# member field so no member's expected line starts off-screen.
datasets = self._current_datasets or (
[self._current_dataset] if self._current_dataset is not None else []
)
fields = [
field
for field in (
reference_field_gauss(getattr(dataset, "run", None), dataset)
for dataset in datasets
)
if field is not None and field > 0.0
]
if not fields:
return None
expected_mhz = float(gauss_to_mhz(max(fields)))
if not np.isfinite(expected_mhz) or expected_mhz <= 0.0:
return None
center = float(self._convert_frequency_axis_for_display(np.array([expected_mhz]))[0])
if not np.isfinite(center) or center <= 0.0:
return None
finite = np.asarray(display_freqs, dtype=float)
finite = finite[np.isfinite(finite)]
if finite.size == 0:
return None
f_max = float(np.max(finite))
upper = 1.5 * center
if upper >= f_max:
return None
return upper
def _frequency_significant_lines(
self, freqs: np.ndarray, values: np.ndarray
) -> tuple[np.ndarray, np.ndarray, float] | None:
"""Return ``(line_freqs, line_ratios, f_max)`` for baseline-clearing bins.
The shared line finder behind the first-paint framing helpers;
``line_ratios`` is each bin's height over its LOCAL baseline. Returns
``None`` for DC-only or featureless spectra, where no non-DC bin clears
the baseline and zooming would merely chase a noise spike. The DC
region (zero-frequency peak + filter rolloff, the lowest 2 % of the
span) is excluded — the DC peak typically dwarfs the signal, so it must
not count as a line.
The baseline is a block-median estimated locally, not one global
median: a real TDC spectrum's noise floor is a coloured pedestal (run
687's floor below ~1 GHz sits 22× above its quiet high-frequency
level), so against a global median a quarter of the spectrum reads as
"significant" and the framing chases the pedestal's edge instead of
the physics. A narrow line perturbs its own block's median negligibly.
"""
f = np.asarray(freqs, dtype=float)
v = np.abs(np.asarray(values, dtype=float))
finite = np.isfinite(f) & np.isfinite(v)
f = f[finite]
v = v[finite]
if f.size < 4:
return None
f_max = float(np.max(f))
if f_max <= 0.0:
return None
dc_cut = f_max * 0.02
nondc = f > dc_cut
if np.count_nonzero(nondc) < 2:
return None
f_nondc = f[nondc]
v_nondc = v[nondc]
n_blocks = int(np.clip(f_nondc.size // 64, 8, 512))
usable = (f_nondc.size // n_blocks) * n_blocks
if usable >= n_blocks:
block_medians = np.median(v_nondc[:usable].reshape(n_blocks, -1), axis=1)
block_centers = f_nondc[:usable].reshape(n_blocks, -1).mean(axis=1)
baseline = np.interp(f_nondc, block_centers, block_medians)
else:
baseline = np.full_like(v_nondc, float(np.median(v_nondc)))
fallback = float(np.median(v_nondc)) or float(np.mean(v_nondc))
baseline = np.where(baseline > 0.0, baseline, fallback)
if not np.any(baseline > 0.0):
return None
ratios = np.divide(v_nondc, baseline, out=np.zeros_like(v_nondc), where=baseline > 0.0)
significant = ratios > _FREQUENCY_PEAK_PROMINENCE
if not np.any(significant):
return None
return f_nondc[significant], ratios[significant], f_max
def _frequency_peak_upper_bound(self, freqs: np.ndarray, values: np.ndarray) -> float | None:
"""Upper frequency-axis bound that frames the highest-frequency line.
Returns ``None`` (keep the full span) for DC-only or featureless
spectra. Otherwise frames to the *highest-frequency* line that rises
above the baseline — not just the single tallest peak — so weaker
high-frequency lines (high-TF Larmor lines, muoniated-radical pairs)
are not cut off by a stronger low-frequency line such as the
diamagnetic peak.
"""
lines = self._frequency_significant_lines(freqs, values)
if lines is None:
return None
line_freqs, line_ratios, f_max = lines
# Only STRICT lines drive the upper bound: on a large padded spectrum
# extreme-value noise reaches ~4.5× its local baseline, so framing to
# the highest merely-significant bin would chase a stray. Real
# secondary lines (radical pairs, satellites) clear 2× the prominence
# threshold comfortably.
strict = line_ratios >= 2.0 * _FREQUENCY_PEAK_PROMINENCE
if not np.any(strict):
return None
# Frame to the highest-frequency strict line with head-room, never
# past the available data.
highest_f = float(np.max(line_freqs[strict]))
return float(min(f_max, highest_f * 1.5))
def _frequency_centered_window(
self, freqs: np.ndarray, values: np.ndarray
) -> tuple[float, float] | None:
"""A window centred on the dominant line, when a from-zero view can't show it.
High transverse field puts a narrow line at a large frequency: run 687
of the corpus has a 0.18 MHz-wide line at 813 MHz, which is sub-pixel
on the from-zero [0, 1.5·f₀] view the wide framing produces. When the
highest significant line's measured FWHM is below 1/400 of that wide
span — genuinely unresolvable, not merely narrow — frame
``f₀ ± max(0.08·f₀, 30·FWHM)`` instead, WiMDA's reference-field ±
offsets view derived from the data. Bails to the wide framing whenever
any other significant line would fall outside the centred window (AFM
satellites, radical pairs — nothing may be framed out) or on the
reference-relative axis, where the framing maths lives near zero.
"""
if self.is_frequency_axis_relative_to_reference():
return None
lines = self._frequency_significant_lines(freqs, values)
if lines is None:
return None
line_freqs, line_ratios, f_max = lines
# Centre on the bin most prominent over its LOCAL baseline — the
# summit. The highest FREQUENCY significant bin is a tail bin a few
# linewidths up-slope, and a half-maximum walk started there straddles
# the summit and measures the whole prominence region instead of the
# line.
center = float(line_freqs[int(np.argmax(line_ratios))])
if center <= 0.0:
return None
f = np.asarray(freqs, dtype=float)
v = np.abs(np.asarray(values, dtype=float))
finite = np.isfinite(f) & np.isfinite(v)
f = f[finite]
v = v[finite]
order = np.argsort(f)
f = f[order]
v = v[order]
peak_index = int(np.argmin(np.abs(f - center)))
half = 0.5 * float(v[peak_index])
left = peak_index
while left > 0 and v[left] > half:
left -= 1
right = peak_index
while right < v.size - 1 and v[right] > half:
right += 1
fwhm = float(f[right] - f[left])
if not np.isfinite(fwhm) or fwhm <= 0.0:
return None
x_min_data = float(np.min(f))
wide_span = min(f_max, center * 1.5) - x_min_data
if wide_span <= 0.0 or fwhm / wide_span >= 1.0 / 400.0:
return None
half_window = max(0.08 * center, 30.0 * fwhm)
lower = max(x_min_data, center - half_window)
upper = min(f_max, center + half_window)
if upper <= lower:
return None
# Only a STRICT line outside the window vetoes the zoom: nothing real
# may be framed out, but extreme-value noise bins (which barely clear
# the significance threshold against their local baseline) must not
# hold the view hostage at full span.
strict = line_ratios >= 2.0 * _FREQUENCY_PEAK_PROMINENCE
strict_freqs = line_freqs[strict]
if np.any(strict_freqs < lower) or np.any(strict_freqs > upper):
return None
return lower, upper
def _auto_x_limits(self) -> None:
"""Auto-scale x-axis and update x-limit controls."""
if not self._has_mpl:
return
if self._last_plot_time is None:
return
finite_mask = np.isfinite(self._last_plot_time)
if not np.any(finite_mask):
return
# On a frequency spectrum, "Auto X" should frame the line sensibly (the
# dominant non-DC peak / field-derived window), not span the full Nyquist
# range where a high-TF Larmor line is squashed to sub-pixel by the DC
# peak. Delegate to the same smart framing used on first paint; it already
# returns control-value units and skips correlation axes. Fall back to the
# raw padded span when it declines (e.g. correlation axis, no finite data).
if self._is_frequency_plot_panel() and self._last_plot_asymmetry is not None:
smart = self._default_x_limits(self._last_plot_time, self._last_plot_asymmetry)
if smart is not None:
self._x_min.setValue(smart[0])
self._x_max.setValue(smart[1])
self._apply_limits(schedule_viewport_refresh=True)
return
time = self._last_plot_time[finite_mask]
x_min = float(np.min(time))
x_max = float(np.max(time))
if x_max <= x_min:
delta = max(abs(x_min) * 0.05, 1e-6)
x_min -= delta
x_max += delta
else:
padding = (x_max - x_min) * 0.05
x_min -= padding
x_max += padding
self._x_min.setValue(self._convert_frequency_axis_limit_to_control_value(x_min))
self._x_max.setValue(self._convert_frequency_axis_limit_to_control_value(x_max))
# Widening the x-window past the previous (possibly zoomed-in) view leaves
# the rendered points decimated for the *old* narrow viewport — the data
# outside it stays missing until a redraw recomputes decimation. Schedule a
# viewport refresh so Auto X re-decimates over the full range it just set.
# (_last_plot_time is already full-resolution, so the computed range is
# correct; only the on-screen sample was stale.) The refresh is coalesced
# and self-suppresses while one is already in progress, so the render-path
# call via _apply_auto_limits_if_enabled cannot recurse.
self._apply_limits(schedule_viewport_refresh=True)
def _auto_y_limits(self) -> None:
"""Auto-scale y-axis from visible, non-low-count points only."""
if not self._has_mpl:
return
if self._grouped_time_subplot_datasets and self._subplot_axes_by_polarization:
updated = False
for dataset in self._grouped_time_subplot_datasets:
axis_key = self._grouped_subplot_axis_key(dataset)
if axis_key not in self._subplot_axes_by_polarization:
continue
limits = self._auto_y_limits_for_datasets([dataset])
if limits is None:
continue
self._y_limits_by_polarization[axis_key] = limits
updated = True
if not updated:
return
self._sync_y_controls_with_visible_axis()
self._apply_limits()
return
if self._subplot_axes_by_polarization and self._current_polarization_axis == "ALL":
updated = False
for axis_key in self._all_mode_axes_order():
limits = self._auto_y_limits_for_datasets(
self._vector_subplot_datasets.get(axis_key, [])
)
if limits is None:
continue
self._y_limits_by_polarization[axis_key] = limits
updated = True
if not updated:
return
self._sync_y_controls_with_visible_axis()
self._apply_limits()
return
limits = self._signal_y_limits_from_last_plot()
if limits is None:
return
y_min, y_max = limits
self._y_min.setValue(y_min)
self._y_max.setValue(y_max)
self._apply_limits()
def _signal_y_limits_from_last_plot(self) -> tuple[float, float] | None:
"""Robust y-limits framing the signal in the current x-window.
Restricts to finite, in-window, non-low-count bins (good-bin/signal
region) and drops ±100 % saturation sentinels, so the error-divergent
late-time tail and bad-bin sentinels never blow the range out. Falls
back to progressively looser masks when the window is empty. Returns
``None`` when no usable points exist (callers keep their prior limits).
"""
if (
self._last_plot_time is None
or self._last_plot_asymmetry is None
or self._last_plot_error is None
):
return None
x_lo = float(self._x_min.value())
x_hi = float(self._x_max.value())
if self._is_frequency_plot_panel():
x_lo = self._convert_frequency_control_value_to_axis_limit(x_lo)
x_hi = self._convert_frequency_control_value_to_axis_limit(x_hi)
lo, hi = (x_lo, x_hi) if x_lo <= x_hi else (x_hi, x_lo)
time = self._last_plot_time
asymmetry = self._last_plot_asymmetry
error = self._last_plot_error
low_mask = self._last_low_count_mask
if low_mask is None:
low_mask = np.zeros_like(time, dtype=bool)
mask = (
np.isfinite(time)
& np.isfinite(asymmetry)
& np.isfinite(error)
& (time >= lo)
& (time <= hi)
& (~low_mask)
)
if not np.any(mask):
mask = np.isfinite(asymmetry) & np.isfinite(error) & (~low_mask)
if not np.any(mask):
mask = np.isfinite(asymmetry) & np.isfinite(error)
if not np.any(mask):
return None
return self._auto_y_limits_from_arrays(asymmetry, error, mask)
def _y_axis_is_asymmetry_percent(self) -> bool:
"""True only when the y-axis shows F-B / group asymmetry in percent.
That is the one axis the ±100 % saturation sentinel applies to. The
frequency view (a.u.) and the raw-counts time view (counts in the
hundreds–thousands) are *not* percent, and their large magnitudes are
legitimate signal, so the sentinel clip must never touch them.
"""
if self._is_frequency_plot_panel():
return False
return getattr(self, "_current_time_view_mode", "fb_asymmetry") != "raw_counts"
def _mask_without_saturation_sentinels(
self, asymmetry: np.ndarray, mask: np.ndarray
) -> np.ndarray:
"""Drop ±100 % saturation sentinels from *mask* on the percent axis.
Only the asymmetry-percent axis carries this sentinel; the frequency
(a.u.) and raw-counts axes hold legitimately large magnitudes, so the
mask is returned unchanged there. If clipping would empty the mask the
original is kept, so a degenerate all-saturated window still yields
*some* limits rather than none.
A waterfall overlay stores displayed (offset) values in the last-plot
arrays, where stacked ±25 % traces legitimately cross ±100 % and a
genuine sentinel is shifted off it, so the threshold on *asymmetry*
misfires both ways. The overlay draw therefore records a raw-value
sentinel mask (``_last_plot_sentinel_mask``) which is preferred when it
aligns with the given array; every other path falls back to the
threshold test.
"""
if not self._y_axis_is_asymmetry_percent():
return mask
stored = self._last_plot_sentinel_mask
if stored is not None and stored.shape == np.shape(asymmetry):
sentinel = stored
else:
finite_asym = np.isfinite(asymmetry)
sentinel = finite_asym & (np.abs(asymmetry) >= _ASYMMETRY_SATURATION_PERCENT)
if not np.any(sentinel):
return mask
trimmed = mask & ~sentinel
return trimmed if np.any(trimmed) else mask
def _auto_y_limits_from_arrays(
self,
asymmetry: np.ndarray,
error: np.ndarray,
mask: np.ndarray,
) -> tuple[float, float] | None:
"""Return padded y-limits for the masked asymmetry arrays."""
if not np.any(mask):
return None
mask = self._mask_without_saturation_sentinels(asymmetry, mask)
if not np.any(mask):
return None
asym = asymmetry[mask]
err = error[mask]
if self._is_frequency_plot_panel():
# A spectrum is non-negative with a dominant peak; the signed
# asymmetry-envelope logic below (error ceilings, weighted
# quantile bounds) is tuned for time-domain data and can collapse
# to degenerate limits on it. Frame the in-window peak directly,
# with a small underdraw so the baseline reads as zero.
peak = float(np.max(asym))
if peak > 0.0:
return -0.04 * peak, 1.10 * peak
# On low-statistics (transverse-field) data the counts decay so the
# late-time error bars diverge (±100 %+) even inside the good-bin window,
# and framing to the raw asymmetry ± error envelope buries the ~±20 %
# precession in a thin band. Bound each bin's error contribution to a
# robust ceiling anchored to the signal envelope so those divergent bars
# can no longer blow the range out. The ceiling sits above every bar on
# uniform-error (ZF/LF) data, so nothing is clipped and framing there is
# unchanged. The frequency view keeps its own framing.
# The reliability weight keys on the original (pre-clamp) error: clamping
# only bounds the envelope *value*, so a divergent bar must still be
# down-weighted, never boosted by having been clamped.
weight_err = err
if not self._is_frequency_plot_panel():
ceiling = self._signal_frame_error_ceiling(asym, err)
if ceiling is not None:
err = np.minimum(err, ceiling)
lower = asym - err
upper = asym + err
if self._y_axis_is_asymmetry_percent():
# Down-weight the counts-depleted late-time tail (large |asymmetry|
# with large error) so it cannot reopen the ±220 % blowout; reduces
# to plain min/max for uniform errors and small samples.
q = _SIGNAL_FRAME_WEIGHTED_QUANTILE
y_min = self._inverse_variance_weighted_bound(lower, weight_err, q)
y_max = self._inverse_variance_weighted_bound(upper, weight_err, 1.0 - q)
else:
y_min = float(np.min(lower))
y_max = float(np.max(upper))
if y_max <= y_min:
delta = max(abs(y_min) * 0.05, 1e-6)
y_min -= delta
y_max += delta
else:
padding = (y_max - y_min) * 0.05
y_min -= padding
y_max += padding
return y_min, y_max
def _inverse_variance_weighted_bound(
self,
values: np.ndarray,
error: np.ndarray,
quantile: float,
) -> float:
"""Return the inverse-variance-weighted *quantile* of *values*.
Each value is weighted by ``1 / σ²`` so high-error (counts-depleted)
bins contribute little and a sparse late-time scatter tail cannot pull
the frame out. The bound is the smallest value whose cumulative
normalised weight (over values sorted ascending) reaches *quantile*. For
uniform weights and small samples this collapses to the plain min/max
(``quantile ≈ 0`` → minimum, ``≈ 1`` → maximum), so framing on
uniform-error data is unchanged.
"""
vals = np.asarray(values, dtype=float)
errs = np.asarray(error, dtype=float)
if vals.size == 0:
return 0.0
if vals.size == 1:
return float(vals[0])
weights = 1.0 / np.maximum(errs, _SIGNAL_FRAME_WEIGHT_ERROR_FLOOR) ** 2
finite = np.isfinite(vals) & np.isfinite(weights) & (weights > 0.0)
if not np.any(finite):
# No usable weights — fall back to the unweighted extreme.
return float(np.max(vals) if quantile >= 0.5 else np.min(vals))
vals = vals[finite]
weights = weights[finite]
order = np.argsort(vals)
vals = vals[order]
weights = weights[order]
cumulative = np.cumsum(weights)
total = float(cumulative[-1])
if not np.isfinite(total) or total <= 0.0:
return float(vals[-1] if quantile >= 0.5 else vals[0])
cumulative = cumulative / total
idx = int(np.searchsorted(cumulative, quantile, side="left"))
idx = min(max(idx, 0), vals.size - 1)
return float(vals[idx])
def _signal_frame_error_ceiling(self, asymmetry: np.ndarray, error: np.ndarray) -> float | None:
"""Robust per-bin error ceiling for framing the signal envelope.
Returns :data:`_SIGNAL_FRAME_ERROR_CEILING_FACTOR` times the larger of
the signal half-span (from robust 2.5/97.5 percentiles of the in-window
asymmetry) and the lower-quartile error, or ``None`` when that scale is
degenerate. Anchoring to the signal keeps the frame ~±(signal) even when
the late-time error bars diverge; the lower-quartile-error floor (the
well-measured bins' scale) keeps the ceiling above genuine error bars on
uniform-error or noise-dominated data, so those are never clipped.
"""
asym = np.asarray(asymmetry, dtype=float)
err = np.asarray(error, dtype=float)
finite_err = err[np.isfinite(err) & (err >= 0.0)]
finite_asym = asym[np.isfinite(asym)]
if finite_err.size == 0 or finite_asym.size == 0:
return None
a_lo, a_hi = np.percentile(finite_asym, (2.5, 97.5))
signal_half = max((float(a_hi) - float(a_lo)) / 2.0, 0.0)
typical_err = float(np.percentile(finite_err, 25))
scale = max(signal_half, typical_err)
if not np.isfinite(scale) or scale <= 0.0:
return None
return scale * _SIGNAL_FRAME_ERROR_CEILING_FACTOR
def _auto_y_limits_for_datasets(
self,
datasets: list[MuonDataset],
) -> tuple[float, float] | None:
"""Return auto y-limits for one polarization's datasets."""
all_times: list[np.ndarray] = []
all_asymmetry: list[np.ndarray] = []
all_error: list[np.ndarray] = []
all_low_masks: list[np.ndarray] = []
for dataset in datasets:
analysis_dataset = rrf_display_dataset(self, self.get_analysis_dataset(dataset))
if analysis_dataset is None:
continue
time = self._convert_frequency_axis_for_display(analysis_dataset.time, dataset)
asymmetry = self._convert_frequency_values_for_display(
analysis_dataset.asymmetry, dataset
)
error = self._convert_frequency_values_for_display(analysis_dataset.error, dataset)
low_mask = self._low_count_mask_for_dataset(
analysis_dataset,
source_dataset=dataset,
)
finite_mask = np.isfinite(time) & np.isfinite(asymmetry) & np.isfinite(error)
if not np.any(finite_mask):
continue
all_times.append(time[finite_mask])
all_asymmetry.append(asymmetry[finite_mask])
all_error.append(error[finite_mask])
all_low_masks.append(low_mask[finite_mask])
if not all_times:
return None
time = np.concatenate(all_times)
asymmetry = np.concatenate(all_asymmetry)
error = np.concatenate(all_error)
low_mask = np.concatenate(all_low_masks)
x_lo = float(self._x_min.value())
x_hi = float(self._x_max.value())
if self._is_frequency_plot_panel():
x_lo = self._convert_frequency_control_value_to_axis_limit(x_lo)
x_hi = self._convert_frequency_control_value_to_axis_limit(x_hi)
lo, hi = (x_lo, x_hi) if x_lo <= x_hi else (x_hi, x_lo)
mask = (
np.isfinite(time)
& np.isfinite(asymmetry)
& np.isfinite(error)
& (time >= lo)
& (time <= hi)
& (~low_mask)
)
if not np.any(mask):
mask = np.isfinite(asymmetry) & np.isfinite(error) & (~low_mask)
if not np.any(mask):
mask = np.isfinite(asymmetry) & np.isfinite(error)
return self._auto_y_limits_from_arrays(asymmetry, error, mask)
def _subplot_datasets_for_axis(self, axis_key: str) -> list[MuonDataset]:
"""Datasets feeding the stacked subplot keyed by *axis_key*."""
if self._grouped_time_subplot_datasets:
return [
dataset
for dataset in self._grouped_time_subplot_datasets
if self._grouped_subplot_axis_key(dataset) == axis_key
]
return list(self._vector_subplot_datasets.get(axis_key, []))
def _frame_subplot_axes_to_signal(self) -> None:
"""Frame each stacked subplot's y-axis to its own signal envelope.
Used at first paint so the Individual-groups / vector views open framed on
the good-bin region (sentinels and the error-divergent tail excluded)
instead of matplotlib's raw autoscale over every plotted point. Mirrors
what the Auto Y button does per axis; manual/cached limits still win in
:meth:`_apply_limits`.
"""
if not self._subplot_axes_by_polarization:
return
for axis_key, ax in self._subplot_axes_by_polarization.items():
# Respect an already-cached (e.g. restored or manually pinned) limit,
# matching the build loop which only auto-scales axes without one.
if axis_key in self._y_limits_by_polarization:
continue
datasets = self._subplot_datasets_for_axis(axis_key)
if not datasets:
continue
limits = self._auto_y_limits_for_datasets(datasets)
if limits is None:
continue
self._y_limits_by_polarization[axis_key] = limits
if hasattr(ax, "set_ylim"):
ax.set_ylim(float(limits[0]), float(limits[1]))
def _low_count_mask_for_dataset(
self,
dataset: MuonDataset,
*,
source_dataset: MuonDataset | None = None,
) -> np.ndarray:
"""Return mask of low-count bins (plotted gray) for *dataset* points."""
time = np.asarray(dataset.time, dtype=float)
mask = np.zeros_like(time, dtype=bool)
if (
self._is_frequency_plot_panel()
or self._is_frequency_domain_dataset(dataset)
or self._is_frequency_domain_dataset(source_dataset)
):
return mask
low_confidence = self._low_confidence_mask_for_dataset(
dataset,
source_dataset=source_dataset,
)
if low_confidence is not None and low_confidence.shape == mask.shape:
mask |= low_confidence
run = dataset.run
if run is None or not isinstance(getattr(run, "grouping", None), dict):
return mask
grouping = run.grouping
first_good = grouping.get("first_good_bin")
last_good = grouping.get("last_good_bin")
if first_good is None or last_good is None:
return mask
histograms = getattr(run, "histograms", None)
axis = np.asarray([], dtype=float)
if histograms:
hist0 = histograms[0]
axis = np.asarray(hist0.time_axis, dtype=float)
try:
lo_idx = max(0, int(first_good))
hi_idx = int(last_good)
except (TypeError, ValueError):
return mask
if lo_idx > hi_idx:
return mask
# Prefer histogram time-axis boundaries when available. This keeps the
# masking consistent for rebinned analysis datasets.
if axis.size > 0:
hi_axis_idx = min(hi_idx, axis.size - 1)
if lo_idx > hi_axis_idx:
return mask
good_t_min = float(axis[lo_idx])
good_t_max = float(axis[hi_axis_idx])
if good_t_min > good_t_max:
good_t_min, good_t_max = good_t_max, good_t_min
tol = 1e-12
if axis.size >= 2:
step = float(np.nanmedian(np.diff(axis)))
if np.isfinite(step) and step != 0.0:
tol = max(1e-12, abs(step) * 1e-6)
mask |= (time < (good_t_min - tol)) | (time > (good_t_max + tol))
return mask
# Fallback for datasets that carry grouping metadata but no raw
# histograms. Use the source, pre-rebinning time axis when available.
reference_time = np.asarray(
source_dataset.time if source_dataset is not None else dataset.time,
dtype=float,
)
if reference_time.size == 0:
return mask
max_idx = reference_time.size - 1
if lo_idx > max_idx:
return mask
hi_ref_idx = min(hi_idx, max_idx)
if lo_idx > hi_ref_idx:
return mask
good_t_min = float(reference_time[lo_idx])
good_t_max = float(reference_time[hi_ref_idx])
if good_t_min > good_t_max:
good_t_min, good_t_max = good_t_max, good_t_min
tol = 1e-12
if reference_time.size >= 2:
step = float(np.nanmedian(np.diff(reference_time)))
if np.isfinite(step) and step != 0.0:
tol = max(1e-12, abs(step) * 1e-6)
mask |= (time < (good_t_min - tol)) | (time > (good_t_max + tol))
return mask
def _low_confidence_mask_for_dataset(
self,
dataset: MuonDataset,
*,
source_dataset: MuonDataset | None = None,
) -> np.ndarray | None:
"""Return low-confidence mask from grouped-count reliability checks.
Historical behavior marks saturated ±100% bins and bins with
non-positive grouped denominator as low-confidence, rendered in gray.
"""
reference_dataset = source_dataset if source_dataset is not None else dataset
reference_metadata = getattr(reference_dataset, "metadata", None)
analysis_metadata = getattr(dataset, "metadata", None)
is_grouped_time_domain = bool(
isinstance(reference_metadata, dict)
and reference_metadata.get("grouped_time_domain")
or isinstance(analysis_metadata, dict)
and analysis_metadata.get("grouped_time_domain")
)
reference_asym = np.asarray(reference_dataset.asymmetry, dtype=float)
if reference_asym.size == 0:
return np.zeros_like(dataset.time, dtype=bool)
saturated = np.zeros_like(reference_asym, dtype=bool)
if not is_grouped_time_domain:
saturated = (np.abs(reference_asym) > 100.0) | np.isclose(
np.abs(reference_asym),
100.0,
atol=1e-12,
)
run = reference_dataset.run
if (
run is None
or not run.histograms
or not isinstance(getattr(run, "grouping", None), dict)
):
return self._project_source_mask_to_analysis_dataset(
source_mask=saturated,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
grouping = run.grouping
groups = grouping.get("groups")
if not isinstance(groups, dict):
return self._project_source_mask_to_analysis_dataset(
source_mask=saturated,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
# The denominator-reliability reduction below sums RAW grouped counts at
# fixed binning. Under variable / constant-error binning the displayed
# asymmetry is summed onto wider edges, so a per-raw-bin denominator no
# longer maps to the plotted bins; the saturation check above (computed
# on the displayed asymmetry) already flags unreliable bins there. Skip
# the raw-bin reduction explicitly rather than letting it self-disable
# via a silent array-shape mismatch.
binning_mode, _, _ = resolve_binning_mode(grouping)
if binning_mode != "fixed":
return self._project_source_mask_to_analysis_dataset(
source_mask=saturated,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
# Reduce the forward/backward groups through the SAME core chokepoint the
# reduction uses (group_forward_backward: resolves groups via the
# exclusion-aware resolver, aligns to a common t0, sums, clamps alpha),
# so the mask cannot drift from the engine's grouping/exclusion/alpha
# handling. It raises when groups are undefined or empty after exclusion.
try:
fb = group_forward_backward(run.histograms, grouping)
except ValueError:
return self._project_source_mask_to_analysis_dataset(
source_mask=saturated,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
n_grouped = min(len(fb.forward), len(fb.backward))
if n_grouped == 0:
return self._project_source_mask_to_analysis_dataset(
source_mask=saturated,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
forward = fb.forward[:n_grouped]
backward = fb.backward[:n_grouped]
alpha = fb.alpha
common_t0 = fb.common_t0
if bool(grouping.get("background_correction", False)):
run_metadata = getattr(run, "metadata", None)
metadata = run_metadata if isinstance(run_metadata, dict) else {}
source_file = str(
getattr(run, "source_file", "") or reference_dataset.metadata.get("source_file", "")
)
# Mode-aware gate (matches the real reduction): apply whichever
# background mode the grouping selects when it is available for this
# source, instead of the old PSI/LEM-only range gate. (reference_run
# needs externally resolved counts that this display-only mask does
# not load, so apply_grouped_background_correction no-ops it here —
# a minor difference in which bins render gray, never in the data.)
mode = resolve_background_mode(grouping)
available = available_background_modes(metadata=metadata, source_file=source_file)
if mode in available:
bin_width = float(run.histograms[0].bin_width) if run.histograms else 1.0
facility = str(
metadata.get(
"facility",
reference_dataset.metadata.get(
"facility",
reference_dataset.metadata.get("instrument", ""),
),
)
)
bkg_result = apply_grouped_background_correction(
forward,
backward,
grouping=grouping,
t0_bin=common_t0,
bin_width_us=bin_width,
facility=facility,
)
if bkg_result.applied:
forward = bkg_result.forward
backward = bkg_result.backward
denominator = np.asarray(forward + alpha * backward, dtype=float)
if denominator.size == 0:
return self._project_source_mask_to_analysis_dataset(
source_mask=saturated,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
lo_default = 0
hi_default = denominator.size - 1
try:
lo = int(grouping.get("first_good_bin", lo_default))
except (TypeError, ValueError):
lo = lo_default
try:
hi = int(grouping.get("last_good_bin", hi_default))
except (TypeError, ValueError):
hi = hi_default
lo = max(0, min(lo, hi_default))
hi = max(0, min(hi, hi_default))
if lo > hi:
return self._project_source_mask_to_analysis_dataset(
source_mask=saturated,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
denominator = denominator[lo : hi + 1]
reliable = denominator > 0.0
source_low = saturated.copy()
if reliable.shape == source_low.shape:
source_low |= ~reliable
return self._project_source_mask_to_analysis_dataset(
source_mask=source_low,
source_dataset=reference_dataset,
analysis_dataset=dataset,
)
def _project_source_mask_to_analysis_dataset(
self,
*,
source_mask: np.ndarray,
source_dataset: MuonDataset,
analysis_dataset: MuonDataset,
) -> np.ndarray:
"""Project source-bin boolean mask onto the plotted analysis dataset."""
source_mask = np.asarray(source_mask, dtype=bool)
source_time = np.asarray(source_dataset.time, dtype=float)
target_time = np.asarray(analysis_dataset.time, dtype=float)
if target_time.size == 0:
return np.zeros(0, dtype=bool)
if source_mask.size == 0 or source_time.size == 0:
return np.zeros_like(target_time, dtype=bool)
if source_mask.size != source_time.size:
n = min(source_mask.size, source_time.size)
source_mask = source_mask[:n]
source_time = source_time[:n]
if n == 0:
return np.zeros_like(target_time, dtype=bool)
if source_time.size == target_time.size:
return source_mask.copy()
# The RRF display slices the filter-edge region off the analysis
# dataset; the recorded trim restores the cheap projection paths.
trim = (
analysis_dataset.metadata.get("rrf_trim")
if isinstance(analysis_dataset.metadata, dict)
else None
)
if isinstance(trim, (list, tuple)) and len(trim) == 3:
start, stop, pre_size = (int(v) for v in trim)
if stop - start == target_time.size and 0 <= start <= stop <= pre_size:
if source_mask.size == pre_size:
return source_mask[start:stop].copy()
pre_bunch = int(self._bunch_factor.value()) if hasattr(self, "_bunch_factor") else 1
if pre_bunch > 1 and source_mask.size >= pre_size * pre_bunch:
folded = (
source_mask[: pre_size * pre_bunch].reshape(pre_size, pre_bunch).any(axis=1)
)
return folded[start:stop]
bunch_factor = int(self._bunch_factor.value()) if hasattr(self, "_bunch_factor") else 1
if bunch_factor > 1 and source_time.size >= target_time.size:
trimmed = (source_time.size // bunch_factor) * bunch_factor
if trimmed > 0 and (trimmed // bunch_factor) == target_time.size:
return source_mask[:trimmed].reshape(target_time.size, bunch_factor).any(axis=1)
if source_time.size == 1:
return np.full(target_time.size, bool(source_mask[0]), dtype=bool)
edges = np.empty(target_time.size + 1, dtype=float)
if target_time.size == 1:
edges[0] = -np.inf
edges[1] = np.inf
else:
edges[1:-1] = 0.5 * (target_time[:-1] + target_time[1:])
edges[0] = -np.inf
edges[-1] = np.inf
projected = np.zeros(target_time.size, dtype=bool)
for i in range(target_time.size):
if i == target_time.size - 1:
in_bucket = (source_time >= edges[i]) & (source_time <= edges[i + 1])
else:
in_bucket = (source_time >= edges[i]) & (source_time < edges[i + 1])
if np.any(in_bucket):
projected[i] = bool(np.any(source_mask[in_bucket]))
return projected
def _on_bunch_changed(self) -> None:
"""Re-plot and refresh fit inputs when the bunch factor changes."""
self._redraw_current_view()
self.bunch_factor_changed.emit(self._bunch_factor.value())
def _set_fit_range(
self,
x_min: float,
x_max: float,
*,
emit_signal: bool,
redraw: bool,
) -> None:
"""Set fit range with ordering and optional signaling.
Stores the range for both domains: ``get_fit_range``/``get_fit_dataset``
(and the frequency fit's dataset source, ``_active_frequency_fit_
dataset``) read ``_fit_x_min``/``_fit_x_max`` regardless of domain, and
the Fit panel's frequency-range spinbox commit path
(``_on_fit_range_edit_committed`` -> ``panel.set_fit_range``) routes
through here. Blocking the frequency case unconditionally used to make
a user-typed frequency fit range a silent no-op — the spinbox showed
the typed value but the state (and therefore the fit) never changed,
and the next render mirrored the untouched full-extent seed back into
the display.
Frequency panels now draw a draggable span too (converted to the
displayed field/relative unit): ``_draw_fit_range_artists`` draws it,
``_detect_handle_hit`` hit-tests it, and the drag path in
``_on_canvas_motion_notify`` converts the cursor back to canonical MHz
before reaching this method.
"""
lo = float(min(x_min, x_max))
hi = float(max(x_min, x_max))
self._fit_x_min = lo
self._fit_x_max = hi
# The fit range and the plot view are independent: setting the fit range
# never pans/zooms the view (so a zoomed-in view survives a fit-range
# edit). An out-of-view fit-range span is simply clipped by the axes.
if redraw:
self._draw_fit_range_artists()
self._canvas.draw_idle()
if emit_signal:
self.fit_range_changed.emit(self._fit_x_min, self._fit_x_max)
# ── spectral-moments overlay (frequency panel) ──────────────────────────
[docs]
def current_frequency_dataset(self) -> MuonDataset | None:
"""Return the active frequency-domain dataset, or ``None``."""
# ``_current_datasets`` and the overlay state only exist when matplotlib
# is available (they are set up in the canvas try-block); guard so a
# headless / no-mpl panel degrades to "no spectrum" instead of raising.
if not self._has_mpl or not self._is_frequency_plot_panel():
return None
if self._current_datasets:
return self._current_datasets[0]
return self._current_dataset
[docs]
def active_spectrum_for_moments(
self,
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None, str] | None:
"""W15 accessor: ``(x, amplitude, errors, x_unit)`` for the active spectrum.
``x`` is the canonical **absolute MHz** axis (the unit-invariant the
moments feature works in) and ``x_unit`` is ``"mhz"``; the caller converts
to its chosen field unit. Returns ``None`` when no spectrum is shown or the
active spectrum is a correlation axis (a hyperfine coupling, not a field).
"""
if not self._is_frequency_plot_panel() or self._frequency_axis_is_correlation:
return None
ds = self.current_frequency_dataset()
if ds is None:
return None
x = np.asarray(getattr(ds, "time", []), dtype=float)
y = np.asarray(getattr(ds, "asymmetry", []), dtype=float)
if x.size == 0 or x.shape != y.shape:
return None
err = getattr(ds, "error", None)
err_arr = None if err is None else np.asarray(err, dtype=float)
if err_arr is not None and err_arr.shape != x.shape:
err_arr = None
return x, y, err_arr, "mhz"
[docs]
def set_moments_overlay(
self,
*,
window_mhz: tuple[float, float] | None,
cutoff_fraction: float,
peak_amplitude: float | None,
visible: bool,
) -> None:
"""Show/update the moment window + cutoff line on the spectrum plot.
*window_mhz* is the analysis window in canonical absolute MHz; the cutoff
line is drawn at ``peak_amplitude · cutoff_fraction``. Drawn only on the
frequency panel.
"""
if not self._has_mpl or not self._is_frequency_plot_panel():
return
self._moments_window_mhz = (
None if window_mhz is None else (float(window_mhz[0]), float(window_mhz[1]))
)
self._moments_cutoff_fraction = float(cutoff_fraction)
self._moments_peak_amp = None if peak_amplitude is None else float(peak_amplitude)
self._moments_overlay_visible = bool(visible) and self._moments_window_mhz is not None
self._draw_moments_artists()
if self._has_mpl:
self._canvas.draw_idle()
[docs]
def clear_moments_overlay(self) -> None:
"""Hide the moment window/cutoff overlay."""
if not self._has_mpl:
return
self._moments_overlay_visible = False
self._clear_moments_artists()
self._canvas.draw_idle()
def _clear_moments_artists(self) -> None:
for artist in (*self._moments_span_artists, *self._moments_cutoff_artists):
try:
artist.remove()
except NotImplementedError:
continue
except Exception:
continue
self._moments_span_artists = []
self._moments_cutoff_artists = []
def _moments_window_display(self) -> tuple[float, float] | None:
"""Return the moment window converted to the current display axis."""
if self._moments_window_mhz is None:
return None
unit = self._current_frequency_x_unit
mode = self._frequency_axis_mode
lo = self._convert_canonical_mhz_to_display_limit(
self._moments_window_mhz[0], unit=unit, mode=mode
)
hi = self._convert_canonical_mhz_to_display_limit(
self._moments_window_mhz[1], unit=unit, mode=mode
)
return (min(lo, hi), max(lo, hi))
def _draw_moments_artists(self) -> None:
"""Draw the moment window span + handles and the cutoff line."""
if not self._has_mpl or not self._is_frequency_plot_panel():
return
self._clear_moments_artists()
if not self._moments_overlay_visible or self._frequency_axis_is_correlation:
return
window = self._moments_window_display()
if window is None:
return
# Reuse the established fit-range span grammar for visual consistency.
span, left_line, right_line = draw_fit_range_span(self._ax, window[0], window[1])
self._moments_span_artists.extend([span, left_line, right_line])
if self._moments_peak_amp is not None and self._moments_cutoff_fraction > 0.0:
# peak_amplitude arrives on the canonical footing (the W15 accessor
# hands the moments feature canonical values); the axis may carry
# the unit-area density Jacobian, so convert the drawn level.
level = (
self._moments_peak_amp
* self._moments_cutoff_fraction
* self._frequency_density_display_factor(self.current_frequency_dataset())
)
cutoff_line = self._ax.axhline(
level, color=right_line.get_color(), alpha=0.45, linestyle=":", linewidth=1.2
)
self._moments_cutoff_artists.append(cutoff_line)
def _detect_moments_handle_hit(self, event) -> str | None:
"""Return ``"min"``/``"max"``/``"cutoff"`` for a moment handle near the cursor."""
if (
not self._moments_overlay_visible
or self._moments_window_mhz is None
or event.inaxes is not self._ax
or event.x is None
or event.y is None
):
return None
window = self._moments_window_display()
if window is None:
return None
handle = nearest_handle(
self._ax,
[(window[0], "min"), (window[1], "max")],
event.x,
tolerance_px=8.0,
)
if handle is not None:
return handle
if self._moments_peak_amp is not None and self._moments_cutoff_fraction > 0.0:
level = self._moments_peak_amp * self._moments_cutoff_fraction
level_px = self._ax.transData.transform((0.0, level))[1]
if abs(event.y - level_px) <= 6.0:
return "cutoff"
return None
def _drag_moments_handle(self, event) -> None:
"""Apply a moment-handle drag, updating state and emitting the change."""
if self._active_moments_handle == "cutoff":
if (
event.ydata is None
or self._moments_peak_amp is None
or self._moments_peak_amp <= 0.0
):
return
fraction = float(min(max(event.ydata / self._moments_peak_amp, 0.0), 0.99))
self._moments_cutoff_fraction = fraction
self._draw_moments_artists()
self._canvas.draw_idle()
self.moments_cutoff_changed.emit(fraction)
return
if event.xdata is None or self._moments_window_mhz is None:
return
# ppm ↔ MHz inversion needs a positive reference; without one the drag
# cannot map back to canonical MHz, so ignore it rather than corrupt the
# stored window (the handles stay put until a reference is available).
if self._frequency_axis_mode == "relative_ppm" and self._active_reference_mhz() <= 0.0:
return
canonical = self._convert_display_limit_to_canonical_mhz(
float(event.xdata),
unit=self._current_frequency_x_unit,
mode=self._frequency_axis_mode,
)
lo, hi = self._moments_window_mhz
if self._active_moments_handle == "min":
lo = canonical
else:
hi = canonical
self._moments_window_mhz = (lo, hi)
self._draw_moments_artists()
self._canvas.draw_idle()
self.moments_window_changed.emit(min(lo, hi), max(lo, hi))
def _frequency_fit_range_display(self) -> tuple[float, float] | None:
"""Return ``(nu_min, nu_max)`` fit-range endpoints in the display axis.
``_fit_x_min``/``_fit_x_max`` are stored in canonical absolute MHz (the
fit spinbox and ``get_fit_dataset`` both work in MHz); the frequency
axis may be showing field or shift units, so convert each endpoint for
drawing/hit-testing exactly as the moments overlay does in
:meth:`_moments_window_display`.
"""
if self._fit_x_min is None or self._fit_x_max is None:
return None
unit = self._current_frequency_x_unit
mode = self._frequency_axis_mode
lo = self._convert_canonical_mhz_to_display_limit(self._fit_x_min, unit=unit, mode=mode)
hi = self._convert_canonical_mhz_to_display_limit(self._fit_x_max, unit=unit, mode=mode)
return lo, hi
def _draw_fit_range_artists(self) -> None:
"""Draw highlight and edge handles for the selected fit range."""
if not self._has_mpl:
return
# The frequency panel also carries the moments overlay; redraw it here so
# it survives every plot rebuild.
self._draw_moments_artists()
self._clear_fit_range_artists()
if self._fit_x_min is None or self._fit_x_max is None:
return
# Frequency panels draw a single span on the main axis, converting the
# MHz-stored range into the displayed field/relative units.
if self._is_frequency_plot_panel():
window = self._frequency_fit_range_display()
if window is None:
return
span, left_line, right_line = draw_fit_range_span(self._ax, min(window), max(window))
self._fit_span_artists.append(span)
self._fit_min_handles.append(left_line)
self._fit_max_handles.append(right_line)
return
axes = self._fit_range_axes()
if not axes:
return
for axis in axes:
span, left_line, right_line = draw_fit_range_span(
axis, self._fit_x_min, self._fit_x_max
)
self._fit_span_artists.append(span)
self._fit_min_handles.append(left_line)
self._fit_max_handles.append(right_line)
def _detect_handle_hit(self, event) -> str | None:
"""Return which fit handle (min/max) was clicked, if any.
Frequency panels now draw a draggable fit-range span too, hit-tested on
the main axis against the endpoints converted to display units. The one
exception is when the spectral-moments overlay is visible: its handles
share the same grammar and its default window coincides with the
seeded full-extent fit range, so we defer to the moments hit-test
(checked next in ``_on_canvas_button_press``) rather than steal its
clicks.
"""
if (
self._fit_x_min is None
or self._fit_x_max is None
or event.inaxes is None
or event.x is None
or event.y is None
):
return None
if self._is_frequency_plot_panel():
if self._moments_overlay_visible or event.inaxes is not self._ax:
return None
window = self._frequency_fit_range_display()
if window is None:
return None
return nearest_handle(
self._ax,
[(window[0], "min"), (window[1], "max")],
event.x,
tolerance_px=8.0,
)
hit_axis = None
for axis in self._fit_range_axes():
if event.inaxes is axis:
hit_axis = axis
break
if hit_axis is None:
return None
return nearest_handle(
hit_axis,
[(self._fit_x_min, "min"), (self._fit_x_max, "max")],
event.x,
tolerance_px=8.0,
)
def _detect_annotation_hit(self, event) -> int | None:
"""Return annotation index hit by the mouse event, if any."""
if event.inaxes != self._ax:
return None
for idx, ann in enumerate(self._annotations):
artist = ann.get("artist")
if artist is None:
continue
contains, _ = artist.contains(event)
if contains:
return idx
return None
def _add_annotation_at_event(self, event) -> None:
"""Prompt for label text and place an annotation at the click location."""
if event.inaxes != self._ax or event.xdata is None or event.ydata is None:
return
text, ok = QInputDialog.getText(self, "Add Label", "Label text:")
if not ok or not text.strip():
return
annotation = {
"x": float(event.xdata),
"y": float(event.ydata),
"text": text.strip(),
"artist": None,
}
self._annotations.append(annotation)
self._add_label_btn.setChecked(False)
self._redraw_current_view()
def _edit_annotation(self, idx: int) -> None:
"""Edit an existing annotation label."""
current = self._annotations[idx]["text"]
text, ok = QInputDialog.getText(self, "Edit Label", "Label text:", text=current)
if not ok or not text.strip():
return
self._annotations[idx]["text"] = text.strip()
self._redraw_current_view()
def _delete_annotation(self, idx: int) -> None:
"""Delete an annotation by index."""
self._annotations.pop(idx)
self._redraw_current_view()
def _on_canvas_button_press(self, event) -> None:
"""Capture left-clicks on fit-range handles for drag/edit."""
if not self._has_mpl:
return
if self._current_navigation_mode() != "none":
return
if event.button == 3:
ann_idx = self._detect_annotation_hit(event)
if ann_idx is not None:
self._delete_annotation(ann_idx)
return
if event.button != 1:
return
if self._add_label_btn.isChecked():
self._add_annotation_at_event(event)
return
handle = self._detect_handle_hit(event)
if handle is not None:
self._active_fit_handle = handle
self._active_fit_axis = event.inaxes
self._drag_started = False
return
moments_handle = self._detect_moments_handle_hit(event)
if moments_handle is not None:
self._active_moments_handle = moments_handle
self._drag_started = False
return
ann_idx = self._detect_annotation_hit(event)
if ann_idx is not None:
self._active_annotation_idx = ann_idx
self._annotation_drag_started = False
elif self._subplot_axes_by_polarization:
# A plain click inside a stacked subplot makes it the fit target.
projection = self._subplot_projection_at_event(event)
if projection is not None:
self.set_fit_target_projection(projection)
def _on_canvas_motion_notify(self, event) -> None:
"""Drag the active fit-range handle while the mouse moves."""
if self._current_navigation_mode() != "none":
return
if self._active_fit_handle is not None and event.xdata is not None:
if not any(event.inaxes is axis for axis in self._fit_range_axes()):
return
self._drag_started = True
new_value = float(event.xdata)
# The frequency range is stored in canonical MHz, but the cursor is
# in the displayed field/shift unit — convert back before storing.
if self._is_frequency_plot_panel():
if (
self._frequency_axis_mode == "relative_ppm"
and self._active_reference_mhz() <= 0.0
):
return # No reference to invert ppm → MHz; ignore the drag.
new_value = self._convert_display_limit_to_canonical_mhz(
new_value,
unit=self._current_frequency_x_unit,
mode=self._frequency_axis_mode,
)
if self._active_fit_handle == "min":
self._set_fit_range(new_value, self._fit_x_max, emit_signal=True, redraw=True)
else:
self._set_fit_range(self._fit_x_min, new_value, emit_signal=True, redraw=True)
if self._active_moments_handle is not None and event.inaxes is self._ax:
self._drag_started = True
self._drag_moments_handle(event)
if (
self._active_annotation_idx is not None
and event.inaxes == self._ax
and event.xdata is not None
and event.ydata is not None
):
self._annotation_drag_started = True
ann = self._annotations[self._active_annotation_idx]
ann["x"] = float(event.xdata)
ann["y"] = float(event.ydata)
artist = ann.get("artist")
if artist is not None:
artist.set_position((ann["x"], ann["y"]))
self._canvas.draw_idle()
# Emit cursor readout for the status bar (no-op during drags).
if (
self._active_fit_handle is None
and self._active_annotation_idx is None
and self._active_moments_handle is None
and event.inaxes == self._ax
and event.xdata is not None
and event.ydata is not None
):
self.cursor_coords_changed.emit(
self._build_cursor_readout(float(event.xdata), float(event.ydata))
)
else:
self.cursor_coords_changed.emit(None)
def _cursor_snap_arrays(self):
"""Cached (t, y, err) for snapping, or None on multi-curve views.
Snapping is only well-defined when the cached arrays correspond to the
main axis. On stacked grouped/vector subplots the cache holds the last
subplot's data, so we decline to snap and report the raw cursor instead.
"""
if getattr(self, "_grouped_time_subplot_datasets", None) or getattr(
self, "_vector_subplot_datasets", None
):
return None
t = self._last_plot_time
y = self._last_plot_asymmetry
if t is None or y is None or len(t) == 0:
return None
return t, y, self._last_plot_error
def _build_cursor_readout(self, xdata: float, ydata: float) -> dict:
"""Build the status-bar cursor payload, snapping to the nearest point.
Adds the spectrum-reading readouts (S/N, a 3-point parabolic peak, and
the windowed average over the visible x-range) when a single curve is in
view; falls back to the raw coordinate otherwise.
"""
arrays = self._cursor_snap_arrays()
if arrays is None:
return {"x": xdata, "y": ydata, "snapped": False}
t, y, e = arrays
t = np.asarray(t, dtype=float)
y = np.asarray(y, dtype=float)
idx = int(np.argmin(np.abs(t - xdata)))
tx = float(t[idx])
ty = float(y[idx])
err = None
if e is not None and idx < len(e):
try:
err = float(e[idx])
except (TypeError, ValueError):
err = None
snr = None
if err is not None and np.isfinite(err) and err != 0.0:
snr = abs(ty / err)
peak = None
if 1 <= idx < len(t) - 1:
peak = parabolic_peak(t[idx - 1 : idx + 2], y[idx - 1 : idx + 2])
window = None
if not self._is_frequency_plot_panel():
lo = float(self._x_min.value())
hi = float(self._x_max.value())
err_arr = np.asarray(e, dtype=float) if e is not None else np.zeros_like(y)
try:
mean, mean_err = integrate_curve(
t, y, err_arr, t_min=min(lo, hi), t_max=max(lo, hi)
)
n = int(np.count_nonzero((t >= min(lo, hi)) & (t <= max(lo, hi))))
window = (float(mean), float(mean_err), n)
except (ValueError, ZeroDivisionError):
window = None
return {
"x": tx,
"y": ty,
"err": err,
"snr": snr,
"peak": peak,
"window": window,
"snapped": True,
}
def _on_canvas_button_release(self, event) -> None:
"""End drag and open numeric editor on click without drag."""
if self._current_navigation_mode() != "none":
# A completed pan/zoom gesture is the user choosing a window, exactly
# like typing in a limit field: take control of the frame so later
# content switches and recomputes hold it instead of reframing over
# it. Only interactive gestures reach here — programmatic set_xlim
# routes through the xlim_changed callback (_on_axis_limits_changed),
# never button_release — so this never fires on a draw-driven change.
self._limits_user_locked = True
return
if self._active_fit_handle is not None:
handle = self._active_fit_handle
was_drag = self._drag_started
self._active_fit_handle = None
self._active_fit_axis = None
self._drag_started = False
if not was_drag and event.button == 1:
self._prompt_handle_value_edit(handle)
if self._active_moments_handle is not None:
self._active_moments_handle = None
was_moments_drag = self._drag_started
self._drag_started = False
if was_moments_drag:
# Mirrors the fit-handle branch above: only an actual move (not a
# stationary click) warrants the expensive full-bootstrap recompute.
self.moments_drag_finished.emit()
return
if self._active_annotation_idx is None:
return
ann_idx = self._active_annotation_idx
was_ann_drag = self._annotation_drag_started
self._active_annotation_idx = None
self._annotation_drag_started = False
if not was_ann_drag and event.button == 1 and getattr(event, "dblclick", False):
self._edit_annotation(ann_idx)
def _prompt_handle_value_edit(self, handle: str) -> None:
"""Prompt for an exact fit-handle x-value (time-domain click-to-edit).
Frequency panels deliberately skip this: exact entry there is the Fit
dock's MHz range spinboxes, and opening a modal ``QInputDialog`` from a
stationary click on a (possibly coincident) frequency handle would block
the event loop — a hang the per-test thread timeout cannot interrupt.
The frequency span stays fully draggable; only the click-to-type editor
is time-domain-only.
"""
if self._is_frequency_plot_panel():
return
if self._fit_x_min is None or self._fit_x_max is None:
return
current = self._fit_x_min if handle == "min" else self._fit_x_max
prompt = "Fit x-value (µs):"
value, ok = QInputDialog.getDouble(
self,
"Set Fit Range",
prompt,
float(current),
-1e6,
1e6,
6,
)
if not ok:
return
if handle == "min":
self._set_fit_range(value, self._fit_x_max, emit_signal=True, redraw=True)
else:
self._set_fit_range(self._fit_x_min, value, emit_signal=True, redraw=True)
[docs]
def plot_fit(
self,
t_fit,
y_fit,
label: str = "Fit",
component_curves: list[tuple[str, object]] | None = None,
fit_result: object | None = None,
fit_function: str | None = None,
run_number: int | None = None,
) -> None:
"""Overlay a fit curve on the current plot.
The fit curve will be retained even when bunching or limits change.
Parameters
----------
t_fit : array
Time points for the fit curve.
y_fit : array
Fitted asymmetry values.
label : str, optional
Label for the fit curve in the legend.
fit_result : object, optional
FitResult containing chi_squared, parameters, uncertainties, etc.
run_number : int, optional
Run the fit was computed for. In a multi-run overlay stacked view
``_current_dataset`` points at the first projection's *last* run, not
the fitted (selected) run, so the caller passes the fitted run
explicitly to keep the overlay key and the persisted single-fit slot
on the same run. Defaults to inferring it from ``_current_dataset``.
"""
if not self._has_mpl:
return
# Store fit curve data for persistence across redraws (single fit)
self._fit_curve = (t_fit, y_fit, label)
explicit_run = run_number
run_number = None
if self._current_dataset is not None:
try:
run_number = int(self._current_dataset.run_number)
except (TypeError, ValueError):
run_number = None
# The fitted run is authoritative when supplied: source it from the one
# place that knows it (the caller's selected dataset) so the curve is
# keyed under the same run the slot is recorded against.
if explicit_run is not None:
try:
run_number = int(explicit_run)
except (TypeError, ValueError):
pass
# Derive the axis from the dataset that actually matches the fitted run,
# not whichever one _current_dataset points at — in a multi-run overlay
# that is the last overlaid run, possibly a different projection — so the
# (run, axis) key is always self-consistent.
axis_source = self._current_dataset
if run_number is not None:
for dataset in self._current_datasets or ():
try:
if int(dataset.run_number) == run_number:
axis_source = dataset
break
except (TypeError, ValueError):
continue
axis_key = self._axis_key_for_dataset(axis_source) if axis_source is not None else None
# In the stacked view the fit belongs to the SELECTED subplot, not the
# first projection that _current_dataset happens to point at — otherwise
# every fit would draw on the first subplot regardless of the target.
if self._subplot_axes_by_polarization:
target = self.fit_target_projection()
if target is not None:
axis_key = target
self._fit_curve_run_number = run_number
if run_number is not None:
self._fit_curves[run_number] = (t_fit, y_fit, label)
self._fit_curves_by_key[(run_number, axis_key)] = (t_fit, y_fit, label)
self._fit_components_by_run[run_number] = list(component_curves or [])
self._fit_components_by_key[(run_number, axis_key)] = list(component_curves or [])
if fit_result is not None or fit_function:
self._store_fit_metadata(
run_number,
fit_result,
fit_function=fit_function,
axis_key=axis_key,
)
self._fit_components = list(component_curves or [])
self._update_export_enabled()
if self._subplot_axes_by_polarization and self._vector_subplot_datasets:
# Stacked multi-subplot view: re-render the subplots so the fit
# overlays the target projection's subplot (drawn per-axis from
# _fit_curves_by_key) without dropping the other projections.
self.plot_vector_subplots(self._vector_subplot_datasets)
elif self._current_dataset is not None:
self.plot_dataset(self._current_dataset)
else:
self._ax.plot(t_fit, y_fit, "-", color=tokens.PLOT_FIT, linewidth=2, label=label)
style_legend(self._ax.legend())
self._canvas.draw()
[docs]
def set_global_fits(self, fit_curves_dict: dict) -> None:
"""Set fit curves from global fitting.
Parameters
----------
fit_curves_dict : dict
Dictionary mapping run_number -> (t_fit, y_fit, label, component_curves),
(t_fit, y_fit, label, component_curves, fit_result), or
(t_fit, y_fit, label, component_curves, fit_result, fit_function), or
(t_fit, y_fit, label, component_curves, fit_result, fit_function, axis_key).
"""
if not self._has_mpl:
return
# Update fit curves, preserving results from other groups
for run_number, payload in fit_curves_dict.items():
axis_key = None
if len(payload) >= 6:
t_fit, y_fit, label, component_curves, fit_result, fit_function = payload[:6]
if len(payload) >= 7:
axis_key = self._axis_canonical_key(payload[6])
if axis_key == "ALL":
axis_key = None
elif len(payload) >= 5:
t_fit, y_fit, label, component_curves, fit_result = payload[:5]
fit_function = None
elif len(payload) == 4:
t_fit, y_fit, label, component_curves = payload
fit_result = None
fit_function = None
else:
t_fit, y_fit, label = payload
component_curves = []
fit_result = None
fit_function = None
try:
run_key = int(run_number)
except (TypeError, ValueError):
continue
self._fit_curves[run_key] = (t_fit, y_fit, label)
self._fit_curves_by_key[(run_key, axis_key)] = (t_fit, y_fit, label)
self._fit_components_by_run[run_key] = list(component_curves or [])
self._fit_components_by_key[(run_key, axis_key)] = list(component_curves or [])
if fit_result is not None or fit_function:
self._store_fit_metadata(
run_key,
fit_result,
fit_function=fit_function,
axis_key=axis_key,
)
# Clear single fit curve
self._fit_curve = None
self._fit_curve_run_number = None
self._fit_components = None
self._update_export_enabled()
# Redraw current view while preserving multi-selection overlays.
self._redraw_current_view()
[docs]
def clear(self, *, message: str | None = None, preserve_view_state: bool = False) -> None:
"""Clear the plot and reset stored data.
When *message* is given, draw it as a centred grey placeholder over the
(axis-off) plot area — the empty-state pattern used by
:meth:`asymmetry.gui.panels.alc_panel.AlcPanel.clear`. The frequency
panel passes a message to prompt the user to compute a spectrum (an FFT
is computed on demand, never automatically); every other caller leaves
*message* ``None`` and gets an unchanged blank plot.
*preserve_view_state* keeps the frame latches (``_limits_initialized``,
``_limits_user_locked``, ``_framed_identity``) rather than resetting
them. It is for *transient* empty states — browsing past an uncomputed
run, or an empty-spectrum render — where the blank is momentary and the
user's chosen window must survive to the next compute. Genuine teardown
(project close/new, dataset removal) leaves it ``False`` so a fresh view
reframes from scratch. It does not touch the spin *fields*, which clear()
never reset; callers re-apply the preserved limits explicitly.
"""
# A cleared panel shows no run, so the transverse-field grouping nudge
# (which is per-run) must go with it; the next render re-derives it.
self.set_grouping_hint(None)
if self._has_mpl:
self._set_canvas_minimum_height_for_axes(1)
self._set_navigation_mode("none")
self._set_alpha_label(None)
self.set_projections([])
self._ax.clear()
style_axes(self._ax)
if message:
draw_empty_state_message(self._ax, message)
self._canvas.draw()
self._current_dataset = None
self._current_datasets = []
self._update_plot_header()
self._fit_curve = None
self._fit_curve_run_number = None
self._fit_curves = {}
self._fit_curves_by_key = {}
self._fit_components = None
self._fit_components_by_run = {}
self._fit_components_by_key = {}
self._fit_metadata = {}
self._fit_metadata_by_key = {}
if not preserve_view_state:
self._limits_initialized = False
self._limits_user_locked = False
self._framed_identity = None
self._last_plot_time = None
self._last_plot_asymmetry = None
self._last_plot_error = None
self._last_low_count_mask = None
self._clear_waterfall_render_record()
self._default_annotations = []
self._annotations_by_group = {}
self._annotations = self._default_annotations
self._active_annotation_idx = None
self._annotation_drag_started = False
self._custom_x_axis_label = None
self._persistent_frequency_markers = []
self._fit_x_min = None
self._fit_x_max = None
self._fit_span_artists = []
self._fit_min_handles = []
self._fit_max_handles = []
self._active_fit_axis = None
self._current_polarization_axis = None
self._y_limits_by_polarization = {}
self._subplot_axes_by_polarization = {}
self._vector_subplot_datasets = {}
if self._is_frequency_plot_panel():
self._frequency_reference_mhz = None
self._apply_axis_labels(*self._default_axis_labels())
self._update_export_enabled()
[docs]
def resizeEvent(self, event) -> None:
"""Keep the canvas width aligned with the viewport during grouped scrolling."""
super().resizeEvent(event)
if not getattr(self, "_has_mpl", False):
return
# Use _stacked_axis_count rather than len(_subplot_axes_by_polarization):
# the dict is briefly empty while plot_vector_subplots/plot_grouped_time_domain_subplots
# rebuild axes, and a resize event in that window would otherwise reset the
# scroll area to single-axis mode, which is the root cause of the scrollbar
# disappearing on first render in the frozen macOS app.
axis_count = max(1, getattr(self, "_stacked_axis_count", 1))
target_height = max(self._default_canvas_min_height, int(self._canvas.minimumHeight()))
self._sync_canvas_scroll_geometry(axis_count=axis_count, target_height=target_height)
self._canvas.draw_idle()
[docs]
def clear_fit(self) -> None:
"""Clear all fit curves and redraw the plot."""
if not self._has_mpl:
return
self._fit_curve = None
self._fit_curve_run_number = None
self._fit_curves = {}
self._fit_curves_by_key = {}
self._fit_components = None
self._fit_components_by_run = {}
self._fit_components_by_key = {}
self._fit_metadata = {}
self._fit_metadata_by_key = {}
self._update_export_enabled()
self._redraw_current_view()
[docs]
def clear_fits_for_runs(self, run_numbers: list[int]) -> int:
"""Clear stored fit overlays for the provided run numbers."""
if not self._has_mpl:
return 0
normalized_runs: set[int] = set()
for run_number in run_numbers:
try:
normalized_runs.add(int(run_number))
except (TypeError, ValueError):
continue
if not normalized_runs:
return 0
removed = 0
for run_number in normalized_runs:
if self._fit_curves.pop(run_number, None) is not None:
removed += 1
self._fit_components_by_run.pop(run_number, None)
self._fit_metadata.pop(run_number, None)
removed_curve_keys = [key for key in self._fit_curves_by_key if key[0] in normalized_runs]
removed_component_keys = [
key for key in self._fit_components_by_key if key[0] in normalized_runs
]
removed_metadata_keys = [
key for key in self._fit_metadata_by_key if key[0] in normalized_runs
]
for key in removed_curve_keys:
self._fit_curves_by_key.pop(key, None)
for key in removed_component_keys:
self._fit_components_by_key.pop(key, None)
for key in removed_metadata_keys:
self._fit_metadata_by_key.pop(key, None)
if self._fit_curve_run_number in normalized_runs:
self._fit_curve = None
self._fit_curve_run_number = None
self._fit_components = None
removed += 1
if removed > 0:
self._update_export_enabled()
self._redraw_current_view()
return removed
def _store_fit_metadata(
self,
run_number: int,
fit_result: object | None,
fit_function: str | None = None,
axis_key: str | None = None,
) -> None:
"""Extract and store fit metadata from a FitResult for export headers."""
meta: dict = {}
if fit_result is not None:
chi2 = getattr(fit_result, "chi_squared", None)
red_chi2 = getattr(fit_result, "reduced_chi_squared", None)
if chi2 is not None:
meta["chi_squared"] = float(chi2)
if red_chi2 is not None:
meta["reduced_chi_squared"] = float(red_chi2)
params = getattr(fit_result, "parameters", None)
uncertainties = getattr(fit_result, "uncertainties", {})
if params is not None:
meta["parameters"] = [
{
"name": p.name,
"value": float(p.value),
"error": float(uncertainties.get(p.name, float("nan"))),
}
for p in params
]
fit_function_value = fit_function
if not fit_function_value:
fit_function_value = getattr(fit_result, "fit_function", None)
if fit_function_value:
meta["fit_function"] = str(fit_function_value)
self._fit_metadata[int(run_number)] = meta
self._fit_metadata_by_key[(int(run_number), axis_key)] = meta
def _update_export_enabled(self) -> None:
"""Enable export controls when there is plotted data to export."""
has_data = bool(self._current_datasets)
self._export_gle_btn.setEnabled(has_data)
self._gle_format_combo.setEnabled(has_data)
@staticmethod
def _safe_file_token(value: str) -> str:
"""Sanitize a string for use in a filename (shared foundation)."""
return safe_file_token(value, fallback="dataset")
@staticmethod
def _dedup_export_token(base: str, used: set[str] | None) -> str:
"""Return a token unique within *used* (shared foundation)."""
return dedup_export_token(base, used)
@staticmethod
def _strip_matplotlib_math(value: object) -> str:
"""Strip matplotlib math markup so an axis label renders as plain text.
Frequency y-labels can carry matplotlib mathtext (e.g. the
``$|F|$ (arb.)`` fallback). GLE has no use for the ``$…$`` delimiters,
``\\mathrm``-style commands, or their braces, so they are removed here —
locally, on the frequency labels only — rather than by widening the
shared :meth:`_sanitize_gle_text` (which the byte-identical time-domain
export also flows through).
"""
text = str(value)
text = text.replace("$", "")
text = re.sub(r"\\(?:mathrm|mathit|mathsf|mathbf|text|rm|it|bf)\b", "", text)
text = text.replace("{", "").replace("}", "")
return text
@staticmethod
def _sanitize_gle_text(value: object, *, fallback: str = "") -> str:
"""Return text that is safe for GLE string rendering."""
text = str(value)
text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
text = text.replace("\r", " ").replace("\n", " ")
text = text.replace("μ", "u").replace("µ", "u")
text = text.replace("χ", "chi").replace("²", "^2")
text = "".join(ch for ch in text if ch.isprintable())
text = " ".join(text.split())
text = text.encode("ascii", "ignore").decode("ascii")
return text or fallback
[docs]
def get_current_plot_export_data(
self, datasets: list[MuonDataset] | None = None
) -> list[dict] | None:
"""Return export payloads for all displayed datasets.
Returns a list of dicts (one per displayed dataset), or *None* when
nothing is available to export.
"""
target_datasets = list(datasets) if datasets is not None else list(self._current_datasets)
if not target_datasets:
return None
payloads: list[dict] = []
for dataset in target_datasets:
analysis = rrf_display_dataset(self, self.get_analysis_dataset(dataset))
if analysis is None:
continue
grouping = None
run = getattr(dataset, "run", None)
if run is not None and isinstance(getattr(run, "grouping", None), dict):
grouping = dict(run.grouping)
elif isinstance(dataset.metadata.get("grouping"), dict):
grouping = dict(dataset.metadata["grouping"])
run_metadata: dict[str, object] = {}
if run is not None and isinstance(getattr(run, "metadata", None), dict):
run_metadata.update(run.metadata)
if isinstance(dataset.metadata, dict):
run_metadata.update(dataset.metadata)
if isinstance(analysis.metadata, dict) and analysis.metadata.get("rrf_frame"):
run_metadata["rrf_frame"] = analysis.metadata["rrf_frame"]
histogram_info: dict[str, object] | None = None
if run is not None and getattr(run, "histograms", None):
histograms = list(run.histograms)
if histograms:
h0 = histograms[0]
total_events = float(np.sum([np.sum(h.counts) for h in histograms]))
histogram_info = {
"count": len(histograms),
"n_bins": int(h0.n_bins),
"bin_width_us": float(h0.bin_width),
"t0_bins": [int(h.t0_bin) for h in histograms],
"events_total": total_events,
}
grouped_total = good_event_count(histograms, grouping)
if grouped_total is not None and grouped_total > 0:
histogram_info["events_grouped"] = grouped_total
rn = dataset.run_number
fit_data = self._fit_curve_for_dataset(dataset)
fit_data = rrf_display_fit_curve(self, fit_data, analysis)
t_fit = None
y_fit = None
fit_label = "Fit"
if fit_data is not None:
t_fit, y_fit, fit_label = fit_data
component_data = self._fit_components_for_dataset(dataset)
if t_fit is not None and component_data:
wrapped_components = []
for name, y_vals in component_data:
shown = rrf_display_fit_curve(self, (t_fit, y_vals, name), analysis)
wrapped_components.append((name, shown[1] if shown is not None else y_vals))
component_data = wrapped_components
label_text = self._dataset_label_for(dataset)
is_frequency = self._is_frequency_plot_panel() or self._is_frequency_domain_dataset(
dataset
)
fit_payload = (
{"t": t_fit, "y": y_fit, "label": fit_label}
if t_fit is not None and y_fit is not None
else None
)
payload = {
"run_number": rn,
"label": label_text,
"domain": "frequency" if is_frequency else "time",
"data": {
"t": analysis.time,
"y": analysis.asymmetry,
"err": analysis.error,
},
"fit": fit_payload,
"components": [{"name": name, "y": y_vals} for name, y_vals in component_data],
"fit_metadata": self._fit_metadata_for_dataset(dataset),
"grouping": grouping,
"run_metadata": run_metadata,
"histogram_info": histogram_info,
}
# Frequency payloads mirror the on-screen spectrum: display-unit x
# data and window, real axis labels, and the Fourier provenance the
# sidecar header records. Read x_display, x_unit, and the
# correlation flag from the same panel state the on-screen render
# uses so the recorded unit can never disagree with the plotted x.
if is_frequency:
x_label, y_label = self._axis_labels_for_dataset(dataset, None)
# Per-dataset x transform: a shift-mode overlay exports each trace
# shifted about its OWN reference, mirroring the on-screen render.
payload["x_display"] = self._convert_frequency_axis_for_display(
analysis.time, dataset
)
payload["x_label"] = x_label
payload["y_label"] = y_label
payload["x_unit"] = self._current_frequency_x_unit
payload["axis_mode"] = self._frequency_axis_mode
# A unit-area density exports on its displayed footing: y values,
# errors, and fit/component curves all carry the same dν/dB
# Jacobian the on-screen render applies, and the sidecar names
# the column per the displayed unit (density_per_G etc.).
if self._is_unit_area_dataset(dataset):
payload["data"] = {
"t": analysis.time,
"y": self._convert_frequency_values_for_display(
analysis.asymmetry, dataset
),
"err": self._convert_frequency_values_for_display(analysis.error, dataset),
}
if fit_payload is not None:
fit_payload["y"] = self._convert_frequency_values_for_display(
fit_payload["y"], dataset
)
payload["components"] = [
{
"name": component["name"],
"y": self._convert_frequency_values_for_display(
component["y"], dataset
),
}
for component in payload["components"]
]
effective_unit = (
"mhz"
if self._frequency_axis_is_correlation
else _FREQUENCY_X_UNIT_FIELD.get(self._current_frequency_x_unit, "mhz")
)
payload["density_unit"] = effective_unit
payload["is_correlation"] = bool(self._frequency_axis_is_correlation)
reference_gauss = reference_field_gauss(run, dataset)
if reference_gauss is not None:
payload["reference_field_gauss"] = float(reference_gauss)
payload["fourier_metadata"] = {
key: dataset.metadata[key]
for key in sorted(dataset.metadata)
if key.startswith("fourier_")
and isinstance(dataset.metadata[key], (str, int, float, bool))
}
if fit_payload is not None:
fit_payload["x_display"] = self._convert_frequency_axis_for_display(
t_fit, dataset
)
# Mirror the waterfall stack the current render actually drew: the
# offset comes from the per-dataset draw record, never from the
# checkbox, so a non-stacked view (single run, grouped subplots,
# MaxEnt, vector "ALL") exports plain values even while the
# Waterfall box is checked.
if self._waterfall_stacked:
drawn_offset = self._waterfall_drawn_offsets.get(id(dataset))
if drawn_offset is not None:
payload["waterfall_offset"] = float(drawn_offset)
payloads.append(payload)
if not payloads:
return None
# Append annotations (shared across all datasets in this view)
annotations = [
{"x": ann["x"], "y": ann["y"], "text": ann["text"]} for ann in self._annotations
]
for p in payloads:
p["annotations"] = annotations
return payloads
def _export_axis_suffix(self, axis_key: str | None) -> str:
"""Return filename-safe suffix for per-axis exports."""
if axis_key in {"P_x", "P_y", "P_z"}:
return axis_key.lower().replace("_", "")
return "main"
def _export_axis_ylabel(self, axis_key: str | None) -> str:
"""Return plain-text y-axis label for plot exports."""
if axis_key in {"P_x", "P_y", "P_z"}:
suffix = axis_key.split("_", 1)[1]
return f"a_0 P_{{{suffix}}}(t) (%)"
return "Asymmetry (%)"
def _plot_export_payloads_on_axis(
self,
ax,
payloads: list[dict],
*,
axis_key: str | None,
written_files: list[Path],
dat_writes: list[tuple[Path, dict, object, str | None]],
gle_path: Path,
colors: list[str],
show_legend: bool,
used_tokens: set[str] | None = None,
) -> None:
"""Draw export payloads on a provided axis with axis-specific naming."""
is_multi = len(payloads) > 1
axis_suffix = self._export_axis_suffix(axis_key)
# gleplot ≥ 1.7 stacks a waterfall offset in the GLE script (``offset=``
# -> ``let``), keeping the written .dat/.fit values raw. Older gleplot
# has no such knob, so fall back to baking the offset into the arrays
# (and the sidecar writers) so the plot still matches the data.
offset_in_gle = gle_offset_supported()
for i, payload in enumerate(payloads):
label_text = payload.get("label", f"dataset_{i}")
safe_label = self._sanitize_gle_text(
label_text,
fallback=f"Run {payload.get('run_number', i)}",
)
# Digit-led sidecar filenames (a bare run number like ``20`` →
# ``20_main.dat``) break the gleplot editor's parser when referenced
# unquoted; prefix the GLE sidecar token with ``run_`` before dedup.
raw_token = self._safe_file_token(f"{label_text}_{axis_suffix}")
if raw_token[:1].isdigit():
raw_token = f"run_{raw_token}"
token = self._dedup_export_token(raw_token, used_tokens)
data_color = colors[i % len(colors)] if is_multi else "black"
fit_color = data_color if is_multi else "red"
suppress_subplot_labels = axis_key in {"P_x", "P_y", "P_z"} and not show_legend
data_label = None if suppress_subplot_labels else safe_label
data = payload.get("data") or {}
fit = payload.get("fit") or {}
t_data = data.get("t")
y_data = data.get("y")
y_err = data.get("err")
t_fit = fit.get("t")
y_fit = fit.get("y")
# Mirror the on-screen waterfall stack. With modern gleplot the
# offset is handed to the plot calls (``gle_offset`` below) and
# applied in the GLE script; against older gleplot we bake it into
# the arrays here. 0.0 (absent) leaves y as-is either way.
offset = float(payload.get("waterfall_offset", 0.0))
gle_offset = offset if offset_in_gle else 0.0
if offset and not offset_in_gle and y_data is not None:
y_data = np.asarray(y_data, dtype=float) + offset
if offset and not offset_in_gle and y_fit is not None:
y_fit = np.asarray(y_fit, dtype=float) + offset
dat_path = gle_path.parent / f"{token}.dat"
if t_data is not None and y_data is not None:
dat_writes.append((dat_path, payload, label_text, axis_key))
written_files.append(dat_path)
if payload.get("domain") == "frequency":
# Mirror the on-screen renderer (_plot_frequency_line_masked):
# a solid line in display-unit x, plus a shaded ±1σ band
# when the error array carries any positive finite value.
x_display = payload.get("x_display")
x_plot = (
np.asarray(x_display, dtype=float)
if x_display is not None
else np.asarray(t_data, dtype=float)
)
ax.plot(
x_plot,
y_data,
"-",
color=data_color,
linewidth=1.2,
label=data_label,
offset=gle_offset,
data_name=token,
)
if y_err is not None:
err = np.asarray(y_err, dtype=float)
if bool(np.any(np.isfinite(err) & (err > 0.0))):
band_err = np.where(np.isfinite(err), err, 0.0)
y_arr = np.asarray(y_data, dtype=float)
band_color = _GLE_BAND_TINT.get(str(data_color).lower(), "lightgray")
ax.fill_between(
x_plot,
y_arr - band_err,
y_arr + band_err,
color=band_color,
alpha=0.25,
label=None,
offset=gle_offset,
data_name=f"{token}_band",
)
else:
ax.errorbar(
t_data,
y_data,
yerr=y_err,
fmt="none",
marker="o",
color=data_color,
markersize=4,
capsize=2,
label=data_label,
offset=gle_offset,
data_name=token,
)
fit_path = gle_path.parent / f"{token}.fit"
if t_fit is not None and y_fit is not None:
self._write_fit_file(fit_path, payload, bake_waterfall_offset=not offset_in_gle)
written_files.append(fit_path)
fit_label = fit.get("label", "Fit")
if is_multi or suppress_subplot_labels:
fit_label = None
else:
fit_label = self._sanitize_gle_text(fit_label, fallback="Fit")
if payload.get("domain") == "frequency" and fit.get("x_display") is not None:
x_fit = np.asarray(fit.get("x_display"), dtype=float)
else:
x_fit = t_fit
ax.plot(
x_fit,
y_fit,
color=fit_color,
linewidth=1.6,
label=fit_label,
offset=gle_offset,
data_name=f"{token}_fit",
)
annotations = payloads[0].get("annotations") or []
for ann in annotations:
try:
x = float(ann.get("x", 0.0))
y = float(ann.get("y", 0.0))
except (TypeError, ValueError):
continue
text = str(ann.get("text", "")).strip()
text = self._sanitize_gle_text(text)
if text:
ax.text(x, y, text, color="black", ha="left")
frequency_payload = next((p for p in payloads if p.get("domain") == "frequency"), None)
if frequency_payload is not None:
ax.set_ylabel(
self._sanitize_gle_text(
self._strip_matplotlib_math(frequency_payload.get("y_label")),
fallback="FFT (%)",
)
)
else:
ax.set_ylabel(self._export_axis_ylabel(axis_key))
if hasattr(ax, "set_xlim"):
x_lo = float(self._x_min.value())
x_hi = float(self._x_max.value())
if self._is_frequency_plot_panel():
x_lo = self._convert_frequency_control_value_to_axis_limit(x_lo)
x_hi = self._convert_frequency_control_value_to_axis_limit(x_hi)
ax.set_xlim(x_lo, x_hi)
if axis_key in self._y_limits_by_polarization and hasattr(ax, "set_ylim"):
y0, y1 = self._y_limits_by_polarization[axis_key]
ax.set_ylim(float(y0), float(y1))
elif hasattr(ax, "set_ylim"):
ax.set_ylim(float(self._y_min.value()), float(self._y_max.value()))
if show_legend:
style_legend(ax.legend(loc="best"))
def _write_fit_file(
self,
fit_path: Path,
payload: dict,
*,
x_range: tuple[float, float] | None = None,
bake_waterfall_offset: bool = True,
) -> None:
"""Write a .fit file with fit-curve data and metadata header.
``x_range`` optionally restricts the written rows to ``[lo, hi]``;
``None`` (the GLE-export default) writes the whole curve.
``bake_waterfall_offset`` adds the payload's waterfall offset to the
written curve. The plain-text export leaves it ``True`` (the text is the
final artifact, so it must carry the stack). The GLE export passes
``False`` when the installed gleplot applies the offset in the script
(``let``) instead, keeping the sidecar values raw. The header records
the offset either way.
"""
fit = payload.get("fit") or {}
t_fit = fit.get("t")
y_fit = fit.get("y")
if t_fit is None or y_fit is None:
return
# Match the drawn fit curve's waterfall offset (see _write_data_file).
waterfall_offset = float(payload.get("waterfall_offset", 0.0))
if waterfall_offset and bake_waterfall_offset:
y_fit = np.asarray(y_fit, dtype=float) + waterfall_offset
meta = payload.get("fit_metadata") or {}
with open(fit_path, "w", encoding="utf-8") as f:
f.write(f"! Fit curve for {payload.get('label', 'dataset')}\n")
f.write(f"! run_number: {payload.get('run_number', '')}\n")
if waterfall_offset:
f.write(f"! waterfall offset: {waterfall_offset:.10g}\n")
fit_function = meta.get("fit_function") or fit.get("label") or "Fit"
f.write(f"! fit_function: {fit_function}\n")
chi2 = meta.get("chi_squared")
red_chi2 = meta.get("reduced_chi_squared")
if chi2 is not None:
f.write(f"! chi_squared: {chi2:.8g}\n")
if red_chi2 is not None:
f.write(f"! reduced_chi_squared: {red_chi2:.8g}\n")
params = meta.get("parameters")
if params:
f.write("! fitted_parameters:\n")
for p in params:
err = p.get("error", float("nan"))
if np.isfinite(err):
f.write(f"! {p['name']} = {p['value']:.8g} +/- {err:.4g}\n")
else:
f.write(f"! {p['name']} = {p['value']:.8g}\n")
f.write("!\n")
if payload.get("domain") == "frequency":
x_unit = str(payload.get("x_unit", "frequency_mhz"))
x_column = self._frequency_x_column_name(
x_unit,
str(payload.get("axis_mode", "absolute")),
bool(payload.get("is_correlation")),
)
x_display = fit.get("x_display")
x_fit = (
np.asarray(x_display, dtype=float)
if x_display is not None
else self._convert_frequency_axis_for_display(t_fit)
)
density_unit = payload.get("density_unit")
y_fit_column = (
f"{_DENSITY_COLUMN_NAME.get(str(density_unit), 'density_per_MHz')}_fit"
if density_unit is not None
else "amplitude_fit"
)
f.write(f"! {x_column} {y_fit_column}\n")
else:
x_fit = t_fit
f.write("! time asymmetry_fit\n")
lo, hi = (None, None) if x_range is None else (min(x_range), max(x_range))
for t_val, y_val in zip(x_fit, y_fit):
tf = float(t_val)
if lo is not None and (tf < lo or tf > hi):
continue
f.write(f"{tf:.10g} {float(y_val):.10g}\n")
def _write_data_file(
self,
dat_path: Path,
payload: dict,
*,
label_text: object | None = None,
x_range: tuple[float, float] | None = None,
axis_key: str | None = None,
bake_waterfall_offset: bool = True,
) -> None:
"""Write a .dat file with spectra data and metadata header.
``x_range`` optionally restricts the written rows to ``[lo, hi]``
(inclusive); ``None`` (the default, used by the GLE export) writes every
point. ``axis_key`` identifies the projection (``P_x``/``P_y``/``P_z``)
for vector exports so the file records which polarization component it
holds rather than a generic asymmetry label.
``bake_waterfall_offset`` adds the payload's waterfall offset to the
written asymmetry. The plain-text export leaves it ``True`` (the text is
the final artifact); the GLE export passes ``False`` when the installed
gleplot applies the offset in the script (``let``) instead, keeping the
written values raw. The header records the offset either way.
"""
data = payload.get("data") or {}
t_data = data.get("t")
y_data = data.get("y")
y_err = data.get("err")
if t_data is None or y_data is None:
return
# Mirror the on-screen waterfall stack: the written asymmetry carries the
# per-trace offset (unless GLE applies it in-script), recorded in a
# header line so the raw values remain recoverable. 0.0 (absent) leaves
# the data untouched.
waterfall_offset = float(payload.get("waterfall_offset", 0.0))
if waterfall_offset and bake_waterfall_offset:
y_data = np.asarray(y_data, dtype=float) + waterfall_offset
display_label = label_text if label_text is not None else payload.get("label", "dataset")
run_metadata = (
payload.get("run_metadata") if isinstance(payload.get("run_metadata"), dict) else {}
)
histogram_info = (
payload.get("histogram_info") if isinstance(payload.get("histogram_info"), dict) else {}
)
grouping = payload.get("grouping") if isinstance(payload.get("grouping"), dict) else {}
def _fmt_float(value: object, decimals: int) -> str:
try:
return f"{float(value):.{decimals}f}"
except (TypeError, ValueError):
return ""
def _fmt_mev(value: object) -> str:
try:
return f"{float(value) / 1.0e6:.2f}"
except (TypeError, ValueError):
return ""
def _safe_int(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
title = str(run_metadata.get("title", "") or "")
comment = str(run_metadata.get("comment", "") or "")
started = str(run_metadata.get("started", "") or "")
stopped = str(run_metadata.get("stopped", "") or "")
temperature = _fmt_float(run_metadata.get("temperature"), 3)
temperature_label = str(
run_metadata.get("temperature_label") or run_metadata.get("temperature_setpoint") or ""
).strip()
field = _fmt_float(run_metadata.get("field"), 2)
n_hist = _safe_int(histogram_info.get("count"))
n_bins = _safe_int(histogram_info.get("n_bins"))
bin_width_us = histogram_info.get("bin_width_us")
bin_width_ps_text = ""
bin_width_us_text = ""
if bin_width_us is not None:
bin_width_ps_text = _fmt_float(float(bin_width_us) * 1.0e6, 6)
bin_width_us_text = _fmt_float(bin_width_us, 2)
events_grouped = _fmt_mev(histogram_info.get("events_grouped"))
events_raw = _fmt_mev(histogram_info.get("events_total"))
groups_raw = grouping.get("groups")
groups: dict[int, list[int]] = {}
if isinstance(groups_raw, dict):
for key, values in groups_raw.items():
gid = _safe_int(key)
if gid is None or not isinstance(values, list):
continue
detectors: list[int] = []
for v in values:
vv = _safe_int(v)
if vv is not None:
detectors.append(vv)
if detectors:
groups[gid] = sorted(set(detectors))
# Backward-compatible fallback for payloads that store detectors as
# direct forward/backward lists rather than a groups mapping.
if not groups:
f_list = grouping.get("forward")
b_list = grouping.get("backward")
if isinstance(f_list, list):
f_det = [v for v in (_safe_int(x) for x in f_list) if v is not None]
if f_det:
groups[1] = sorted(set(f_det))
if isinstance(b_list, list):
b_det = [v for v in (_safe_int(x) for x in b_list) if v is not None]
if b_det:
groups[2] = sorted(set(b_det))
t0_bins = (
histogram_info.get("t0_bins") if isinstance(histogram_info.get("t0_bins"), list) else []
)
forward_group = _safe_int(grouping.get("forward_group"))
backward_group = _safe_int(grouping.get("backward_group"))
alpha = _fmt_float(grouping.get("alpha"), 4)
t0_bin = _safe_int(grouping.get("t0_bin"))
if t0_bin is None:
t0_bin = 0
t_good_offset = _safe_int(grouping.get("t_good_offset"))
first_good_bin = _safe_int(grouping.get("first_good_bin"))
if t_good_offset is None and first_good_bin is not None:
t_good_offset = max(0, first_good_bin - t0_bin)
last_good_bin = _safe_int(grouping.get("last_good_bin"))
bunching_factor = _safe_int(grouping.get("bunching_factor"))
deadtime_correction = bool(grouping.get("deadtime_correction", False))
with open(dat_path, "w", encoding="utf-8") as f:
f.write("! START OF RUN INFORMATION\n")
f.write(f"! Run number : {payload.get('run_number', '')}\n")
if title:
f.write(f"! Title : {title}\n")
if temperature:
if temperature_label:
f.write(f"! Temperature : {temperature} K (label {temperature_label})\n")
else:
f.write(f"! Temperature : {temperature} K\n")
if field:
f.write(f"! Field : {field} G\n")
if comment:
f.write(f"! Comment : {comment}\n")
if started:
f.write(f"! Started : {started}\n")
if stopped:
f.write(f"! Stopped : {stopped}\n")
if (
n_hist is not None
and n_bins is not None
and bin_width_ps_text
and bin_width_us_text
):
f.write(
"! Histograms : "
f"{n_hist} ({n_bins} bins of {bin_width_ps_text} ps = {bin_width_us_text} us)\n"
)
if events_grouped and events_raw:
f.write(
f"! Events : {events_grouped} MEv grouped in range (raw = {events_raw})\n"
)
elif events_raw:
f.write(f"! Events : {events_raw} MEv\n")
f.write("! END OF RUN INFORMATION\n")
f.write("!\n")
f.write("! START OF GROUPING INFORMATION\n")
for gid in sorted(groups):
det_tokens: list[str] = []
for det in groups[gid]:
idx = det - 1
if 0 <= idx < len(t0_bins):
det_tokens.append(f"{det:02d}({int(t0_bins[idx])})")
else:
det_tokens.append(f"{det:02d}")
f.write(f"! Group#{gid:02d} Hist(t0): {', '.join(det_tokens)}\n")
if forward_group is not None or backward_group is not None:
fwd_text = "" if forward_group is None else str(forward_group)
bwd_text = "" if backward_group is None else str(backward_group)
f.write(
f"! Forward Group = {fwd_text}, Backward Group = {bwd_text}, Alpha = {alpha}\n"
)
elif isinstance(grouping.get("forward"), list) or isinstance(
grouping.get("backward"), list
):
f.write(f"! Forward Group = forward, Backward Group = backward, Alpha = {alpha}\n")
if t_good_offset is not None or last_good_bin is not None:
fg_text = "" if t_good_offset is None else str(t_good_offset)
lg_text = "" if last_good_bin is None else str(last_good_bin)
f.write(f"! Offset to first good bin = {fg_text}, Last good bin = {lg_text}\n")
binning_mode = str(grouping.get("binning_mode", "fixed"))
if binning_mode == "variable":
f.write(
f"! Variable binning, initial bin = {grouping.get('bin0_us')} us, "
f"bin at 10 us = {grouping.get('bin10_us')} us\n"
)
elif binning_mode == "constant_error":
f.write(f"! Constant-error binning, initial bin = {grouping.get('bin0_us')} us\n")
elif bunching_factor is not None:
f.write(f"! Fixed binning, bunching factor = {bunching_factor}\n")
f.write(f"! Deadtime correction {'on' if deadtime_correction else 'off'}\n")
f.write("! END OF GROUPING INFORMATION\n")
f.write("!\n")
if payload.get("domain") == "frequency":
self._write_frequency_data_body(
f, payload, display_label=display_label, x_range=x_range
)
return
if axis_key in {"P_x", "P_y", "P_z"}:
suffix = axis_key.split("_", 1)[1]
datarow_label = f"a_0 P_{suffix}(t)"
ylabel = f"a_0 P_{suffix}(t) (%)"
else:
datarow_label = "FB asymmetry"
ylabel = "% Asymmetry"
f.write("! START OF DATA SET INFORMATION\n")
f.write(f"! Datarow: (Time) ({datarow_label}) (Error)\n")
if axis_key in {"P_x", "P_y", "P_z"}:
f.write(f"! Projection: {axis_key}\n")
f.write(f"! Title: {display_label}\n")
f.write("! Xlabel: Time in microseconds\n")
f.write(f"! Ylabel: {ylabel}\n")
if waterfall_offset:
f.write(f"! waterfall offset: {waterfall_offset:.10g}\n")
f.write("! END OF DATA SET INFORMATION\n")
f.write("! time asymmetry error\n")
err_arr = y_err if y_err is not None else np.zeros_like(y_data)
lo, hi = (None, None) if x_range is None else (min(x_range), max(x_range))
for t_val, y_val, e_val in zip(t_data, y_data, err_arr):
tf = float(t_val)
if lo is not None and (tf < lo or tf > hi):
continue
f.write(f"{tf:.10g} {float(y_val):.10g} {float(e_val):.10g}\n")
@staticmethod
def _frequency_x_column_name(x_unit: str, axis_mode: str, is_correlation: bool) -> str:
"""Return the self-describing ``.dat`` x-column name for the display mode."""
if is_correlation:
return "coupling_MHz"
if axis_mode == "relative_ppm":
return "relative_shift_ppm"
if axis_mode == "shift":
return _FREQUENCY_SHIFT_X_COLUMN_NAME.get(x_unit, "shift_MHz")
return _FREQUENCY_X_COLUMN_NAME.get(x_unit, "frequency_MHz")
def _write_frequency_data_body(
self,
f,
payload: dict,
*,
display_label: object,
x_range: tuple[float, float] | None,
) -> None:
"""Write the frequency-view DATA SET / FOURIER header and data rows.
Columns are ``c1`` = x in the current display unit, ``c2`` = amplitude,
``c3`` = error, and — only when the display unit differs from canonical
MHz — ``c4`` = the canonical ``frequency_MHz`` axis, so the file stays
self-describing while the GLE script's ``d1=c1,c2`` mapping (plotted x,
amplitude) remains valid. The x-range filter is applied to the
display-unit column, matching the on-screen window and the text
export's control-value conversion.
"""
data = payload.get("data") or {}
t_data = data.get("t")
y_data = data.get("y")
y_err = data.get("err")
if t_data is None or y_data is None:
return
mhz_arr = np.asarray(t_data, dtype=float)
x_display = payload.get("x_display")
x_disp_arr = (
np.asarray(x_display, dtype=float)
if x_display is not None
else self._convert_frequency_axis_for_display(t_data)
)
x_unit = str(payload.get("x_unit", "frequency_mhz"))
axis_mode = str(payload.get("axis_mode", "absolute"))
is_correlation = bool(payload.get("is_correlation"))
x_column = self._frequency_x_column_name(x_unit, axis_mode, is_correlation)
# Shift/ppm columns are not self-describing on their own (they need the
# reference), so always keep the canonical frequency_MHz column beside
# them; absolute field columns keep it only when the unit differs from MHz.
if is_correlation:
include_mhz = False
elif axis_mode in ("shift", "relative_ppm"):
include_mhz = True
else:
include_mhz = x_unit in ("field_gauss", "field_tesla")
x_label = str(payload.get("x_label") or self._display_x_label())
y_label = str(payload.get("y_label") or "FFT (%)")
# A unit-area field distribution is a density in the displayed x unit
# (the payload carries the exported Jacobian's unit), so name its column
# distinctly from an amplitude spectrum. The label sniff is a fallback
# for payloads without the explicit key.
density_unit = payload.get("density_unit")
if density_unit is None and "1/mhz" in y_label.lower():
density_unit = "mhz"
amplitude_column = (
_DENSITY_COLUMN_NAME.get(str(density_unit), "density_per_MHz")
if density_unit is not None
else "amplitude"
)
f.write("! START OF DATA SET INFORMATION\n")
f.write(f"! Datarow: ({x_label}) (Amplitude) (Error)\n")
f.write(f"! Title: {display_label}\n")
f.write(f"! Xlabel: {x_label}\n")
f.write(f"! Ylabel: {y_label}\n")
f.write("! END OF DATA SET INFORMATION\n")
f.write("!\n")
f.write("! START OF FOURIER INFORMATION\n")
fourier_metadata = payload.get("fourier_metadata")
if isinstance(fourier_metadata, dict):
for key in sorted(fourier_metadata):
f.write(f"! {key}: {fourier_metadata[key]}\n")
f.write(f"! Display mode: {y_label}\n")
f.write(f"! X unit: {x_unit}\n")
f.write(f"! Axis mode: {axis_mode}\n")
if density_unit is not None:
f.write(
f"! Density unit: {_DENSITY_COLUMN_NAME.get(str(density_unit), str(density_unit))}\n"
)
reference_gauss = payload.get("reference_field_gauss")
if reference_gauss is not None:
f.write(f"! Reference field: {float(reference_gauss):.4g} G\n")
f.write("! END OF FOURIER INFORMATION\n")
column_names = [x_column, amplitude_column, "error"]
if include_mhz:
column_names.append("frequency_MHz")
f.write("! " + " ".join(column_names) + "\n")
err_arr = y_err if y_err is not None else np.zeros_like(y_data)
lo, hi = (None, None) if x_range is None else (min(x_range), max(x_range))
for x_val, y_val, e_val, mhz_val in zip(x_disp_arr, y_data, err_arr, mhz_arr):
xf = float(x_val)
if lo is not None and (xf < lo or xf > hi):
continue
if include_mhz:
f.write(
f"{xf:.10g} {float(y_val):.10g} {float(e_val):.10g} {float(mhz_val):.10g}\n"
)
else:
f.write(f"{xf:.10g} {float(y_val):.10g} {float(e_val):.10g}\n")
def _show_export_result_dialog(self, title: str, summary: str, details: str) -> None:
"""Show export results (delegates to the shared foundation)."""
show_export_result_dialog(self, title, summary, details)
@staticmethod
def _export_figure_size(series_count: int) -> tuple[float, float]:
"""Return a figure size that grows taller for crowded multi-series exports."""
width = 6.0
if series_count <= 1:
return width, 4.2
# Increase height with number of overlaid spectra while capping growth.
# This keeps multi-series plots closer to square for readability.
height = 4.2 + min(3.0, 0.55 * float(series_count - 1))
height = max(height, width * 0.85)
height = min(height, 7.2)
return width, height
def _collect_export_payloads(self) -> list[dict] | None:
"""Assemble the current plot's export payloads (with the ALL fallback).
Shared by the GLE export and the plain-text data export so both see the
same per-dataset payloads.
"""
payloads = self.get_current_plot_export_data()
if (
payloads is None
and self._current_polarization_axis == "ALL"
and self._vector_subplot_datasets
):
order = self._projection_subplot_order(self._vector_subplot_datasets)
first_axis = order[0] if order else None
payloads = self.get_current_plot_export_data(
self._vector_subplot_datasets.get(first_axis) if first_axis else []
)
return payloads
def _export_axis_groups(self) -> list[tuple[str | None, list[dict]]]:
"""Return ``(axis_key, payloads)`` groups covering every series in view.
In vector "ALL" mode this yields one entry per projection so multi-series
views export *all* components — not just the first, which is all that
``_current_datasets`` (and hence ``_collect_export_payloads``) holds.
Every other view yields a single ``(None, payloads)`` entry.
"""
if self._current_polarization_axis == "ALL" and self._vector_subplot_datasets:
order = self._projection_subplot_order(self._vector_subplot_datasets)
groups: list[tuple[str | None, list[dict]]] = []
for axis_key in order:
payloads = self.get_current_plot_export_data(
self._vector_subplot_datasets.get(axis_key, [])
)
if payloads:
groups.append((axis_key, payloads))
if groups:
return groups
payloads = self._collect_export_payloads()
return [(None, payloads)] if payloads else []
def _prompt_text_export_options(self) -> tuple[str, bool] | None:
"""Ask for the data-only export content and x-range option.
Returns ``(content, limit_to_range)`` where content is one of
``"data"`` / ``"data_fit"`` / ``"fit"``, or ``None`` if cancelled.
"""
dialog = QDialog(self)
dialog.setWindowTitle("Export plotted data (text)")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
layout.addWidget(QLabel("Content to export:"))
content_combo = QComboBox(dialog)
content_combo.addItem("Data only", "data")
content_combo.addItem("Data + fit", "data_fit")
content_combo.addItem("Fit only", "fit")
layout.addWidget(content_combo)
range_box = QCheckBox("Limit to current x-range", dialog)
range_box.setChecked(False)
layout.addWidget(range_box)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
parent=dialog,
)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() != QDialog.DialogCode.Accepted:
return None
return str(content_combo.currentData()), bool(range_box.isChecked())
[docs]
def export_plotted_data_as_text(self) -> None:
"""Export the plotted curve(s) as plain text, without the GLE machinery.
Reuses the same ``.dat``/``.fit`` writers (and their provenance header)
as the GLE export, but writes only the text files — no ``.gleplot``
folder, GLE script, or compile. The content switch (data only / data +
fit / fit only) mirrors WiMDA's stData/stBoth/stFit.
"""
axis_groups = self._export_axis_groups()
if not axis_groups:
QMessageBox.warning(
self,
"Export unavailable",
"No plotted data is available to export.",
)
return
options = self._prompt_text_export_options()
if options is None:
return
content, limit_to_range = options
x_range = None
if limit_to_range:
x0 = float(self._x_min.value())
x1 = float(self._x_max.value())
if self._is_frequency_plot_panel():
x0 = self._convert_frequency_control_value_to_axis_limit(x0)
x1 = self._convert_frequency_control_value_to_axis_limit(x1)
x_range = (x0, x1)
total_series = sum(len(payloads) for _, payloads in axis_groups)
if total_series == 1:
axis_key, payloads = axis_groups[0]
payload = payloads[0]
token = self._safe_file_token(str(payload.get("label", "dataset")))
path, _ = QFileDialog.getSaveFileName(
self,
"Export plotted data (text)",
default_export_path(f"{token}.dat"),
"Data files (*.dat);;Text files (*.txt);;All files (*)",
)
if not path:
return
remember_export_path(path)
base = Path(path).with_suffix("")
written = self._write_payload_text_files(
base, payload, content, x_range, axis_key=axis_key
)
else:
directory = QFileDialog.getExistingDirectory(
self,
"Export plotted data (text) — choose a folder",
default_export_path(""),
)
if not directory:
return
remember_export_path(directory)
export_dir = Path(directory)
written = []
used_tokens: set[str] = set()
for axis_key, payloads in axis_groups:
axis_suffix = self._export_axis_suffix(axis_key)
for i, payload in enumerate(payloads):
label = str(payload.get("label", f"dataset_{i}"))
name = f"{label}_{axis_suffix}" if axis_key in {"P_x", "P_y", "P_z"} else label
token = self._dedup_export_token(self._safe_file_token(name), used_tokens)
written.extend(
self._write_payload_text_files(
export_dir / token,
payload,
content,
x_range,
axis_key=axis_key,
)
)
if not written:
QMessageBox.warning(
self,
"Nothing written",
"The selected content produced no files (for example 'fit only' "
"with no fit on the plot).",
)
return
files_text = "\n".join(str(p) for p in written)
self._show_export_result_dialog(
"Export Successful",
f"Wrote {len(written)} text file(s).",
files_text,
)
def _write_payload_text_files(
self,
base: Path,
payload: dict,
content: str,
x_range: tuple[float, float] | None,
axis_key: str | None = None,
) -> list[Path]:
"""Write the .dat and/or .fit text files for one payload."""
written: list[Path] = []
if content in ("data", "data_fit"):
dat_path = base.with_suffix(".dat")
self._write_data_file(
dat_path,
payload,
label_text=payload.get("label"),
x_range=x_range,
axis_key=axis_key,
)
if dat_path.exists():
written.append(dat_path)
if content in ("data_fit", "fit"):
fit = payload.get("fit") or {}
if fit.get("t") is not None and fit.get("y") is not None:
fit_path = base.with_suffix(".fit")
self._write_fit_file(fit_path, payload, x_range=x_range)
if fit_path.exists():
written.append(fit_path)
return written
def _make_stacked_gle_axes(self, glp, n_axes: int):
"""Create *n_axes* vertically stacked, x-shared GLE axes.
Returns ``(figure, axes_list)``. Prefers ``glp.subplots`` and falls back
to manual ``add_subplot`` wiring. Shared by the vector-projection and
grouped-time-domain export branches so both render as stacked subplots.
"""
if hasattr(glp, "subplots"):
fig, axes_candidate = glp.subplots(
nrows=n_axes,
ncols=1,
figsize=(6.4, 2.8 * n_axes),
sharex=True,
)
axes_objs = (
list(axes_candidate) if isinstance(axes_candidate, list) else [axes_candidate]
)
return fig, axes_objs
fig = glp.figure(figsize=(6.4, 2.8 * n_axes))
axes_objs = []
shared_ax = None
for idx in range(n_axes):
ax = fig.add_subplot(n_axes, 1, idx + 1, sharex=shared_ax)
if shared_ax is None:
shared_ax = ax
axes_objs.append(ax)
return fig, axes_objs
[docs]
def export_plots_to_gle(self) -> None:
"""Export current main-plot view as GLE using gleplot.
Time-domain data is plotted with error bars (no connecting lines) and
fit curves with lines (no markers). Frequency-domain (FFT/MaxEnt) views
mirror the on-screen spectrum instead: a piecewise-linear line in the
current display unit plus a light shaded ±1σ band. File names are
derived from the Label dropdown value for each dataset.
"""
payloads = self._collect_export_payloads()
if not payloads:
QMessageBox.warning(
self,
"Export unavailable",
"No plotted data is available to export.",
)
return
run_gle_export(
self,
tasks=self._tasks,
dialog_title="Export Plot(s) to GLE",
default_name="asymmetry_plot.gleplot",
output_format=self._gle_format_combo.currentText().lower(),
build=lambda glp, gle_path, export_dir: self._build_gle_export(glp, gle_path, payloads),
)
def _build_gle_export(self, glp, gle_path: Path, payloads: list[dict]) -> GleExportBuild:
"""Build the export figure + sidecars (``run_gle_export`` builder)."""
colors = [
"black",
"red",
"blue",
"green",
"orange",
"purple",
"cyan",
"magenta",
"brown",
"gray",
]
written_files: list[Path] = []
dat_writes: list[tuple[Path, dict, object, str | None]] = []
# Track every data-file token used this export so two series sharing a
# label cannot silently overwrite each other's .dat/.fit files.
used_tokens: set[str] = set()
grouped_datasets = list(getattr(self, "_grouped_time_subplot_datasets", []) or [])
axis_order: list[str] = []
if self._current_polarization_axis == "ALL" and self._vector_subplot_datasets:
axis_order = self._projection_subplot_order(self._vector_subplot_datasets)
if axis_order:
fig, axes_objs = self._make_stacked_gle_axes(glp, len(axis_order))
last_ax = None
for idx, axis_key in enumerate(axis_order):
ax = axes_objs[idx]
axis_payloads = self.get_current_plot_export_data(
self._vector_subplot_datasets.get(axis_key, [])
)
if not axis_payloads:
continue
show_legend = idx == 0 and len(axis_payloads) > 1
self._plot_export_payloads_on_axis(
ax,
axis_payloads,
axis_key=axis_key,
written_files=written_files,
dat_writes=dat_writes,
gle_path=gle_path,
colors=colors,
show_legend=show_legend,
used_tokens=used_tokens,
)
last_ax = ax
# Label the time axis on the last subplot that actually drew data, so
# an empty trailing projection cannot leave the figure unlabeled.
if last_ax is not None:
last_ax.set_xlabel("Time (µs)")
if hasattr(fig, "subplots_adjust"):
# Keep y-axis labels and bottom x-label clear of clipping.
fig.subplots_adjust(left=0.20, right=0.98, top=0.98, bottom=0.12, hspace=0.08)
elif len(grouped_datasets) > 1:
# Individual groups: render one stacked subplot per group, mirroring
# the on-screen grouped-time-domain view rather than overlaying every
# group on a single axis.
fig, axes_objs = self._make_stacked_gle_axes(glp, len(grouped_datasets))
last_ax = None
for idx, dataset in enumerate(grouped_datasets):
ax = axes_objs[idx]
group_payloads = self.get_current_plot_export_data([dataset])
if not group_payloads:
continue
# Use the group's run label as its identity so per-group data
# files get distinct names and the subplot title matches screen.
group_label = str(
getattr(dataset, "run_label", group_payloads[0].get("label", f"group_{idx}"))
)
group_payloads[0]["label"] = group_label
self._plot_export_payloads_on_axis(
ax,
group_payloads,
axis_key=None,
written_files=written_files,
dat_writes=dat_writes,
gle_path=gle_path,
colors=colors,
show_legend=False,
used_tokens=used_tokens,
)
if hasattr(ax, "set_title"):
ax.set_title(self._sanitize_gle_text(group_label), loc="left")
last_ax = ax
if last_ax is not None:
last_ax.set_xlabel("Time (µs)")
if hasattr(fig, "subplots_adjust"):
fig.subplots_adjust(left=0.20, right=0.98, top=0.98, bottom=0.12, hspace=0.08)
else:
# Create the figure only in the branch that uses it (the stacked
# branches build their own via _make_stacked_gle_axes).
figure_kwargs: dict = {"figsize": self._export_figure_size(len(payloads))}
if any(p.get("domain") == "frequency" for p in payloads) and hasattr(
glp, "GLEGraphConfig"
):
# A measured spectrum must render piecewise-linear: GLE's
# ``smooth`` spline overshoots on sharp resonance lines.
# ``GlobalConfig.get_graph()`` hands every figure the shared
# singleton, so never flip ``smooth_curves`` on it in place —
# pass this figure its own config instead. Time-domain exports
# keep gleplot's default behavior untouched.
figure_kwargs["graph"] = glp.GLEGraphConfig(smooth_curves=False)
fig = glp.figure(**figure_kwargs)
ax = fig.add_subplot(111)
self._plot_export_payloads_on_axis(
ax,
payloads,
axis_key=None,
written_files=written_files,
dat_writes=dat_writes,
gle_path=gle_path,
colors=colors,
show_legend=len(payloads) > 1,
used_tokens=used_tokens,
)
if payloads and payloads[0].get("domain") == "frequency":
ax.set_xlabel(
self._sanitize_gle_text(
self._strip_matplotlib_math(
payloads[0].get("x_label") or self._display_x_label()
),
fallback="Frequency (MHz)",
)
)
else:
ax.set_xlabel("Time (µs)")
fig.savefig(str(gle_path))
# Ensure our sidecar .dat files retain metadata headers even when
# gleplot generates/overwrites data_name-matched data files on save.
# These sidecars stay raw when gleplot applies the waterfall offset in
# the script (``let``); against older gleplot the offset is baked here
# so the file matches the drawn stack.
bake_offset = not gle_offset_supported()
for dat_path, payload, label_text, axis_key in dat_writes:
self._write_data_file(
dat_path,
payload,
label_text=label_text,
axis_key=axis_key,
bake_waterfall_offset=bake_offset,
)
return GleExportBuild(files=written_files)
# Keep old name as alias for backward compatibility with tests.
[docs]
def export_current_plot(self) -> None:
"""Export current main-plot view as GLE (with optional compiled output)."""
self.export_plots_to_gle()
[docs]
def shutdown_workers(self, timeout_ms: int = 10_000) -> None:
"""Stop this panel's background tasks (GLE export compiles)."""
self._tasks.shutdown(timeout_ms)
# ── rotating reference frame (Options → Advanced) ───────────────────
[docs]
def set_rrf_feature_enabled(self, enabled: bool) -> None:
"""Enable/disable the whole RRF surface (the Advanced toggle, app-level).
Forwards to the RRF controls (which appear/disappear under the usual
view condition) and redraws so the display reverts to or from the
rotating frame immediately.
"""
controls = getattr(self, "_rrf_controls", None)
if controls is None:
return
controls.set_feature_enabled(bool(enabled))
self._redraw_current_view()
[docs]
def rrf_has_active_parameters(self) -> bool:
"""True when the RRF controls carry an active frame (enabled + ν₀ > 0).
Independent of the feature toggle: used on project open to decide
whether a stored RRF configuration should auto-enable the toggle so the
user's analysis is not silently hidden.
"""
controls = getattr(self, "_rrf_controls", None)
if controls is None:
return False
return controls.has_active_frame()
[docs]
def rrf_fit_frequency_mhz(self) -> float | None:
"""Frame frequency ν₀ (MHz) when an RRF fit should be performed, else None.
The fit auto-couples to the plot's RRF controls: a single composite fit
runs in the rotating frame exactly when the RRF display is active (the
feature is on, the controls are enabled with ν₀ > 0, and the FB-asymmetry
time view is showing). The fit consumes raw data with this offset and
reports lab-frame frequencies (δν + ν₀).
"""
controls = getattr(self, "_rrf_controls", None)
if controls is None or not controls.is_active() or not controls.applies_to_current_view():
return None
return controls.frequency_mhz()
# ── project state helpers ──────────────────────────────────────────
[docs]
def get_state(self) -> dict:
"""Return a serialisable snapshot of the plot panel state.
This captures the bunch factor, axis limits, the currently displayed
run number, and any stored fit curves. Fit curve arrays are
serialised as plain Python lists for JSON compatibility.
Returns
-------
dict
Plot state suitable for inclusion in a project file.
"""
state: dict = {
"plot_panel_domain": self._domain,
"current_run_number": (
self._current_dataset.run_number if self._current_dataset is not None else None
),
"time_view_mode": self.current_time_view_mode(),
"log_counts_scale": bool(getattr(self, "_log_counts_enabled", False)),
"label_field": self._label_field_combo.currentData() if self._has_mpl else "run",
"default_label_field": self._default_label_field,
"label_field_by_group": dict(self._label_field_by_group),
"overlay_enabled": self.is_overlay_enabled(),
"waterfall": {
"enabled": self.is_waterfall_enabled(),
"offset": self.waterfall_offset(),
},
"bunch_factor": self._bunch_factor.value() if self._has_mpl else 1,
"auto_x_enabled": self._auto_x_btn.isChecked() if self._has_mpl else False,
"auto_y_enabled": self._auto_y_btn.isChecked() if self._has_mpl else False,
"x_min": self._x_min.value() if self._has_mpl else 0.0,
"x_max": self._x_max.value() if self._has_mpl else 10.0,
"y_min": self._y_min.value() if self._has_mpl else -30.0,
"y_max": self._y_max.value() if self._has_mpl else 30.0,
"polarization_axis": self._current_polarization_axis,
"projection_selection": list(self._selected_projection_labels),
"y_limits_by_polarization": {
axis: [float(lim[0]), float(lim[1])]
for axis, lim in self._y_limits_by_polarization.items()
},
"fit_curve": None,
"fit_curve_run_number": self._fit_curve_run_number,
"fit_curves": {},
"fit_curves_by_key": {},
"fit_components": None,
"fit_components_by_run": {},
"fit_components_by_key": {},
"annotations": [],
"annotations_by_group": {},
"fit_x_min": self._fit_x_min,
"fit_x_max": self._fit_x_max,
}
if hasattr(self, "_rrf_controls"):
state["rrf"] = self._rrf_controls.get_state()
if self._is_frequency_plot_panel():
state["frequency_x_unit"] = self._current_frequency_x_unit
state["frequency_axis_mode"] = self._frequency_axis_mode
state["frequency_reference_mode"] = self._frequency_reference_mode
if self._frequency_common_reference_mhz is not None:
state["frequency_common_reference_mhz"] = float(
self._frequency_common_reference_mhz
)
state["frequency_x_limits_by_unit"] = {
unit: [float(limits[0]), float(limits[1])]
for unit, limits in self._frequency_x_limits_by_unit.items()
}
if self._has_mpl:
if self._fit_curve is not None:
t_fit, y_fit, label = self._fit_curve
state["fit_curve"] = {
"t": list(t_fit),
"y": list(y_fit),
"label": label,
}
for run_number, (t_fit, y_fit, label) in self._fit_curves.items():
state["fit_curves"][str(run_number)] = {
"t": list(t_fit),
"y": list(y_fit),
"label": label,
}
for (run_number, axis_key), (t_fit, y_fit, label) in self._fit_curves_by_key.items():
state["fit_curves_by_key"][self._encode_fit_storage_key(run_number, axis_key)] = {
"t": list(t_fit),
"y": list(y_fit),
"label": label,
}
if self._fit_components is not None:
state["fit_components"] = [
{"name": name, "y": list(y_vals)} for name, y_vals in self._fit_components
]
for run_number, curves in self._fit_components_by_run.items():
state["fit_components_by_run"][str(run_number)] = [
{"name": name, "y": list(y_vals)} for name, y_vals in curves
]
for (run_number, axis_key), curves in self._fit_components_by_key.items():
state["fit_components_by_key"][
self._encode_fit_storage_key(run_number, axis_key)
] = [{"name": name, "y": list(y_vals)} for name, y_vals in curves]
state["annotations"] = self._serialize_annotations(self._default_annotations)
state["annotations_by_group"] = {
str(group_id): self._serialize_annotations(annotations)
for group_id, annotations in self._annotations_by_group.items()
}
state["fit_metadata"] = {str(rn): meta for rn, meta in self._fit_metadata.items()}
state["fit_metadata_by_key"] = {
self._encode_fit_storage_key(run_number, axis_key): meta
for (run_number, axis_key), meta in self._fit_metadata_by_key.items()
}
return state
[docs]
def restore_state(
self,
state: dict,
dataset: MuonDataset | None = None,
) -> None:
"""Restore plot panel state from a saved dict.
Parameters
----------
state : dict
Plot state as returned by :meth:`get_state`.
dataset : MuonDataset, optional
Dataset to re-plot after restoring limits. If *None* no plot is
drawn, but all other state (limits, bunch factor, fit curves) is
still applied.
"""
if not self._has_mpl:
return
import numpy as np
if hasattr(self, "_rrf_controls"):
self._rrf_controls.set_state(state.get("rrf"))
self._auto_x_btn.setChecked(bool(state.get("auto_x_enabled", False)))
self._auto_y_btn.setChecked(bool(state.get("auto_y_enabled", False)))
default_label_field = state.get("default_label_field", state.get("label_field", "run"))
if not self._is_restorable_label_field(default_label_field):
default_label_field = "run"
self._default_label_field = str(default_label_field)
raw_group_label_fields = state.get("label_field_by_group", {})
self._label_field_by_group = {}
if isinstance(raw_group_label_fields, dict):
for group_id, field in raw_group_label_fields.items():
if self._is_restorable_label_field(field):
self._label_field_by_group[str(group_id)] = str(field)
self._active_label_group_id = None
self._current_polarization_axis = self._axis_canonical_key(state.get("polarization_axis"))
# Seed the selected subset so a restored stacked-subplot view reopens
# with the exact projections it was saved with (the chip bar is rebuilt
# later by _refresh_vector_axis_selector, which reads this).
raw_selection = state.get("projection_selection")
self._selected_projection_labels = (
[str(label) for label in raw_selection if label]
if isinstance(raw_selection, list)
else []
)
self._y_limits_by_polarization = {}
raw_y_limits_by_axis = state.get("y_limits_by_polarization", {})
if isinstance(raw_y_limits_by_axis, dict):
for raw_axis, raw_limits in raw_y_limits_by_axis.items():
axis = self._axis_canonical_key(raw_axis)
if (
axis is None
or not isinstance(raw_limits, (list, tuple))
or len(raw_limits) != 2
):
continue
try:
lo = float(raw_limits[0])
hi = float(raw_limits[1])
except (TypeError, ValueError):
continue
self._y_limits_by_polarization[axis] = (lo, hi)
# Adopt the saved current selection as the default when valid, then let the
# combo target that intent. A saved *custom* column that the host has not
# pushed back yet is kept as the default (not clobbered to "run") and gets
# selected automatically once set_custom_label_fields offers it.
label_field = state.get("label_field", self._default_label_field)
if self._is_restorable_label_field(label_field) and self._active_label_group_id is None:
self._default_label_field = str(label_field)
self._rebuild_label_field_combo()
self.set_overlay_enabled(bool(state.get("overlay_enabled", True)), emit_signal=False)
waterfall_state = state.get("waterfall")
if isinstance(waterfall_state, dict):
raw_offset = waterfall_state.get("offset")
offset: float | None
try:
offset = None if raw_offset is None else float(raw_offset)
except (TypeError, ValueError):
offset = None
self.set_waterfall_offset(offset, emit_signal=False)
self.set_waterfall_enabled(
bool(waterfall_state.get("enabled", False)), emit_signal=False
)
else:
self.set_waterfall_offset(None, emit_signal=False)
self.set_waterfall_enabled(False, emit_signal=False)
self.set_time_view_modes(
self._available_time_view_modes,
current_mode=state.get("time_view_mode", self._current_time_view_mode),
)
self._log_counts_enabled = bool(state.get("log_counts_scale", False))
if hasattr(self, "_log_counts_checkbox"):
self._log_counts_checkbox.blockSignals(True)
self._log_counts_checkbox.setChecked(self._log_counts_enabled)
self._log_counts_checkbox.blockSignals(False)
self._refresh_log_counts_visibility()
if self._is_frequency_plot_panel():
# Valid per-mode limit-stash keys: absolute/shift keyed on unit, ppm
# unit-independent. Bare-unit keys are a pre-mode legacy shape; the
# ``:relative`` keys are the retired old relative axis (view state,
# discarded — new limits reframe cleanly).
valid_stash_keys = {
"frequency_mhz",
"field_gauss",
"field_tesla",
"frequency_mhz:absolute",
"field_gauss:absolute",
"field_tesla:absolute",
"frequency_mhz:shift",
"field_gauss:shift",
"field_tesla:shift",
"relative_ppm",
}
self._frequency_x_limits_by_unit = {}
raw_limits_by_unit = state.get("frequency_x_limits_by_unit", {})
if isinstance(raw_limits_by_unit, dict):
for raw_unit, raw_limits in raw_limits_by_unit.items():
if (
raw_unit not in valid_stash_keys
or not isinstance(raw_limits, (list, tuple))
or len(raw_limits) != 2
):
continue
try:
lo = float(raw_limits[0])
hi = float(raw_limits[1])
except (TypeError, ValueError):
continue
self._frequency_x_limits_by_unit[str(raw_unit)] = (lo, hi)
restored_unit = str(state.get("frequency_x_unit", "frequency_mhz"))
if restored_unit not in {"frequency_mhz", "field_gauss", "field_tesla"}:
restored_unit = "frequency_mhz"
self._current_frequency_x_unit = restored_unit
# Axis mode: prefer the new key; migrate a legacy relative boolean
# (schema < 16, or a defensive read) to "shift" + "common" reference.
restored_mode = state.get("frequency_axis_mode")
restored_reference_mode = state.get("frequency_reference_mode")
if restored_mode is None:
legacy_relative = bool(state.get("frequency_axis_relative_to_reference", False))
restored_mode = "shift" if legacy_relative else "absolute"
if legacy_relative and restored_reference_mode is None:
restored_reference_mode = "common"
if str(restored_mode) not in _FREQUENCY_AXIS_MODES:
restored_mode = "absolute"
self._frequency_axis_mode = str(restored_mode)
if str(restored_reference_mode) not in _FREQUENCY_REFERENCE_MODES:
restored_reference_mode = "run"
self._frequency_reference_mode = str(restored_reference_mode)
common_reference = state.get("frequency_common_reference_mhz")
if common_reference is not None:
try:
self._frequency_common_reference_mhz = float(common_reference)
except (TypeError, ValueError):
self._frequency_common_reference_mhz = None
self._sync_frequency_axis_controls()
self._apply_axis_labels(
*self._axis_labels_for_dataset(dataset, self._current_polarization_axis)
)
# Keep bunch factor at default in restored projects; control is hidden.
self._bunch_factor.blockSignals(True)
self._bunch_factor.setValue(1)
self._bunch_factor.blockSignals(False)
# Restore axis limit fields (will be applied after optional re-plot).
for spin, key, default in (
(self._x_min, "x_min", 0.0),
(self._x_max, "x_max", 10.0),
(self._y_min, "y_min", -30.0),
(self._y_max, "y_max", 30.0),
):
spin.blockSignals(True)
spin.setValue(state.get(key, default))
spin.blockSignals(False)
# Treat restored limits as user-defined so later dataset additions do
# not overwrite them with auto-derived bounds — a restored project view
# is explicit user intent, held across content switches like a manual
# edit (see _limits_user_locked).
self._limits_initialized = True
self._limits_user_locked = True
fit_x_min = state.get("fit_x_min")
fit_x_max = state.get("fit_x_max")
if fit_x_min is not None and fit_x_max is not None:
self._fit_x_min = float(fit_x_min)
self._fit_x_max = float(fit_x_max)
# Restore fit curves.
self._fit_curve = None
self._fit_curve_run_number = None
self._fit_curves = {}
self._fit_curves_by_key = {}
self._fit_components = None
self._fit_components_by_run = {}
self._fit_components_by_key = {}
fit_curve_data = state.get("fit_curve")
if fit_curve_data:
self._fit_curve = (
np.array(fit_curve_data["t"]),
np.array(fit_curve_data["y"]),
fit_curve_data.get("label", "Fit"),
)
fit_curve_run_number = state.get("fit_curve_run_number")
if fit_curve_run_number is not None:
try:
self._fit_curve_run_number = int(fit_curve_run_number)
except (TypeError, ValueError):
self._fit_curve_run_number = None
for run_str, curve_data in state.get("fit_curves", {}).items():
self._fit_curves[int(run_str)] = (
np.array(curve_data["t"]),
np.array(curve_data["y"]),
curve_data.get("label", "Global Fit"),
)
raw_fit_curves_by_key = state.get("fit_curves_by_key", {})
if isinstance(raw_fit_curves_by_key, dict):
for storage_key, curve_data in raw_fit_curves_by_key.items():
decoded = self._decode_fit_storage_key(storage_key)
if decoded is None or not isinstance(curve_data, dict):
continue
self._fit_curves_by_key[decoded] = (
np.array(curve_data.get("t", [])),
np.array(curve_data.get("y", [])),
curve_data.get("label", "Global Fit"),
)
for (run_number, _axis_key), curve_data in self._fit_curves_by_key.items():
self._fit_curves.setdefault(run_number, curve_data)
else:
for run_number, curve_data in self._fit_curves.items():
self._fit_curves_by_key[(run_number, None)] = curve_data
fit_components = state.get("fit_components")
if isinstance(fit_components, list):
self._fit_components = [
(entry.get("name", "Component"), np.array(entry.get("y", []), dtype=float))
for entry in fit_components
if isinstance(entry, dict)
]
for run_str, entries in state.get("fit_components_by_run", {}).items():
if not isinstance(entries, list):
continue
self._fit_components_by_run[int(run_str)] = [
(entry.get("name", "Component"), np.array(entry.get("y", []), dtype=float))
for entry in entries
if isinstance(entry, dict)
]
raw_fit_components_by_key = state.get("fit_components_by_key", {})
if isinstance(raw_fit_components_by_key, dict):
for storage_key, entries in raw_fit_components_by_key.items():
decoded = self._decode_fit_storage_key(storage_key)
if decoded is None or not isinstance(entries, list):
continue
self._fit_components_by_key[decoded] = [
(entry.get("name", "Component"), np.array(entry.get("y", []), dtype=float))
for entry in entries
if isinstance(entry, dict)
]
for (run_number, _axis_key), curves in self._fit_components_by_key.items():
self._fit_components_by_run.setdefault(run_number, list(curves))
else:
for run_number, curves in self._fit_components_by_run.items():
self._fit_components_by_key[(run_number, None)] = list(curves)
self._default_annotations = self._deserialize_annotations(state.get("annotations", []))
raw_annotations_by_group = state.get("annotations_by_group", {})
self._annotations_by_group = {}
if isinstance(raw_annotations_by_group, dict):
for group_id, payload in raw_annotations_by_group.items():
self._annotations_by_group[str(group_id)] = self._deserialize_annotations(payload)
self._annotations = self._default_annotations
self._active_annotation_idx = None
self._annotation_drag_started = False
# Restore fit metadata.
self._fit_metadata = {}
self._fit_metadata_by_key = {}
raw_fit_metadata = state.get("fit_metadata", {})
if isinstance(raw_fit_metadata, dict):
for rn_str, meta in raw_fit_metadata.items():
if isinstance(meta, dict):
try:
self._fit_metadata[int(rn_str)] = meta
except (TypeError, ValueError):
pass
raw_fit_metadata_by_key = state.get("fit_metadata_by_key", {})
if isinstance(raw_fit_metadata_by_key, dict):
for storage_key, meta in raw_fit_metadata_by_key.items():
decoded = self._decode_fit_storage_key(storage_key)
if decoded is None or not isinstance(meta, dict):
continue
self._fit_metadata_by_key[decoded] = meta
for (run_number, _axis_key), meta in self._fit_metadata_by_key.items():
self._fit_metadata.setdefault(run_number, meta)
else:
for run_number, meta in self._fit_metadata.items():
self._fit_metadata_by_key[(run_number, None)] = meta
self._update_export_enabled()
# Re-plot the current dataset if one was provided.
if dataset is not None:
self._current_dataset = dataset
self.plot_dataset(dataset)
# Draw the fit-range span first: on an axis that was not (re)plotted
# with data, drawing artists triggers a Matplotlib autoscale whose
# margins would otherwise feed back through the axis-limit callback and
# overwrite the restored view limits. Doing it before the limit re-apply
# keeps the restored window authoritative.
if fit_x_min is not None and fit_x_max is not None:
self._set_fit_range(
float(fit_x_min),
float(fit_x_max),
emit_signal=False,
redraw=True,
)
# Re-apply saved axis limits last, after any redraw/autoscale, so the
# restored view wins over data-derived or autoscale-margin defaults.
for spin, key, default in (
(self._x_min, "x_min", 0.0),
(self._x_max, "x_max", 10.0),
(self._y_min, "y_min", -30.0),
(self._y_max, "y_max", 30.0),
):
spin.blockSignals(True)
spin.setValue(state.get(key, default))
spin.blockSignals(False)
# Always apply the restored limits.
self._apply_limits()