mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 08:21:27 +01:00
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:
12 files changed
+2049
-5
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user