diff --git a/bec_widgets/utils/qml_host.py b/bec_widgets/utils/qml_host.py new file mode 100644 index 00000000..778821a5 --- /dev/null +++ b/bec_widgets/utils/qml_host.py @@ -0,0 +1,71 @@ +"""Helpers to host QML views inside BEC QWidgets (QQuickWidget, theme colours, Material icons).""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qs + +from bec_qthemes import material_icon +from qtpy.QtCore import QSize, QUrl +from qtpy.QtGui import QColor, QPixmap +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QWidget + + +class MaterialIconProvider(QQuickImageProvider): + """Serve bec_qthemes Material icons to QML as ``image://material/?color=``. + + Add ``&filled=1`` for the filled variant. + """ + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap: + """Render the requested icon. + + Args: + icon_id (str): Icon name with optional query parameters. + size (QSize): Out-parameter for the original size (unused). + requested_size (QSize): The size requested by QML. + + Returns: + QPixmap: The rendered icon. + """ + name, _, query = icon_id.partition("?") + options = {key: values[-1] for key, values in parse_qs(query).items()} + width = requested_size.width() if requested_size.width() > 0 else 24 + height = requested_size.height() if requested_size.height() > 0 else width + color = options.get("color") + return material_icon( + name, + size=(width, height), + color=QColor(color) if color else None, + filled=options.get("filled") == "1", + convert_to_pixmap=True, + ) + + +def create_quick_widget( + parent: QWidget, qml_file: str | Path, context: dict[str, object] | None = None +) -> QQuickWidget: + """Create a transparent QQuickWidget that sizes its root item to the widget. + + Args: + parent (QWidget): Parent widget. + qml_file (str | Path): The QML file to load. + context (dict[str, object] | None): Objects exposed to QML as context properties. + + Returns: + QQuickWidget: The loaded view. Check ``status()`` and ``errors()`` for load failures. + """ + view = QQuickWidget(parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setClearColor(QColor(0, 0, 0, 0)) + engine = view.engine() + engine.addImageProvider("material", MaterialIconProvider()) + for name, obj in (context or {}).items(): + view.rootContext().setContextProperty(name, obj) + view.setSource(QUrl.fromLocalFile(str(Path(qml_file).resolve()))) + return view diff --git a/bec_widgets/widgets/dap/lmfit_dialog/compare_fit_dialogs.py b/bec_widgets/widgets/dap/lmfit_dialog/compare_fit_dialogs.py new file mode 100644 index 00000000..18a52650 --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/compare_fit_dialogs.py @@ -0,0 +1,217 @@ +"""Show the current, QWidget and QML fit dialogs side by side with the same simulated fits. + +Run with ``python -m bec_widgets.widgets.dap.lmfit_dialog.compare_fit_dialogs [--light] [--live]``. +""" + +from __future__ import annotations + +import math +import sys + +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QVBoxLayout, QWidget + +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog +from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog_qml import LMFitDialogQml +from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog_qwidget import LMFitDialogQWidget + + +def sample_fit(curve_id: str, step: int = 0) -> dict: + """Return a DAP-like LMFit summary for the demo curves. + + Args: + curve_id (str): One of the demo curve names. + step (int): Update counter, used to jitter the values like a live fit. + + Returns: + dict: A fit summary in the LMFit DAP format. + """ + jitter = 1 + 0.02 * math.sin(step) + if curve_id == "bpm4i-gauss": + center = -0.0412 * jitter + return { + "model": "Model(gaussian)", + "method": "leastsq", + "ndata": 101, + "nvarys": 3, + "nfev": 31, + "chisqr": 0.0421 * jitter, + "redchi": 0.000429 * jitter, + "rsquared": 0.9987, + "success": True, + "message": "Fit succeeded.", + "params": [ + [ + "amplitude", + 12.84 * jitter, + True, + None, + -math.inf, + math.inf, + None, + 0.071, + {}, + 1.0, + None, + ], + ["center", center, True, None, -math.inf, math.inf, None, 0.0009, {}, 0.0, None], + [ + "sigma", + 0.2471, + True, + None, + 0, + math.inf, + None, + 0.0016, + {"amplitude": 0.58}, + 1.0, + None, + ], + [ + "fwhm", + 0.5819, + False, + "2.3548200*sigma", + -math.inf, + math.inf, + None, + 0.0038, + {}, + None, + None, + ], + ], + } + if curve_id == "diode-breit_wigner": + return { + "model": "Model(breit_wigner)", + "method": "leastsq", + "ndata": 4, + "nvarys": 4, + "nfev": 2498, + "chisqr": 1.2583, + "redchi": 1.2583, + "rsquared": 0.8650 + 0.05 * math.sin(step), + "success": True, + "message": "Fit succeeded.", + "params": [ + [ + "amplitude", + 1.5824, + True, + None, + -math.inf, + math.inf, + None, + 1.3249, + {"center": 0.843}, + 1.0, + None, + ], + [ + "center", + -2.8415 * jitter, + True, + None, + -math.inf, + math.inf, + None, + 0.5077, + {"sigma": -0.9988}, + 0.0, + None, + ], + [ + "sigma", + 0.000255, + True, + None, + 0.0, + math.inf, + None, + 113.25, + {"q": 0.99999}, + 1.0, + None, + ], + [ + "q", + -259.85, + True, + None, + -math.inf, + math.inf, + None, + 114893884.6, + {"sigma": 0.99999}, + 1.0, + None, + ], + ], + } + return { + "model": "Model(lorentzian) + Model(linear)", + "method": "leastsq", + "ndata": 41, + "nvarys": 5, + "nfev": 10000, + "chisqr": 88.1, + "redchi": 2.448, + "rsquared": 0.41, + "success": False, + "message": "Fit aborted: number of function evaluations > 10000", + "params": [ + ["amplitude", 3.1, True, None, -math.inf, math.inf, None, None, {}, 1.0, None], + ["center", 0.88, True, None, -math.inf, math.inf, None, None, {}, 0.0, None], + ["slope", 0.0, False, None, -math.inf, math.inf, None, None, {}, 0.0, None], + ], + } + + +CURVES = ["bpm4i-gauss", "diode-breit_wigner", "temp-lorentz"] + + +def main(): # pragma: no cover - interactive demo + """Open the comparison window.""" + app = QApplication(sys.argv) + apply_theme("light" if "--light" in sys.argv else "dark") + window = QWidget() + window.setWindowTitle("Fit dialog: current vs QWidget vs QML") + layout = QHBoxLayout(window) + dialogs = [] + for title, cls in ( + ("Current", LMFitDialog), + ("QWidget redesign", LMFitDialogQWidget), + ("QML redesign", LMFitDialogQml), + ): + box = QGroupBox(title) + box_layout = QVBoxLayout(box) + dialog = cls(parent=box) + dialog.active_action_list = ["center"] + dialog.move_action.connect(lambda action, t=title: print(f"{t}: move {action}")) + box_layout.addWidget(dialog) + layout.addWidget(box) + dialogs.append(dialog) + + step = {"n": 0} + + def tick(): + step["n"] += 1 + for curve_id in CURVES: + for dialog in dialogs: + dialog.update_summary_tree(sample_fit(curve_id, step["n"]), {"curve_id": curve_id}) + + tick() + timer = QTimer(window) + timer.timeout.connect(tick) + if "--live" in sys.argv: + timer.start(500) + window.resize(1250, 520) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/dap/lmfit_dialog/fit_dialog_base.py b/bec_widgets/widgets/dap/lmfit_dialog/fit_dialog_base.py new file mode 100644 index 00000000..58cad0a1 --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/fit_dialog_base.py @@ -0,0 +1,271 @@ +"""Shared state and public API for the redesigned LMFit dialogs (QML and QWidget views).""" + +from __future__ import annotations + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QApplication, QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.dap.lmfit_dialog.fit_summary import FitSummary + + +class MoveActionHandle: + """Button-like handle for a parameter's move action. + + It keeps the legacy ``action_buttons[name].isEnabled()`` / ``.click()`` contract working for + views that do not create one QPushButton per parameter. + """ + + def __init__(self, dialog: FitDialogBase, param_name: str): + self._dialog = dialog + self.param_name = param_name + + def isEnabled(self) -> bool: # pylint: disable=invalid-name + """Whether the move action can be triggered.""" + return self._dialog.enable_actions + + def click(self): + """Trigger the move action, as a click on the button would.""" + if self.isEnabled(): + self._dialog.request_move(self.param_name) + + +class FitDialogBase(BECWidget, QWidget): + """Base for fit dialogs: keeps the LMFitDialog public API and delegates drawing to a view. + + Subclasses implement ``_render()`` (redraw everything from ``self``) and may override + ``_render_actions()`` for the cheaper case where only the move-button state changed. + """ + + PLUGIN = False + RPC = False + ICON_NAME = "monitoring" + # Signal to emit the currently selected fit curve_id + selected_fit = Signal(str) + # Signal to emit a move action in form of a tuple (param_name, value) + move_action = Signal(tuple) + + def __init__( + self, + parent=None, + client=None, + config=None, + target_widget=None, + gui_id: str | None = None, + ui_file: str | None = None, + compact: bool | None = None, + **kwargs, + ): + """ + Args: + parent (QWidget): The parent widget. + client: BEC client object. + config: Configuration of the widget. + target_widget: The widget that the settings will be taken from and applied to. + gui_id (str): GUI ID. + ui_file (str): Accepted for compatibility with LMFitDialog; a name containing + "compact" selects the compact layout. + compact (bool): Use the side-by-side layout for short embedded panels. + """ + super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) + self.setProperty("skip_settings", True) + self.target_widget = target_widget + if compact is None: + compact = bool(ui_file and "compact" in ui_file) + self.compact = compact + self.summary_data: dict[str, dict | None] = {} + self._fit_curve_id: str | None = None + self._active_actions: list[str] = [] + self._enable_actions = True + self._always_show_latest = False + self._hide_curve_selection = False + self._hide_summary = False + self._hide_parameters = False + + ################################################################################ + # Public API (same as LMFitDialog) + + @property + def enable_actions(self) -> bool: + """Whether the move buttons are enabled.""" + return self._enable_actions + + @enable_actions.setter + def enable_actions(self, enable: bool): + self._enable_actions = bool(enable) + self._render_actions() + + @SafeProperty(list) + def active_action_list(self) -> list[str]: + """Names of the fit parameters that get a move button.""" + return self._active_actions + + @active_action_list.setter + def active_action_list(self, actions: list[str]): + self._active_actions = list(actions or []) + self._render() + + @SafeProperty(bool) + def always_show_latest(self) -> bool: + """Whether the dialog follows the most recently updated fit.""" + return self._always_show_latest + + @always_show_latest.setter + def always_show_latest(self, show: bool): + self._always_show_latest = show + + @SafeProperty(bool) + def hide_curve_selection(self) -> bool: + """Whether the curve selector is hidden.""" + return self._hide_curve_selection + + @hide_curve_selection.setter + def hide_curve_selection(self, hide: bool): + self._hide_curve_selection = hide + self._render() + + @SafeProperty(bool) + def hide_summary(self) -> bool: + """Whether the fit summary card is hidden.""" + return self._hide_summary + + @hide_summary.setter + def hide_summary(self, hide: bool): + self._hide_summary = hide + self._render() + + @SafeProperty(bool) + def hide_parameters(self) -> bool: + """Whether the parameter table is hidden.""" + return self._hide_parameters + + @hide_parameters.setter + def hide_parameters(self, hide: bool): + self._hide_parameters = hide + self._render() + + @property + def fit_curve_id(self) -> str | None: + """The curve_id of the displayed fit.""" + return self._fit_curve_id + + @fit_curve_id.setter + def fit_curve_id(self, curve_id: str | None): + changed = curve_id != self._fit_curve_id + self._fit_curve_id = curve_id + if curve_id is not None: + self.selected_fit.emit(curve_id) + if changed: + self._render() + + @property + def action_buttons(self) -> dict[str, MoveActionHandle]: + """Move actions of the displayed fit, keyed by parameter name.""" + summary = self.current_summary + if summary is None: + return {} + names = {p.name for p in summary.params} + return { + name: MoveActionHandle(self, name) for name in self._active_actions if name in names + } + + @SafeSlot(str) + def remove_dap_data(self, curve_id: str): + """Remove the DAP data for the given curve_id. + + Args: + curve_id (str): The curve_id of the DAP data to be removed. + """ + self.summary_data.pop(curve_id, None) + if self._fit_curve_id == curve_id: + remaining = list(self.summary_data) + if remaining: + self.fit_curve_id = remaining[0] + return + self._fit_curve_id = None + self._render() + + @SafeSlot(str) + def select_curve(self, curve_id: str): + """Select the curve whose fit is displayed. + + Args: + curve_id (str): curve_id to be selected. + """ + self.fit_curve_id = curve_id + + @SafeSlot(dict, dict) + def update_summary_tree(self, data: dict, metadata: dict): + """Store a new fit summary and show it if its curve is selected. + + Args: + data (dict): Data for the DAP Summary. + metadata (dict): Metadata of the fit curve. + """ + curve_id = metadata.get("curve_id", "") + self.summary_data[curve_id] = data + if self._fit_curve_id is None or self._always_show_latest: + self._fit_curve_id = curve_id + self.selected_fit.emit(curve_id) + self._render() + + def refresh_curve_list(self): + """Redraw the curve selector from ``summary_data``.""" + self._render() + + ################################################################################ + # Helpers for views + + @property + def curve_ids(self) -> list[str]: + """The curves with fit data, in arrival order.""" + return list(self.summary_data) + + @property + def current_summary(self) -> FitSummary | None: + """The parsed summary of the selected curve, or None.""" + if self._fit_curve_id is None or self._fit_curve_id not in self.summary_data: + return None + return FitSummary.from_dap(self._fit_curve_id, self.summary_data[self._fit_curve_id]) + + def curve_quality(self, curve_id: str) -> str: + """Quality verdict for a curve's fit, used to colour the curve selector.""" + return FitSummary.from_dap(curve_id, self.summary_data.get(curve_id)).quality + + def show_curve_selection(self) -> bool: + """Show the selector only when it helps: not hidden and more than one fit.""" + return not self._hide_curve_selection and len(self.summary_data) > 1 + + def request_move(self, param_name: str): + """Emit move_action for a parameter of the displayed fit. + + Args: + param_name (str): The parameter to move to. + """ + if not self._enable_actions: + return + summary = self.current_summary + if summary is None: + return + for param in summary.params: + if param.name == param_name: + self.move_action.emit((param.name, param.value)) + return + + @staticmethod + def copy_to_clipboard(text: str): + """Put text on the clipboard. + + Args: + text (str): The text to copy. + """ + clipboard = QApplication.clipboard() + if clipboard is not None: + clipboard.setText(text) + + def _render(self): # pragma: no cover - implemented by views + raise NotImplementedError + + def _render_actions(self): + self._render() diff --git a/bec_widgets/widgets/dap/lmfit_dialog/fit_dialog_factory.py b/bec_widgets/widgets/dap/lmfit_dialog/fit_dialog_factory.py new file mode 100644 index 00000000..f2e4906c --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/fit_dialog_factory.py @@ -0,0 +1,35 @@ +"""Pick which fit dialog implementation the waveform uses (for comparing the redesigns).""" + +from __future__ import annotations + +import os + +from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog + +FIT_DIALOG_ENV = "BEC_FIT_DIALOG" + + +def fit_dialog_class() -> type: + """Return the fit dialog class selected by the ``BEC_FIT_DIALOG`` environment variable. + + ``qml`` selects the QML redesign, ``qwidget`` the QWidget redesign; anything else keeps the + current LMFitDialog. + + Returns: + type: The dialog class. + """ + choice = os.environ.get(FIT_DIALOG_ENV, "").strip().lower() + if choice == "qml": + from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog_qml import LMFitDialogQml + + return LMFitDialogQml + if choice == "qwidget": + from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog_qwidget import LMFitDialogQWidget + + return LMFitDialogQWidget + return LMFitDialog + + +def create_fit_dialog(*args, **kwargs): + """Create the fit dialog selected by ``BEC_FIT_DIALOG`` with LMFitDialog's arguments.""" + return fit_dialog_class()(*args, **kwargs) diff --git a/bec_widgets/widgets/dap/lmfit_dialog/fit_summary.py b/bec_widgets/widgets/dap/lmfit_dialog/fit_summary.py new file mode 100644 index 00000000..a703602c --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/fit_summary.py @@ -0,0 +1,312 @@ +"""Qt-free view model for LMFit DAP summaries, shared by the QML and QWidget fit dialogs.""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass, field +from typing import Any, Literal + +FitQuality = Literal["good", "fair", "poor", "failed", "unknown"] + +QUALITY_LABELS: dict[str, str] = { + "good": "Good fit", + "fair": "Fair fit", + "poor": "Poor fit", + "failed": "Fit failed", + "unknown": "No R²", +} + +# A parameter whose standard error is larger than its value is effectively unconstrained. +LOOSE_RELATIVE_ERROR = 1.0 +STRONG_CORRELATION = 0.9 + + +def format_number(value: Any, digits: int = 4) -> str: + """Format a number with significant digits, switching to scientific notation for extremes. + + Args: + value (Any): The value to format. + digits (int): Number of significant digits. + + Returns: + str: The formatted number, or an em dash when the value is missing or not numeric. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "—" + if math.isnan(value): + return "—" + if math.isinf(value): + return "∞" if value > 0 else "−∞" + if value == 0: + return "0" + magnitude = abs(value) + if magnitude < 1e-3 or magnitude >= 1e5: + mantissa, exponent = f"{value:.{digits - 1}e}".split("e") + mantissa = mantissa.rstrip("0").rstrip(".") + return f"{mantissa}e{int(exponent)}".replace("-", "−") + text = f"{value:.{digits}g}" + if "e" in text: # pragma: no cover - guarded by the magnitude check above + text = f"{value:f}" + return text.replace("-", "−") + + +def short_model_name(model: str) -> str: + """Turn lmfit's 'Model(breit_wigner)' into 'breit_wigner'; composite models keep their form. + + Args: + model (str): The model repr reported by lmfit. + + Returns: + str: A short, readable model name. + """ + if not model: + return "Fit" + return re.sub(r"Model\(([^)]*)\)", r"\1", model) + + +@dataclass +class FitParam: + """One fitted parameter, ready for display.""" + + name: str + value: Any + stderr: Any = None + vary: bool = True + minimum: Any = None + maximum: Any = None + initial: Any = None + expr: str | None = None + correlations: dict[str, float] = field(default_factory=dict) + + @property + def value_text(self) -> str: + """The formatted value.""" + return format_number(self.value) + + @property + def stderr_text(self) -> str: + """The formatted standard error, empty for fixed or derived parameters.""" + if not self.vary or self.expr: + return "" + return format_number(self.stderr) + + @property + def relative_error(self) -> float | None: + """The standard error relative to the value, or None if it cannot be computed.""" + if not isinstance(self.stderr, (int, float)) or not isinstance(self.value, (int, float)): + return None + if self.value == 0 or math.isnan(self.stderr) or math.isnan(self.value): + return None + return abs(self.stderr / self.value) + + @property + def relative_error_text(self) -> str: + """The relative error as a percentage.""" + rel = self.relative_error + if rel is None or not self.vary: + return "" + if rel >= 10: + return ">999%" + return f"{rel * 100:.0f}%" if rel >= 0.01 else "<1%" + + @property + def state(self) -> Literal["ok", "loose", "fixed", "derived", "unknown"]: + """How well the parameter is determined.""" + if self.expr: + return "derived" + if not self.vary: + return "fixed" + if not isinstance(self.stderr, (int, float)) or math.isnan(self.stderr): + return "unknown" + rel = self.relative_error + return "loose" if rel is not None and rel > LOOSE_RELATIVE_ERROR else "ok" + + @property + def strong_correlations(self) -> list[tuple[str, float]]: + """Correlations with other parameters above the strong threshold, strongest first.""" + strong = [ + (name, r) + for name, r in (self.correlations or {}).items() + if isinstance(r, (int, float)) and abs(r) >= STRONG_CORRELATION + ] + return sorted(strong, key=lambda item: -abs(item[1])) + + @property + def tooltip(self) -> str: + """A plain-text explanation of the parameter: bounds, start value and correlations.""" + lines = [self.name] + if self.expr: + lines.append(f"Derived from: {self.expr}") + elif not self.vary: + lines.append("Fixed during the fit") + if self.state == "loose": + lines.append("Poorly constrained: the error is larger than the value") + bounds_known = self.minimum is not None or self.maximum is not None + if bounds_known and not ( + _is_inf(self.minimum, negative=True) and _is_inf(self.maximum, negative=False) + ): + lines.append(f"Bounds: {format_number(self.minimum)} … {format_number(self.maximum)}") + if isinstance(self.initial, (int, float)): + lines.append(f"Initial value: {format_number(self.initial)}") + strong = self.strong_correlations + if strong: + pairs = ", ".join(f"{name} ({format_number(r, 3)})" for name, r in strong) + lines.append(f"Strongly correlated with {pairs}") + return "\n".join(lines) + + @classmethod + def from_lmfit(cls, raw: Any) -> FitParam: + """Build a parameter from lmfit's serialized form. + + lmfit serializes a parameter as + [name, value, vary, expr, min, max, brute_step, stderr, correl, init_value, user_data]. + A mapping with the same keys is accepted as well. + + Args: + raw (Any): The serialized parameter. + + Returns: + FitParam: The parsed parameter. + """ + if isinstance(raw, dict): + return cls( + name=str(raw.get("name", "")), + value=raw.get("value"), + stderr=raw.get("stderr"), + vary=bool(raw.get("vary", True)), + minimum=raw.get("min"), + maximum=raw.get("max"), + initial=raw.get("init_value"), + expr=raw.get("expr"), + correlations=raw.get("correl") or {}, + ) + if not isinstance(raw, (list, tuple)) or not raw: + raise TypeError(f"Unsupported parameter format: {raw!r}") + raw = list(raw) + [None] * (11 - len(raw)) + return cls( + name=str(raw[0]), + value=raw[1], + vary=True if raw[2] is None else bool(raw[2]), + expr=raw[3] or None, + minimum=raw[4], + maximum=raw[5], + stderr=raw[7], + correlations=raw[8] or {}, + initial=raw[9], + ) + + +def _is_inf(value: Any, negative: bool) -> bool: + if not isinstance(value, (int, float)) or not math.isinf(value): + return value is None + return (value < 0) == negative + + +@dataclass +class FitSummary: + """A fit result reduced to what the dialog shows.""" + + curve_id: str + model: str = "" + method: str = "" + success: bool | None = None + message: str = "" + chisqr: Any = None + redchi: Any = None + rsquared: Any = None + ndata: Any = None + nvarys: Any = None + nfev: Any = None + params: list[FitParam] = field(default_factory=list) + + @property + def model_name(self) -> str: + """Short model name for the header.""" + return short_model_name(self.model) + + @property + def quality(self) -> FitQuality: + """A coarse verdict on the fit, driven by lmfit's success flag and R².""" + if self.success is False: + return "failed" + if not isinstance(self.rsquared, (int, float)) or math.isnan(self.rsquared): + return "unknown" + if self.rsquared >= 0.95: + return "good" + if self.rsquared >= 0.8: + return "fair" + return "poor" + + @property + def quality_label(self) -> str: + """Readable label for the quality verdict.""" + return QUALITY_LABELS[self.quality] + + @property + def details_text(self) -> str: + """One line under the model name: method, points and evaluations.""" + parts = [] + if self.method: + parts.append(self.method) + if isinstance(self.ndata, int): + parts.append(f"{self.ndata} points") + if isinstance(self.nvarys, int): + parts.append(f"{self.nvarys} free params") + if isinstance(self.nfev, int): + parts.append(f"{self.nfev} evaluations") + return " · ".join(parts) + + @property + def show_message(self) -> bool: + """Whether lmfit's message is worth showing (it is noise when the fit succeeded).""" + return bool(self.message) and (self.success is False or "succeeded" not in self.message) + + @property + def metrics(self) -> list[tuple[str, str, str]]: + """The headline metrics as (label, value, tooltip).""" + return [ + ("R²", format_number(self.rsquared), "Coefficient of determination (1 is perfect)"), + ("χ²ᵣ", format_number(self.redchi), "Reduced chi-square (≈1 for a good fit)"), + ("χ²", format_number(self.chisqr), "Chi-square"), + ] + + @property + def loose_params(self) -> list[str]: + """Names of parameters whose error exceeds their value.""" + return [p.name for p in self.params if p.state == "loose"] + + @classmethod + def from_dap(cls, curve_id: str, data: dict | None) -> FitSummary: + """Build a summary from the DAP 'fit_summary' dictionary. + + Args: + curve_id (str): The curve the fit belongs to. + data (dict | None): The fit summary as published by the LMFit DAP service. + + Returns: + FitSummary: The parsed summary. + """ + if not isinstance(data, dict): + return cls(curve_id=curve_id) + params = [] + for raw in data.get("params") or []: + try: + params.append(FitParam.from_lmfit(raw)) + except (TypeError, IndexError): + continue + success = data.get("success") + return cls( + curve_id=curve_id, + model=str(data.get("model", "") or ""), + method=str(data.get("method", "") or ""), + success=None if success is None else bool(success), + message=str(data.get("message", "") or ""), + chisqr=data.get("chisqr"), + redchi=data.get("redchi"), + rsquared=data.get("rsquared"), + ndata=data.get("ndata"), + nvarys=data.get("nvarys"), + nfev=data.get("nfev"), + params=params, + ) diff --git a/bec_widgets/widgets/dap/lmfit_dialog/fit_theme.py b/bec_widgets/widgets/dap/lmfit_dialog/fit_theme.py new file mode 100644 index 00000000..4b0072ea --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/fit_theme.py @@ -0,0 +1,80 @@ +"""Colour roles for the fit dialogs, derived from the active bec_qthemes theme.""" + +from __future__ import annotations + +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication + +from bec_widgets.utils.colors import get_theme_name + +_FALLBACK = { + "dark": { + "BG": "#0f1115", + "CARD_BG": "#171a21", + "FIELD_BG": "#10131a", + "BORDER": "#2a2f3a", + "FG": "#e8ebf1", + "PRIMARY": "#3b82f6", + "ON_PRIMARY": "#ffffff", + }, + "light": { + "BG": "#f6f7fb", + "CARD_BG": "#ffffff", + "FIELD_BG": "#f0f2f7", + "BORDER": "#d9dde6", + "FG": "#151924", + "PRIMARY": "#0a60ff", + "ON_PRIMARY": "#ffffff", + }, +} +_ACCENTS = {"ACCENT_SUCCESS": "#2CA58D", "ACCENT_WARNING": "#EAC435", "ACCENT_EMERGENCY": "#CC181E"} + + +def _mix(a: QColor, b: QColor, t: float) -> str: + return QColor( + round(a.red() * (1 - t) + b.red() * t), + round(a.green() * (1 - t) + b.green() * t), + round(a.blue() * (1 - t) + b.blue() * t), + ).name() + + +def fit_dialog_palette() -> dict[str, str]: + """Return the dialog's colour roles as hex strings for the current theme. + + Returns: + dict[str, str]: Colour roles shared by the QML and QWidget fit dialogs. + """ + name = get_theme_name() + fallback = _FALLBACK.get(name, _FALLBACK["dark"]) + theme = getattr(QApplication.instance(), "theme", None) + + def color(key: str) -> QColor: + default = fallback.get(key) or _ACCENTS.get(key, "#888888") + if theme is None: + return QColor(default) + return theme.color(key, default) + + bg, card, border, fg = (color(k) for k in ("BG", "CARD_BG", "BORDER", "FG")) + primary = color("PRIMARY") + success, warning, danger = ( + color(k) for k in ("ACCENT_SUCCESS", "ACCENT_WARNING", "ACCENT_EMERGENCY") + ) + return { + "dark": "true" if name == "dark" else "", + "bg": bg.name(), + "card": card.name(), + "field": _mix(card, fg, 0.06), + "border": border.name(), + "fg": fg.name(), + "muted": _mix(fg, card, 0.45), + "primary": primary.name(), + "on_primary": color("ON_PRIMARY").name(), + "selection": _mix(primary, card, 0.8), + "warning": warning.name() if name == "dark" else _mix(warning, fg, 0.35), + "warning_bg": _mix(warning, card, 0.82), + "quality_good": success.name(), + "quality_fair": warning.name() if name == "dark" else _mix(warning, fg, 0.25), + "quality_poor": danger.name(), + "quality_failed": danger.name(), + "quality_unknown": _mix(fg, card, 0.45), + } diff --git a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_qml.py b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_qml.py new file mode 100644 index 00000000..37660153 --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_qml.py @@ -0,0 +1,163 @@ +"""Redesigned LMFit dialog drawn in QML and hosted in a QQuickWidget.""" + +from __future__ import annotations + +from pathlib import Path + +from bec_lib.logger import bec_logger +from qtpy.QtCore import Property, QObject, QUrl, Signal, Slot +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QVBoxLayout + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.qml_host import create_quick_widget +from bec_widgets.widgets.dap.lmfit_dialog.fit_dialog_base import FitDialogBase +from bec_widgets.widgets.dap.lmfit_dialog.fit_summary import QUALITY_LABELS +from bec_widgets.widgets.dap.lmfit_dialog.fit_theme import fit_dialog_palette + +logger = bec_logger.logger + +QML_FILE = Path(__file__).parent / "qml" / "FitDialog.qml" + + +class FitDialogBridge(QObject): + """Exposes the dialog state and actions to QML.""" + + stateChanged = Signal() + paletteChanged = Signal() + + def __init__(self, dialog: LMFitDialogQml): + super().__init__(dialog) + self._dialog = dialog + self._state: dict = {} + self._palette: dict = {} + + @Property("QVariantMap", notify=stateChanged) + def state(self) -> dict: + """Everything the view shows, as plain maps and lists.""" + return self._state + + @Property("QVariantMap", notify=paletteChanged) + def palette(self) -> dict: + """Theme colour roles.""" + return self._palette + + def set_state(self, state: dict): + """Replace the view state and notify QML.""" + self._state = state + self.stateChanged.emit() + + def set_palette(self, palette: dict): + """Replace the colour roles and notify QML.""" + self._palette = palette + self.paletteChanged.emit() + + @Slot(str) + def selectCurve(self, curve_id: str): # pylint: disable=invalid-name + """Select a curve from QML.""" + self._dialog.select_curve(curve_id) + + @Slot(str) + def move(self, param_name: str): + """Request a move from QML.""" + self._dialog.request_move(param_name) + + @Slot(str) + def copy(self, text: str): + """Copy text from QML.""" + self._dialog.copy_to_clipboard(text) + + +class LMFitDialogQml(FitDialogBase): + """Fit summary and parameters of LMFit DAP processes, drawn in QML.""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.bridge = FitDialogBridge(self) + self.bridge.set_palette(fit_dialog_palette()) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.quick = create_quick_widget(self, QML_FILE, {"fit": self.bridge}) + if self.quick.status() == QQuickWidget.Status.Error: + for error in self.quick.errors(): + logger.error(f"FitDialog.qml: {error.toString()}") + layout.addWidget(self.quick) + self._render() + + def _render(self): + if not hasattr(self, "bridge"): + return + self.bridge.set_state(self._build_state()) + + def _build_state(self) -> dict: + palette = self.bridge.palette + summary = self.current_summary + curves = [ + { + "id": cid, + "color": palette.get(f"quality_{quality}", palette.get("muted", "#888")), + "tip": f"{cid}: {QUALITY_LABELS[quality]}", + } + for cid, quality in ((cid, self.curve_quality(cid)) for cid in self.curve_ids) + ] + state = { + "hasData": summary is not None, + "compact": self.compact, + "showCurves": self.show_curve_selection(), + "showSummary": not self.hide_summary, + "showParams": not self.hide_parameters, + "actionsEnabled": self.enable_actions, + "currentCurve": self.fit_curve_id or "", + "curves": curves, + } + if summary is None: + return state + message = summary.message if summary.show_message else "" + if not message and summary.loose_params: + message = f"Poorly constrained: {', '.join(summary.loose_params)}" + state.update( + { + "model": summary.model_name, + "modelFull": summary.model, + "quality": summary.quality, + "qualityLabel": summary.quality_label, + "qualityColor": palette.get(f"quality_{summary.quality}", "#888"), + "details": summary.details_text, + "metrics": [ + {"label": label, "value": value, "tip": tip} + for label, value, tip in summary.metrics + ], + "message": message, + "params": [ + { + "name": p.name, + "value": p.value_text, + "raw": str(p.value), + "error": p.stderr_text, + "rel": p.relative_error_text, + "state": p.state, + "tip": p.tooltip, + "movable": p.name in self.active_action_list, + } + for p in summary.params + ], + } + ) + return state + + @SafeSlot(str) + def apply_theme(self, theme: str): + """Push the theme colours to QML. + + Args: + theme (str): "dark" or "light". + """ + if hasattr(self, "bridge"): + self.bridge.set_palette(fit_dialog_palette()) + self._render() + + def cleanup(self): + """Release the QML scene before the widget goes away.""" + if hasattr(self, "quick"): + self.quick.setSource(QUrl()) + super().cleanup() diff --git a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_qwidget.py b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_qwidget.py new file mode 100644 index 00000000..859b5081 --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog_qwidget.py @@ -0,0 +1,356 @@ +"""Redesigned LMFit dialog built with plain QWidgets (comparison counterpart of the QML view).""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import ( + QButtonGroup, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import get_theme_name +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.dap.lmfit_dialog.fit_dialog_base import FitDialogBase +from bec_widgets.widgets.dap.lmfit_dialog.fit_summary import QUALITY_LABELS, FitSummary +from bec_widgets.widgets.dap.lmfit_dialog.fit_theme import fit_dialog_palette + + +class LMFitDialogQWidget(FitDialogBase): + """Fit summary and parameters of LMFit DAP processes, drawn with QWidgets.""" + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._curve_buttons: dict[str, QToolButton] = {} + self._curve_key: tuple = () + self._move_buttons: dict[str, QPushButton] = {} + self._param_rows: list[tuple[QLabel, QLabel, QLabel, QToolButton]] = [] + self._param_key: tuple | None = None + self._build_ui() + self.apply_theme(get_theme_name()) + self._render() + + ################################################################################ + # Layout + + def _build_ui(self): + self.setObjectName("FitDialog") + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(8) + + self.curve_bar = QWidget(self) + self.curve_bar_layout = QHBoxLayout(self.curve_bar) + self.curve_bar_layout.setContentsMargins(0, 0, 0, 0) + self.curve_bar_layout.setSpacing(4) + self.curve_group = QButtonGroup(self) + self.curve_group.setExclusive(True) + root.addWidget(self.curve_bar) + + self.empty_label = QLabel( + "No fit results yet.\nAdd a fit model to a curve to see its parameters here.", self + ) + self.empty_label.setObjectName("FitEmpty") + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_label.setWordWrap(True) + root.addWidget(self.empty_label, 1) + + self.body = QWidget(self) + body_layout = QHBoxLayout(self.body) if self.compact else QVBoxLayout(self.body) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(8) + root.addWidget(self.body, 1) + + # Summary card + self.summary_card = QFrame(self.body) + self.summary_card.setObjectName("FitCard") + card = QVBoxLayout(self.summary_card) + card.setContentsMargins(12, 10, 12, 10) + card.setSpacing(6) + header = QHBoxLayout() + header.setSpacing(8) + self.model_label = QLabel(self.summary_card) + self.model_label.setObjectName("FitModel") + self.quality_pill = QLabel(self.summary_card) + self.quality_pill.setObjectName("FitPill") + header.addWidget(self.model_label, 1) + header.addWidget(self.quality_pill, 0, Qt.AlignmentFlag.AlignRight) + card.addLayout(header) + self.details_label = QLabel(self.summary_card) + self.details_label.setObjectName("FitMuted") + self.details_label.setWordWrap(True) + card.addWidget(self.details_label) + metrics = QHBoxLayout() + metrics.setSpacing(6) + self.metric_values: list[QLabel] = [] + self.metric_names: list[QLabel] = [] + for _ in range(3): + tile = QFrame(self.summary_card) + tile.setObjectName("FitTile") + tile_layout = QVBoxLayout(tile) + tile_layout.setContentsMargins(8, 4, 8, 4) + tile_layout.setSpacing(0) + name = QLabel(tile) + name.setObjectName("FitMuted") + value = QLabel(tile) + value.setObjectName("FitMetric") + value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + tile_layout.addWidget(name) + tile_layout.addWidget(value) + metrics.addWidget(tile, 1) + self.metric_names.append(name) + self.metric_values.append(value) + card.addLayout(metrics) + self.message_label = QLabel(self.summary_card) + self.message_label.setObjectName("FitWarning") + self.message_label.setWordWrap(True) + card.addWidget(self.message_label) + if self.compact: + card.addStretch(1) + self.summary_card.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding + ) + body_layout.addWidget(self.summary_card, 2 if self.compact else 0) + + # Parameter table + self.param_card = QFrame(self.body) + self.param_card.setObjectName("FitCard") + param_outer = QVBoxLayout(self.param_card) + param_outer.setContentsMargins(0, 6, 0, 6) + param_outer.setSpacing(0) + title = QLabel("Parameters", self.param_card) + title.setObjectName("FitSection") + title.setContentsMargins(12, 0, 12, 4) + param_outer.addWidget(title) + self.param_scroll = QScrollArea(self.param_card) + self.param_scroll.setWidgetResizable(True) + self.param_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.param_scroll.setObjectName("FitScroll") + self.param_host = QWidget() + self.param_host.setObjectName("FitParamHost") + self.param_grid = QGridLayout(self.param_host) + self.param_grid.setContentsMargins(12, 0, 12, 0) + self.param_grid.setHorizontalSpacing(10) + self.param_grid.setVerticalSpacing(2) + self.param_scroll.setWidget(self.param_host) + param_outer.addWidget(self.param_scroll, 1) + body_layout.addWidget(self.param_card, 3 if self.compact else 1) + + ################################################################################ + # Rendering + + def _render(self): + if not hasattr(self, "param_grid"): + return + self._render_curves() + summary = self.current_summary + has_data = summary is not None + self.empty_label.setVisible(not has_data) + self.body.setVisible(has_data) + if not has_data: + return + self.summary_card.setVisible(not self.hide_summary) + self.param_card.setVisible(not self.hide_parameters) + self._render_summary(summary) + self._render_params(summary) + + def _render_curves(self): + self.curve_bar.setVisible(self.show_curve_selection()) + key = tuple((cid, self.curve_quality(cid)) for cid in self.curve_ids) + if key != self._curve_key: + self._curve_key = key + for button in self._curve_buttons.values(): + self.curve_group.removeButton(button) + button.hide() + button.deleteLater() + self._curve_buttons = {} + while self.curve_bar_layout.count(): + self.curve_bar_layout.takeAt(0) + for curve_id, quality in key: + button = QToolButton(self.curve_bar) + button.setObjectName("FitCurveChip") + button.setText(curve_id) + button.setCheckable(True) + button.setAutoRaise(True) + button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + button.setIcon( + material_icon( + "circle", size=(10, 10), color=self._quality_color(quality), filled=True + ) + ) + button.setIconSize(QSize(10, 10)) + button.setToolTip(f"{curve_id}: {QUALITY_LABELS[quality]}") + button.clicked.connect(lambda _=False, cid=curve_id: self.select_curve(cid)) + self.curve_group.addButton(button) + self.curve_bar_layout.addWidget(button) + self._curve_buttons[curve_id] = button + self.curve_bar_layout.addStretch(1) + for curve_id, button in self._curve_buttons.items(): + button.setChecked(curve_id == self.fit_curve_id) + + def _render_summary(self, summary: FitSummary): + self.model_label.setText(summary.model_name) + self.model_label.setToolTip(summary.model) + self.quality_pill.setText(summary.quality_label) + color = self._quality_color(summary.quality) + self.quality_pill.setStyleSheet( + f"color: {color}; border: 1px solid {color}; border-radius: 9px; padding: 1px 8px;" + ) + self.details_label.setText(summary.details_text) + self.details_label.setVisible(bool(summary.details_text)) + for (name, value, tip), name_label, value_label in zip( + summary.metrics, self.metric_names, self.metric_values + ): + name_label.setText(name) + value_label.setText(value) + name_label.parentWidget().setToolTip(tip) + message = summary.message if summary.show_message else "" + loose = summary.loose_params + if not message and loose: + message = f"Poorly constrained: {', '.join(loose)}" + self.message_label.setText(message) + self.message_label.setVisible(bool(message)) + + def _render_params(self, summary: FitSummary): + layout_key = tuple((p.name, p.name in self.active_action_list) for p in summary.params) + if layout_key != self._param_key: + self._param_key = layout_key + self._build_param_rows(layout_key) + for param, (name, value, error, copy) in zip(summary.params, self._param_rows): + for label in (name, value, error): + label.setToolTip(param.tooltip) + value.setText(param.value_text) + copy.setToolTip(f"Copy {param.name} = {param.value}") + if param.state == "fixed": + text, style = "fixed", "FitMuted" + elif param.state == "derived": + text, style = "derived", "FitMuted" + elif param.state == "unknown": + text, style = "no estimate", "FitMuted" + else: + rel = param.relative_error_text + text = f"± {param.stderr_text}" + (f" ({rel})" if rel else "") + style = "FitWarningText" if param.state == "loose" else "FitMuted" + error.setText(text) + if error.objectName() != style: + error.setObjectName(style) + error.style().unpolish(error) + error.style().polish(error) + self._render_actions() + + def _build_param_rows(self, layout_key: tuple): + while self.param_grid.count(): + item = self.param_grid.takeAt(0) + if item.widget() is not None: + item.widget().hide() + item.widget().deleteLater() + self._move_buttons = {} + self._param_rows = [] + for col, text in enumerate(["Name", "Value", "Std error", ""]): + label = QLabel(text, self.param_host) + label.setObjectName("FitMuted") + self.param_grid.addWidget(label, 0, col) + for row, (param_name, movable) in enumerate(layout_key, start=1): + name = QLabel(param_name, self.param_host) + value = QLabel(self.param_host) + value.setObjectName("FitValue") + value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + error = QLabel(self.param_host) + actions = QWidget(self.param_host) + actions_layout = QHBoxLayout(actions) + actions_layout.setContentsMargins(0, 0, 0, 0) + actions_layout.setSpacing(2) + copy = QToolButton(actions) + copy.setAutoRaise(True) + copy.setIcon( + material_icon( + "content_copy", + size=(14, 14), + color=self._palette["muted"], + convert_to_pixmap=False, + ) + ) + copy.clicked.connect(lambda _=False, n=param_name: self._copy_param(n)) + actions_layout.addWidget(copy) + if movable: + move = QPushButton("Move", actions) + move.setObjectName("FitMove") + move.setIcon(material_icon("my_location", size=(14, 14), convert_to_pixmap=False)) + move.clicked.connect(lambda _=False, n=param_name: self.request_move(n)) + actions_layout.addWidget(move) + self._move_buttons[param_name] = move + self.param_grid.addWidget(name, row, 0) + self.param_grid.addWidget(value, row, 1) + self.param_grid.addWidget(error, row, 2) + self.param_grid.addWidget(actions, row, 3, Qt.AlignmentFlag.AlignRight) + self._param_rows.append((name, value, error, copy)) + self.param_grid.setColumnStretch(2, 1) + self.param_grid.setRowStretch(len(layout_key) + 1, 1) + + def _copy_param(self, param_name: str): + summary = self.current_summary + if summary is None: + return + for param in summary.params: + if param.name == param_name: + self.copy_to_clipboard(str(param.value)) + return + + def _render_actions(self): + summary = self.current_summary + for name, button in self._move_buttons.items(): + button.setEnabled(self.enable_actions) + if self.enable_actions and summary is not None: + value = next((p.value_text for p in summary.params if p.name == name), "") + button.setToolTip(f"Move to {name} = {value}") + else: + button.setToolTip("Moving is not available right now") + + ################################################################################ + # Theme + + def _quality_color(self, quality: str) -> str: + return self._palette.get(f"quality_{quality}", self._palette["muted"]) + + @SafeSlot(str) + def apply_theme(self, theme: str): + """Restyle the dialog for the given theme. + + Args: + theme (str): "dark" or "light". + """ + self._palette = fit_dialog_palette() + p = self._palette + self.setStyleSheet(f""" + #FitCard {{ background: {p['card']}; border: 1px solid {p['border']}; + border-radius: 8px; }} + #FitTile {{ background: {p['field']}; border-radius: 6px; }} + #FitModel {{ font-size: 15px; font-weight: 600; }} + #FitSection {{ font-weight: 600; }} + #FitMetric {{ font-size: 15px; font-weight: 600; }} + #FitValue {{ font-weight: 600; }} + #FitMuted, #FitEmpty {{ color: {p['muted']}; }} + #FitWarning {{ color: {p['fg']}; background: {p['warning_bg']}; + border-radius: 6px; padding: 4px 8px; }} + #FitWarningText {{ color: {p['warning']}; }} + #FitScroll, #FitParamHost {{ background: transparent; }} + #FitCurveChip {{ border: 1px solid {p['border']}; border-radius: 12px; + padding: 2px 10px; }} + #FitCurveChip:checked {{ background: {p['selection']}; border-color: {p['primary']}; }} + #FitMove {{ background: {p['primary']}; color: {p['on_primary']}; border: none; + border-radius: 4px; padding: 2px 10px; }} + #FitMove:disabled {{ background: {p['field']}; color: {p['muted']}; }} + """) + if hasattr(self, "param_grid"): + self._curve_key = () + self._param_key = None + self._render() diff --git a/bec_widgets/widgets/dap/lmfit_dialog/qml/FitDialog.qml b/bec_widgets/widgets/dap/lmfit_dialog/qml/FitDialog.qml new file mode 100644 index 00000000..a53da655 --- /dev/null +++ b/bec_widgets/widgets/dap/lmfit_dialog/qml/FitDialog.qml @@ -0,0 +1,350 @@ +// Fit summary and parameters of LMFit DAP processes. +// Data and actions come from the `fit` context object (FitDialogBridge in lmfit_dialog_qml.py). +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: root + + readonly property var s: fit ? fit.state : ({}) + readonly property var p: fit ? fit.palette : ({}) + readonly property bool compact: s.compact === true + + implicitWidth: 360 + implicitHeight: 320 + + component Muted: Label { + color: root.p.muted + font.pixelSize: 11 + } + + component Card: Rectangle { + color: root.p.card + border.color: root.p.border + radius: 8 + } + + component Tip: MouseArea { + property string text: "" + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + ToolTip.visible: containsMouse && text.length > 0 + ToolTip.delay: 500 + ToolTip.text: text + } + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + // Curve selector: one chip per fitted curve, the dot shows the fit quality. + Flow { + Layout.fillWidth: true + visible: root.s.showCurves === true + spacing: 4 + + Repeater { + model: root.s.curves || [] + + delegate: Rectangle { + required property var modelData + readonly property bool selected: modelData.id === root.s.currentCurve + + width: chipRow.implicitWidth + 20 + height: 24 + radius: 12 + color: selected ? root.p.selection : (chipMouse.containsMouse ? root.p.field : "transparent") + border.color: selected ? root.p.primary : root.p.border + Behavior on color { ColorAnimation { duration: 120 } } + + Row { + id: chipRow + anchors.centerIn: parent + spacing: 6 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + color: modelData.color + } + Label { + text: modelData.id + color: root.p.fg + font.pixelSize: 12 + } + } + + MouseArea { + id: chipMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: fit.selectCurve(modelData.id) + ToolTip.visible: containsMouse + ToolTip.delay: 500 + ToolTip.text: modelData.tip + } + } + } + } + + // Empty state + Label { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.s.hasData !== true + text: "No fit results yet.\nAdd a fit model to a curve to see its parameters here." + color: root.p.muted + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + } + + GridLayout { + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.s.hasData === true + columns: root.compact ? 2 : 1 + columnSpacing: 8 + rowSpacing: 8 + + // Summary card: model, verdict, headline metrics and any warning. + Card { + Layout.fillWidth: true + Layout.fillHeight: root.compact + Layout.preferredWidth: root.compact ? 2 : -1 + Layout.preferredHeight: root.compact ? -1 : summaryCol.implicitHeight + 20 + visible: root.s.showSummary === true + + ColumnLayout { + id: summaryCol + anchors.fill: parent + anchors.margins: 10 + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + Layout.fillWidth: true + text: root.s.model || "" + color: root.p.fg + font.pixelSize: 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + Tip { text: root.s.modelFull || "" } + } + Rectangle { + implicitWidth: pillText.implicitWidth + 16 + implicitHeight: 20 + radius: 10 + color: "transparent" + border.color: root.s.qualityColor || root.p.muted + Label { + id: pillText + anchors.centerIn: parent + text: root.s.qualityLabel || "" + color: root.s.qualityColor || root.p.muted + font.pixelSize: 11 + } + } + } + + Muted { + Layout.fillWidth: true + visible: text.length > 0 + text: root.s.details || "" + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Repeater { + model: root.s.metrics || [] + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: metricCol.implicitHeight + 8 + radius: 6 + color: root.p.field + Column { + id: metricCol + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + Muted { text: modelData.label } + Label { + text: modelData.value + color: root.p.fg + font.pixelSize: 15 + font.weight: Font.DemiBold + } + } + Tip { text: modelData.tip } + } + } + } + + Rectangle { + Layout.fillWidth: true + visible: (root.s.message || "").length > 0 + implicitHeight: messageText.implicitHeight + 8 + radius: 6 + color: root.p.warning_bg + Label { + id: messageText + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + verticalAlignment: Text.AlignVCenter + text: root.s.message || "" + color: root.p.fg + wrapMode: Text.WordWrap + } + } + + Item { Layout.fillHeight: true; visible: root.compact } + } + } + + // Parameter table + Card { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: root.compact ? 3 : -1 + Layout.minimumHeight: 80 + visible: root.s.showParams === true + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: 6 + anchors.bottomMargin: 6 + spacing: 2 + + Label { + Layout.leftMargin: 12 + text: "Parameters" + color: root.p.fg + font.weight: Font.DemiBold + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 8 + Muted { text: "Name"; Layout.preferredWidth: 76 } + Muted { text: "Value"; Layout.preferredWidth: 70 } + Muted { text: "Std error"; Layout.fillWidth: true } + } + + ListView { + id: paramList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.s.params || [] + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + id: row + required property var modelData + width: paramList.width + height: 30 + color: rowMouse.containsMouse ? root.p.field : "transparent" + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + ToolTip.visible: containsMouse + ToolTip.delay: 600 + ToolTip.text: modelData.tip + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + + Label { + Layout.preferredWidth: 76 + text: modelData.name + color: root.p.fg + elide: Text.ElideRight + } + Label { + Layout.preferredWidth: 70 + text: modelData.value + color: root.p.fg + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + elide: Text.ElideRight + color: modelData.state === "loose" ? root.p.warning : root.p.muted + text: modelData.state === "fixed" ? "fixed" + : modelData.state === "derived" ? "derived" + : modelData.state === "unknown" ? "no estimate" + : "± " + modelData.error + (modelData.rel ? " (" + modelData.rel + ")" : "") + } + ToolButton { + id: copyButton + implicitWidth: 24 + implicitHeight: 24 + opacity: rowMouse.containsMouse || hovered ? 1 : 0.35 + icon.source: "image://material/content_copy?color=" + root.p.muted + icon.width: 14 + icon.height: 14 + icon.color: "transparent" + onClicked: fit.copy(modelData.raw) + ToolTip.visible: hovered + ToolTip.text: "Copy " + modelData.name + " = " + modelData.raw + background: Rectangle { + radius: 4 + color: copyButton.hovered ? root.p.border : "transparent" + } + } + Button { + id: moveButton + visible: modelData.movable === true + enabled: root.s.actionsEnabled === true + implicitHeight: 24 + leftPadding: 8 + rightPadding: 10 + text: "Move" + icon.source: "image://material/my_location?color=" + + (enabled ? root.p.on_primary : root.p.muted) + icon.width: 14 + icon.height: 14 + icon.color: "transparent" + font.pixelSize: 12 + palette.buttonText: enabled ? root.p.on_primary : root.p.muted + onClicked: fit.move(modelData.name) + ToolTip.visible: hovered + ToolTip.text: enabled ? "Move to " + modelData.name + " = " + modelData.value + : "Moving is not available right now" + background: Rectangle { + radius: 4 + color: !moveButton.enabled ? root.p.field + : moveButton.down ? Qt.darker(root.p.primary, 1.2) + : moveButton.hovered ? Qt.lighter(root.p.primary, 1.1) + : root.p.primary + } + } + } + } + } + } + } + } + } +} diff --git a/bec_widgets/widgets/plots/waveform/utils/alignment_panel.py b/bec_widgets/widgets/plots/waveform/utils/alignment_panel.py index 02ada7b8..a29eca8a 100644 --- a/bec_widgets/widgets/plots/waveform/utils/alignment_panel.py +++ b/bec_widgets/widgets/plots/waveform/utils/alignment_panel.py @@ -17,7 +17,7 @@ from bec_widgets.utils.colors import get_accent_colors, get_theme_name from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import ( PositionerControlLine, ) -from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog +from bec_widgets.widgets.dap.lmfit_dialog.fit_dialog_factory import create_fit_dialog class WaveformAlignmentPanel(QWidget): @@ -36,7 +36,7 @@ class WaveformAlignmentPanel(QWidget): self.positioner = PositionerControlLine(parent=self, client=client, gui_id=gui_id) self.positioner.hide_device_selection = True - self.fit_dialog = LMFitDialog( + self.fit_dialog = create_fit_dialog( parent=self, client=client, gui_id=gui_id, ui_file="lmfit_dialog_compact.ui" ) self.fit_dialog.active_action_list = ["center"] diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index b8b689f2..4b3db4a5 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -40,7 +40,7 @@ from bec_widgets.utils.signal_classification import ( ) from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import MaterialIconAction -from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog +from bec_widgets.widgets.dap.lmfit_dialog.fit_dialog_factory import create_fit_dialog from bec_widgets.widgets.plots.plot_base import PlotBase from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting @@ -630,7 +630,7 @@ class Waveform(PlotBase): """ Add the DAP summary to the side panel. """ - self.dap_summary = LMFitDialog(parent=self) + self.dap_summary = create_fit_dialog(parent=self) self.side_panel.add_menu( action_id="fit_params", icon_name="monitoring", @@ -646,7 +646,7 @@ class Waveform(PlotBase): """ fit_action = self.toolbar.components.get_action("fit_params").action if self.dap_summary_dialog is None or not self.dap_summary_dialog.isVisible(): - self.dap_summary = LMFitDialog(parent=self) + self.dap_summary = create_fit_dialog(parent=self) self.dap_summary_dialog = QDialog(modal=False) self.dap_summary_dialog.layout = QVBoxLayout(self.dap_summary_dialog) self.dap_summary_dialog.layout.addWidget(self.dap_summary) diff --git a/tests/unit_tests/test_lmfit_dialog_redesign.py b/tests/unit_tests/test_lmfit_dialog_redesign.py new file mode 100644 index 00000000..ed0b65b6 --- /dev/null +++ b/tests/unit_tests/test_lmfit_dialog_redesign.py @@ -0,0 +1,189 @@ +import math +from unittest import mock + +import pytest +from qtpy.QtWidgets import QApplication + +from bec_widgets.tests.utils import create_widget +from bec_widgets.widgets.dap.lmfit_dialog.compare_fit_dialogs import sample_fit +from bec_widgets.widgets.dap.lmfit_dialog.fit_dialog_factory import FIT_DIALOG_ENV, fit_dialog_class +from bec_widgets.widgets.dap.lmfit_dialog.fit_summary import FitParam, FitSummary, format_number +from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog +from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog_qml import LMFitDialogQml +from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog_qwidget import LMFitDialogQWidget + +GAUSS = "bpm4i-gauss" +BREIT = "diode-breit_wigner" +FAILED = "temp-lorentz" + + +@pytest.mark.parametrize( + "value, expected", + [ + (1.5824142042890903, "1.582"), + (-2.8415356591834326, "−2.842"), + (0.0002550847234503717, "2.551e−4"), + (114893884.64553572, "1.149e8"), + (0, "0"), + (None, "—"), + (math.inf, "∞"), + (math.nan, "—"), + (True, "—"), + ], +) +def test_format_number(value, expected): + assert format_number(value) == expected + + +def test_fit_summary_quality_and_flags(): + good = FitSummary.from_dap(GAUSS, sample_fit(GAUSS)) + assert good.model_name == "gaussian" + assert good.quality == "good" + assert not good.show_message + assert [p.state for p in good.params] == ["ok", "ok", "ok", "derived"] + + loose = FitSummary.from_dap(BREIT, sample_fit(BREIT)) + assert loose.quality == "fair" + assert loose.loose_params == ["sigma", "q"] + sigma = loose.params[2] + assert sigma.relative_error_text == ">999%" + assert "Strongly correlated with q" in sigma.tooltip + + failed = FitSummary.from_dap(FAILED, sample_fit(FAILED)) + assert failed.quality == "failed" + assert failed.model_name == "lorentzian + linear" + assert failed.show_message + assert [p.state for p in failed.params] == ["unknown", "unknown", "fixed"] + + +def test_fit_summary_tolerates_bad_input(): + assert FitSummary.from_dap("x", None).params == [] + summary = FitSummary.from_dap("x", {"params": [["a", 1.0], "bad", None]}) + assert [p.name for p in summary.params] == ["a"] + assert FitParam.from_lmfit({"name": "b", "value": 0.0, "stderr": 0.1}).state == "ok" + + +@pytest.fixture(params=[LMFitDialogQWidget, LMFitDialogQml], ids=["qwidget", "qml"]) +def dialog(request, qtbot, mocked_client): + yield create_widget(qtbot, request.param, client=mocked_client) + + +def _feed(dialog, *curves): + for curve_id in curves: + dialog.update_summary_tree(sample_fit(curve_id), {"curve_id": curve_id}) + + +def test_first_fit_is_selected_and_emitted(dialog): + callback = mock.MagicMock() + dialog.selected_fit.connect(callback) + assert dialog.current_summary is None + _feed(dialog, GAUSS, BREIT) + assert dialog.fit_curve_id == GAUSS + callback.assert_called_once_with(GAUSS) + assert dialog.curve_ids == [GAUSS, BREIT] + assert dialog.show_curve_selection() + + +def test_select_curve_switches_displayed_fit(dialog): + _feed(dialog, GAUSS, BREIT) + dialog.select_curve(BREIT) + assert dialog.current_summary.model_name == "breit_wigner" + + +def test_always_show_latest_follows_updates(dialog): + dialog.always_show_latest = True + _feed(dialog, GAUSS, BREIT) + assert dialog.fit_curve_id == BREIT + + +def test_remove_dap_data_switches_then_clears(dialog): + callback = mock.MagicMock() + _feed(dialog, GAUSS, BREIT) + dialog.selected_fit.connect(callback) + dialog.remove_dap_data(GAUSS) + assert dialog.fit_curve_id == BREIT + callback.assert_called_once_with(BREIT) + dialog.remove_dap_data("not-there") + assert dialog.curve_ids == [BREIT] + dialog.remove_dap_data(BREIT) + assert dialog.fit_curve_id is None + assert dialog.current_summary is None + + +def test_move_action_and_enable_actions(dialog): + received = [] + dialog.move_action.connect(received.append) + dialog.active_action_list = ["center"] + _feed(dialog, GAUSS) + assert list(dialog.action_buttons) == ["center"] + dialog.action_buttons["center"].click() + assert received == [("center", sample_fit(GAUSS)["params"][1][1])] + + dialog.enable_actions = False + assert dialog.action_buttons["center"].isEnabled() is False + dialog.action_buttons["center"].click() + dialog.request_move("center") + assert len(received) == 1 + + +def test_curve_selection_hidden_for_single_fit_or_by_property(dialog): + _feed(dialog, GAUSS) + assert not dialog.show_curve_selection() + _feed(dialog, BREIT) + dialog.hide_curve_selection = True + assert not dialog.show_curve_selection() + + +def test_copy_to_clipboard(dialog): + dialog.copy_to_clipboard("1.25") + assert QApplication.clipboard().text() == "1.25" + + +def test_qwidget_view_renders_rows_and_buttons(qtbot, mocked_client): + dialog = create_widget(qtbot, LMFitDialogQWidget, client=mocked_client) + dialog.active_action_list = ["center"] + _feed(dialog, GAUSS, FAILED) + assert dialog.body.isVisibleTo(dialog) + assert not dialog.empty_label.isVisibleTo(dialog) + assert dialog.model_label.text() == "gaussian" + assert dialog.quality_pill.text() == "Good fit" + assert set(dialog._curve_buttons) == {GAUSS, FAILED} + assert dialog._curve_buttons[GAUSS].isChecked() + assert dialog._move_buttons["center"].isEnabled() + + dialog._curve_buttons[FAILED].click() + assert dialog.fit_curve_id == FAILED + assert dialog.quality_pill.text() == "Fit failed" + assert "Fit aborted" in dialog.message_label.text() + + dialog.enable_actions = False + assert not dialog._move_buttons["center"].isEnabled() + + +def test_qml_view_loads_and_exposes_state(qtbot, mocked_client): + dialog = create_widget(qtbot, LMFitDialogQml, client=mocked_client) + assert dialog.quick.errors() == [] + assert dialog.bridge.state["hasData"] is False + dialog.active_action_list = ["center"] + _feed(dialog, BREIT, GAUSS) + state = dialog.bridge.state + assert state["model"] == "breit_wigner" + assert state["qualityLabel"] == "Fair fit" + assert state["message"] == "Poorly constrained: sigma, q" + assert [c["id"] for c in state["curves"]] == [BREIT, GAUSS] + assert [p["movable"] for p in state["params"]] == [False, True, False, False] + + received = [] + dialog.move_action.connect(received.append) + dialog.bridge.move("center") + dialog.bridge.selectCurve(GAUSS) + assert received and received[0][0] == "center" + assert dialog.bridge.state["model"] == "gaussian" + + +@pytest.mark.parametrize( + "value, cls", [("", LMFitDialog), ("qml", LMFitDialogQml), ("QWidget", LMFitDialogQWidget)] +) +def test_fit_dialog_factory(monkeypatch, value, cls): + monkeypatch.setenv(FIT_DIALOG_ENV, value) + assert fit_dialog_class() is cls