feat(lmfit_dialog): add QML and QWidget redesigns of the fit dialog for comparison

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZu5Qs47sgM5yn3njLg6k7
This commit is contained in:
Claude committed 2026-10-09 20:37:02 +00:00
1 parent 703513b175
commit 5cb1adf164
12 files changed
+2049 -5

No files matched your search

+71
View File
@@ -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/<name>?color=<hex>``.
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
@@ -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()
@@ -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()
@@ -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)
@@ -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,
)
@@ -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),
}
@@ -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()
@@ -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()
@@ -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
}
}
}
}
}
}
}
}
}
}
@@ -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"]
@@ -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)
@@ -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