diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 1a60668d..ae17455a 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -26,6 +26,7 @@ class Widgets(str, enum.Enum): DeviceBrowser = "DeviceBrowser" DeviceComboBox = "DeviceComboBox" DeviceLineEdit = "DeviceLineEdit" + LMFitDialog = "LMFitDialog" PositionerBox = "PositionerBox" PositionerControlLine = "PositionerControlLine" ResetButton = "ResetButton" @@ -1825,15 +1826,6 @@ class BECWaveform(RPCBase): x_entry(str): Entry of the x signal. """ - @rpc_call - def get_dap_params(self) -> "dict": - """ - Get the DAP parameters of all DAP curves. - - Returns: - dict: DAP parameters of all DAP curves. - """ - @rpc_call def remove_curve(self, *identifiers): """ @@ -2096,10 +2088,10 @@ class BECWaveformWidget(RPCBase): self, x_name: "str", y_name: "str", + dap: "str", x_entry: "str | None" = None, y_entry: "str | None" = None, color: "str | None" = None, - dap: "str" = "GaussianModel", validate_bec: "bool" = True, **kwargs, ) -> "BECCurve": @@ -2120,15 +2112,6 @@ class BECWaveformWidget(RPCBase): BECCurve: The curve object. """ - @rpc_call - def get_dap_params(self) -> "dict": - """ - Get the DAP parameters of all DAP curves. - - Returns: - dict: DAP parameters of all DAP curves. - """ - @rpc_call def remove_curve(self, *identifiers): """ @@ -2313,7 +2296,7 @@ class BECWaveformWidget(RPCBase): class DarkModeButton(RPCBase): @rpc_call - def toggle_dark_mode(self) -> None: + def toggle_dark_mode(self) -> "None": """ Toggle the dark mode state. This will change the theme of the entire application to dark or light mode. @@ -2392,6 +2375,24 @@ class DeviceLineEdit(RPCBase): """ +class LMFitDialog(RPCBase): + @property + @rpc_call + def _config_dict(self) -> "dict": + """ + Get the configuration of the widget. + + Returns: + dict: The configuration of the widget. + """ + + @rpc_call + def _get_all_rpc(self) -> "dict": + """ + Get all registered RPC objects. + """ + + class PositionerBox(RPCBase): @rpc_call def set_positioner(self, positioner: "str | Positioner"): diff --git a/bec_widgets/widgets/figure/plots/waveform/waveform.py b/bec_widgets/widgets/figure/plots/waveform/waveform.py index 6825ad74..20589f49 100644 --- a/bec_widgets/widgets/figure/plots/waveform/waveform.py +++ b/bec_widgets/widgets/figure/plots/waveform/waveform.py @@ -50,7 +50,6 @@ class BECWaveform(BECPlotBase): "plot", "add_dap", "set_x", - "get_dap_params", "remove_curve", "scan_history", "curves", @@ -74,8 +73,8 @@ class BECWaveform(BECPlotBase): ] scan_signal_update = pyqtSignal() async_signal_update = pyqtSignal() - dap_params_update = pyqtSignal(dict) - dap_summary_update = pyqtSignal(dict) + dap_params_update = pyqtSignal(dict, dict) + dap_summary_update = pyqtSignal(dict, dict) autorange_signal = pyqtSignal() new_scan = pyqtSignal() @@ -1085,8 +1084,9 @@ class BECWaveform(BECPlotBase): curve.setData(x, y) curve.dap_params = msg["data"][1]["fit_parameters"] curve.dap_summary = msg["data"][1]["fit_summary"] - self.dap_params_update.emit(curve.dap_params) - self.dap_summary_update.emit(curve.dap_summary) + metadata.update({"curve_id": curve_id_request}) + self.dap_params_update.emit(curve.dap_params, metadata) + self.dap_summary_update.emit(curve.dap_summary, metadata) break @Slot(dict, dict) diff --git a/bec_widgets/widgets/lmfit_dialog/__init__.py b/bec_widgets/widgets/lmfit_dialog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/lmfit_dialog/lm_fit_dialog.pyproject b/bec_widgets/widgets/lmfit_dialog/lm_fit_dialog.pyproject new file mode 100644 index 00000000..181f4f8d --- /dev/null +++ b/bec_widgets/widgets/lmfit_dialog/lm_fit_dialog.pyproject @@ -0,0 +1 @@ +{'files': ['lmfit_dialog.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/lmfit_dialog/lm_fit_dialog_plugin.py b/bec_widgets/widgets/lmfit_dialog/lm_fit_dialog_plugin.py new file mode 100644 index 00000000..90b712b5 --- /dev/null +++ b/bec_widgets/widgets/lmfit_dialog/lm_fit_dialog_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.lmfit_dialog.lmfit_dialog import LMFitDialog + +DOM_XML = """ + + + + +""" + + +class LMFitDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = LMFitDialog(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(LMFitDialog.ICON_NAME) + + def includeFile(self): + return "lm_fit_dialog" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "LMFitDialog" + + def toolTip(self): + return "LMFitDialog" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/lmfit_dialog/lmfit_dialog.py b/bec_widgets/widgets/lmfit_dialog/lmfit_dialog.py new file mode 100644 index 00000000..c30aee17 --- /dev/null +++ b/bec_widgets/widgets/lmfit_dialog/lmfit_dialog.py @@ -0,0 +1,185 @@ +import os + +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from qtpy.QtCore import Property, Signal, Slot +from qtpy.QtWidgets import QTreeWidgetItem, QVBoxLayout, QWidget + +from bec_widgets.utils import UILoader +from bec_widgets.utils.bec_widget import BECWidget + +logger = bec_logger.logger + + +class LMFitDialog(BECWidget, QWidget): + + ICON_NAME = "bike_lane" + selected_fit = Signal(str) + + def __init__( + self, + parent=None, + client=None, + config=None, + target_widget=None, + gui_id: str | None = None, + ui_file="lmfit_dialog_vertical.ui", + ): + super().__init__(client=client, config=config, gui_id=gui_id) + QWidget.__init__(self, parent=parent) + self._ui_file = ui_file + self.target_widget = target_widget + + current_path = os.path.dirname(__file__) + self.ui = UILoader(self).loader(os.path.join(current_path, self._ui_file)) + self.layout = QVBoxLayout(self) + self.layout.addWidget(self.ui) + self.summary_data = {} + self._fit_curve_id = None + self._deci_precision = 3 + self.ui.curve_list.currentItemChanged.connect(self.display_fit_details) + self.setLayout(self.layout) + + @Property(bool) + def hide_curve_selection(self): + """Property for showing the curve selection.""" + return not self.ui.group_curve_selection.isVisible() + + @hide_curve_selection.setter + def hide_curve_selection(self, show: bool): + """Setter for showing the curve selection. + + Args: + show (bool): Whether to show the curve selection. + """ + self.ui.group_curve_selection.setVisible(not show) + + @property + def fit_curve_id(self): + """Property for the currently displayed fit curve_id.""" + return self._fit_curve_id + + @fit_curve_id.setter + def fit_curve_id(self, curve_id: str): + """Setter for the currently displayed fit curve_id. + + Args: + fit_curve_id (str): The curve_id of the fit curve to be displayed. + """ + self._fit_curve_id = curve_id + self.selected_fit.emit(curve_id) + + @Slot(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) + self.refresh_curve_list() + + @Slot(str) + def select_curve(self, curve_id: str): + """Select active curve_id in the curve list. + + Args: + curve_id (str): curve_id to be selected. + """ + self.fit_curve_id = curve_id + + @Slot(dict, dict) + def update_summary_tree(self, data: dict, metadata: dict): + """Update the summary tree with the given data. + + Args: + data (dict): Data for the DAP Summary. + metadata (dict): Metadata of the fit curve. + """ + curve_id = metadata.get("curve_id", "") + self.summary_data.update({curve_id: data}) + self.refresh_curve_list() + if self.fit_curve_id is None: + self.fit_curve_id = curve_id + for index in range(self.ui.curve_list.count()): + item = self.ui.curve_list.item(index) + if item.text() == curve_id: + self.ui.curve_list.setCurrentItem(item) + if curve_id != self.fit_curve_id: + return + if data is None: + return + self.ui.summary_tree.clear() + properties = [ + ("Model", data.get("model", "")), + ("Method", data.get("method", "")), + ("Chi-Squared", f"{data.get('chisqr', 0.0):.{self._deci_precision}f}"), + ("Reduced Chi-Squared", f"{data.get('redchi', 0.0):.{self._deci_precision}f}"), + ("R-Squared", f"{data.get('rsquared', 0.0):.{self._deci_precision}f}"), + ("Message", data.get("message", "")), + ] + for prop, val in properties: + QTreeWidgetItem(self.ui.summary_tree, [prop, val]) + self.update_param_tree(data.get("params", [])) + + def _update_summary_data(self, curve_id: str, data: dict): + """Update the summary data with the given data. + + Args: + curve_id (str): The curve_id of the fit curve. + data (dict): The data to be updated. + """ + self.summary_data.update({curve_id: data}) + if self.fit_curve_id is not None: + return + self.fit_curve_id = curve_id + + def update_param_tree(self, params): + """Update the parameter tree with the given parameters. + + Args: + params (list): List of LMFit parameters for the fit curve. + """ + self.ui.param_tree.clear() + for param in params: + param_name, param_value, param_std = ( + param[0], + f"{param[1]:.{self._deci_precision}f}", + f"{param[7]:.{self._deci_precision}f}", + ) + QTreeWidgetItem(self.ui.param_tree, [param_name, param_value, param_std]) + + def populate_curve_list(self): + """Populate the curve list with the available fit curves.""" + for curve_name in self.summary_data.keys(): + self.ui.curve_list.addItem(curve_name) + + def refresh_curve_list(self): + """Refresh the curve list with the updated data.""" + self.ui.curve_list.clear() + self.populate_curve_list() + + def display_fit_details(self, current): + """Callback for displaying the fit details of the selected curve. + + Args: + current: The current item in the curve list. + """ + if current: + curve_name = current.text() + self.fit_curve_id = curve_name + data = self.summary_data[curve_name] + if data is None: + return + self.update_summary_tree(data, {"curve_id": curve_name}) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = LMFitDialog() + dialog.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/lmfit_dialog/lmfit_dialog_compact.ui b/bec_widgets/widgets/lmfit_dialog/lmfit_dialog_compact.ui new file mode 100644 index 00000000..086d0cf6 --- /dev/null +++ b/bec_widgets/widgets/lmfit_dialog/lmfit_dialog_compact.ui @@ -0,0 +1,120 @@ + + + Form + + + + 0 + 0 + 655 + 520 + + + + Form + + + + + + + 1 + 0 + + + + QFrame::Shape::VLine + + + QFrame::Shadow::Plain + + + 1 + + + Qt::Orientation::Horizontal + + + true + + + true + + + + Select Curve + + + + + + + + + + + 2 + 0 + + + + Qt::Orientation::Vertical + + + + Fit Summary + + + + + + false + + + + Property + + + + + Value + + + + + + + + + Parameter Details + + + + + + + Parameter + + + + + Value + + + + + Std + + + + + + + + + + + + + + diff --git a/bec_widgets/widgets/lmfit_dialog/lmfit_dialog_vertical.ui b/bec_widgets/widgets/lmfit_dialog/lmfit_dialog_vertical.ui new file mode 100644 index 00000000..616e5d56 --- /dev/null +++ b/bec_widgets/widgets/lmfit_dialog/lmfit_dialog_vertical.ui @@ -0,0 +1,147 @@ + + + Form + + + + 0 + 0 + 274 + 568 + + + + + 0 + 0 + + + + Form + + + + + + Select Curve + + + + + + + + + + + + + 0 + 0 + + + + + 250 + 200 + + + + Fit Summary + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + false + + + 80 + + + + Property + + + + + Value + + + + + + + + + + + + 0 + 0 + + + + + 250 + 200 + + + + Parameter Details + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + 80 + + + + Parameter + + + + + Value + + + + + Std + + + + + + + + + + + + diff --git a/bec_widgets/widgets/lmfit_dialog/register_lm_fit_dialog.py b/bec_widgets/widgets/lmfit_dialog/register_lm_fit_dialog.py new file mode 100644 index 00000000..73f9c3aa --- /dev/null +++ b/bec_widgets/widgets/lmfit_dialog/register_lm_fit_dialog.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.lmfit_dialog.lm_fit_dialog_plugin import LMFitDialogPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(LMFitDialogPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/waveform/waveform_popups/dap_summary_dialog/dap_summary.ui b/bec_widgets/widgets/waveform/waveform_popups/dap_summary_dialog/dap_summary.ui deleted file mode 100644 index 49160460..00000000 --- a/bec_widgets/widgets/waveform/waveform_popups/dap_summary_dialog/dap_summary.ui +++ /dev/null @@ -1,127 +0,0 @@ - - - Form - - - - 0 - 0 - 800 - 600 - - - - Form - - - - - - - 1 - 0 - - - - QFrame::Shape::VLine - - - QFrame::Shadow::Plain - - - 1 - - - Qt::Orientation::Horizontal - - - true - - - true - - - - Select Curve - - - - - - Refresh DAP Summary - - - - - - - - - - - - 2 - 0 - - - - Qt::Orientation::Vertical - - - - Fit Summary - - - - - - false - - - - Property - - - - - Value - - - - - - - - - Parameter Details - - - - - - - Parameter - - - - - Value - - - - - Std - - - - - - - - - - - - - - diff --git a/bec_widgets/widgets/waveform/waveform_popups/dap_summary_dialog/dap_summary_dialog.py b/bec_widgets/widgets/waveform/waveform_popups/dap_summary_dialog/dap_summary_dialog.py index 00512e05..54dd48d1 100644 --- a/bec_widgets/widgets/waveform/waveform_popups/dap_summary_dialog/dap_summary_dialog.py +++ b/bec_widgets/widgets/waveform/waveform_popups/dap_summary_dialog/dap_summary_dialog.py @@ -4,66 +4,26 @@ from qtpy.QtWidgets import QDialog, QTreeWidgetItem, QVBoxLayout from bec_widgets.qt_utils.error_popups import SafeSlot as Slot from bec_widgets.utils import UILoader +from bec_widgets.widgets.lmfit_dialog.lmfit_dialog import LMFitDialog class FitSummaryWidget(QDialog): + def __init__(self, parent=None, target_widget=None): super().__init__(parent=parent) - self.target_widget = target_widget - self.summary_data = self.target_widget.get_dap_summary() - self.setModal(True) - - current_path = os.path.dirname(__file__) - self.ui = UILoader(self).loader(os.path.join(current_path, "dap_summary.ui")) + self.target_widget = target_widget + self.dap_dialog = LMFitDialog(parent=self, ui_file="lmfit_dialog_compact.ui") self.layout = QVBoxLayout(self) - self.layout.addWidget(self.ui) + self.layout.addWidget(self.dap_dialog) + self.target_widget.dap_summary_update.connect(self.dap_dialog.update_summary_tree) + self.setLayout(self.layout) + self._get_dap_from_target_widget() - self.ui.curve_list.currentItemChanged.connect(self.display_fit_details) - self.ui.refresh_button.clicked.connect(self.refresh_dap) - - self.populate_curve_list() - - def populate_curve_list(self): - for curve_name in self.summary_data.keys(): - self.ui.curve_list.addItem(curve_name) - - def display_fit_details(self, current): - if current: - curve_name = current.text() - data = self.summary_data[curve_name] - if data is None: - return - self.refresh_trees(data) - - @Slot() - def refresh_dap(self): - self.ui.curve_list.clear() - self.summary_data = self.target_widget.get_dap_summary() - self.populate_curve_list() - - def refresh_trees(self, data): - self.update_summary_tree(data) - self.update_param_tree(data["params"]) - - def update_summary_tree(self, data): - self.ui.summary_tree.clear() - properties = [ - ("Model", data.get("model", "")), - ("Method", data.get("method", "")), - ("Chi-Squared", str(data.get("chisqr", ""))), - ("Reduced Chi-Squared", str(data.get("redchi", ""))), - ("AIC", str(data.get("aic", ""))), - ("BIC", str(data.get("bic", ""))), - ("R-Squared", str(data.get("rsquared", ""))), - ("Message", data.get("message", "")), - ] - for prop, val in properties: - QTreeWidgetItem(self.ui.summary_tree, [prop, val]) - - def update_param_tree(self, params): - self.ui.param_tree.clear() - for param in params: - param_name, param_value, param_std = param[0], str(param[1]), str(param[7]) - QTreeWidgetItem(self.ui.param_tree, [param_name, param_value, param_std]) + def _get_dap_from_target_widget(self) -> None: + """Get the DAP data from the target widget and update the DAP dialog manually on creation.""" + dap_summary = self.target_widget.get_dap_summary() + for curve_id, data in dap_summary.items(): + md = {"curve_id": curve_id} + self.dap_dialog.update_summary_tree(data=data, metadata=md) diff --git a/bec_widgets/widgets/waveform/waveform_widget.py b/bec_widgets/widgets/waveform/waveform_widget.py index 2f265494..c7c4cd5d 100644 --- a/bec_widgets/widgets/waveform/waveform_widget.py +++ b/bec_widgets/widgets/waveform/waveform_widget.py @@ -33,7 +33,6 @@ class BECWaveformWidget(BECWidget, QWidget): "curves", "plot", "add_dap", - "get_dap_params", "remove_curve", "scan_history", "get_all_data", @@ -55,8 +54,8 @@ class BECWaveformWidget(BECWidget, QWidget): ] scan_signal_update = Signal() async_signal_update = Signal() - dap_params_update = Signal(dict) - dap_summary_update = Signal(dict) + dap_summary_update = Signal(dict, dict) + dap_params_update = Signal(dict, dict) autorange_signal = Signal() new_scan = Signal() crosshair_position_changed = Signal(tuple) @@ -218,7 +217,7 @@ class BECWaveformWidget(BECWidget, QWidget): def show_fit_summary_dialog(self): dialog = FitSummaryWidget(target_widget=self) dialog.resize(800, 600) - dialog.show() + dialog.exec() ################################### # User Access Methods from Waveform @@ -257,7 +256,7 @@ class BECWaveformWidget(BECWidget, QWidget): """ self.waveform.set_colormap(colormap) - @SafeSlot(popup_error=True) + @SafeSlot(str, popup_error=True) def set_x(self, x_name: str, x_entry: str | None = None): """ Change the x axis of the plot widget. @@ -272,7 +271,7 @@ class BECWaveformWidget(BECWidget, QWidget): """ self.waveform.set_x(x_name, x_entry) - @SafeSlot(popup_error=True) + @SafeSlot(str, popup_error=True) def plot( self, arg1: list | np.ndarray | str | None = None, @@ -331,15 +330,16 @@ class BECWaveformWidget(BECWidget, QWidget): **kwargs, ) - @SafeSlot(popup_error=True) + @SafeSlot(str, str, str, popup_error=True) def add_dap( self, x_name: str, y_name: str, + dap: str, x_entry: str | None = None, y_entry: str | None = None, color: str | None = None, - dap: str = "GaussianModel", + # dap: str = "GaussianModel", validate_bec: bool = True, **kwargs, ) -> BECCurve: diff --git a/docs/assets/widget_screenshots/lmfit_dialog.png b/docs/assets/widget_screenshots/lmfit_dialog.png new file mode 100644 index 00000000..86b6203a Binary files /dev/null and b/docs/assets/widget_screenshots/lmfit_dialog.png differ diff --git a/docs/assets/widget_screenshots/lmfit_dialog_connect.png b/docs/assets/widget_screenshots/lmfit_dialog_connect.png new file mode 100644 index 00000000..d2efa904 Binary files /dev/null and b/docs/assets/widget_screenshots/lmfit_dialog_connect.png differ diff --git a/docs/user/widgets/lmfit_dialog/lmfit_dialog.md b/docs/user/widgets/lmfit_dialog/lmfit_dialog.md new file mode 100644 index 00000000..29141d66 --- /dev/null +++ b/docs/user/widgets/lmfit_dialog/lmfit_dialog.md @@ -0,0 +1,50 @@ +(user.widgets.lmfit_dialog)= + +# LMFit Dialog + +````{tab} Overview + +The [`LMFit Dialog`](/api_reference/_autosummary/bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog) is a widget that is developed to be used togther with the [`BECWaveformWidget`](/api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget). The `BECWaveformWidget` allows user to submit a fit request to BEC's [DAP server](https://bec.readthedocs.io/en/latest/developer/getting_started/architecture.html) choosing from a selection of [LMFit models](https://lmfit.github.io/lmfit-py/builtin_models.html#) to fit monitored data sources. The `LMFit Dialog` provides an interface to monitor these fits, including statistics and fit parameters in real time. +Within the `BECWaveformWidget`, the dialog is accessible via the toolbar and will be automatically linked to the current waveform widget. For a more customised use, we can embed the `LMFit Dialog` in a larger GUI using the *BEC Designer*. In this case, one has to connect the [`update_summary_tree`](/api_reference/_autosummary/bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog.rst#bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog.update_summary_tree) slot of the LMFit Dialog to the [`dap_summary_update`](/api_reference/_autosummary/bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.rst#bec_widgets.widgets.waveform.waveform_widget.BECWaveformWidget.dap_summary_update) signal of the BECWaveformWidget to ensure its functionality. + + +## Key Features: +- **Fit Summary**: Display updates on LMFit DAP processes and fit statistics. +- **Fit Parameter**: Display current fit parameter. +- **BECWaveformWidget Integration**: Directly connect to BECWaveformWidget to display fit statistics and parameters. +```{figure} /assets/widget_screenshots/lmfit_dialog.png +--- +name: lmfit_dialog +--- +LMFit Dialog +``` +```` +````{tab} Connect in BEC Designer +The `LMFit Dialog` widget can be connected to a `BECWaveformWidget` to display fit statistics and parameters from the LMFit DAP process hooked up to the waveform widget. You can use the signal/slot editor from the BEC Designer to connect the `dap_summary_update` signal of the BECWaveformWidget to the `update_summary_tree` slot of the LMFit Dialog. + +```{figure} /assets/widget_screenshots/lmfit_dialog_connect.png +```` +````{tab} Connect in Python +It is also possible to directly connect the `dap_summary_update` signal of the BECWaveformWidget to the `update_summary_tree` slot of the LMFit Dialog in Python. + +```python +waveform = BECWaveformWidget(...) +lmfit_dialog = LMFitDialog(...) +waveform.dap_summary_update.connect(lmfit_dialog.update_summary_tree) + +``` +```` +````{tab} API +```{eval-rst} +.. include:: /api_reference/_autosummary/bec_widgets.cli.client.LMFitDialog.rst +``` +```` + + + + + + + + + diff --git a/docs/user/widgets/widgets.md b/docs/user/widgets/widgets.md index ce46b49d..86d353e0 100644 --- a/docs/user/widgets/widgets.md +++ b/docs/user/widgets/widgets.md @@ -190,6 +190,14 @@ Display spinner widget for loading or device movement. Display position of motor withing its limits. ``` + +```{grid-item-card} LMFit Dialog +:link: user.widgets.lmfit_dialog +:link-type: ref +:img-top: /assets/widget_screenshots/lmfit_dialog.png + +Display DAP summaries of LMFit models in a window. +``` ```` ```{toctree} @@ -216,5 +224,6 @@ toggle/toggle.md spinner/spinner.md device_input/device_input.md position_indicator/position_indicator.md +lmfit_dialog/lmfit_dialog.md ``` \ No newline at end of file diff --git a/tests/unit_tests/test_lmfit_dialog.py b/tests/unit_tests/test_lmfit_dialog.py new file mode 100644 index 00000000..f016d353 --- /dev/null +++ b/tests/unit_tests/test_lmfit_dialog.py @@ -0,0 +1,183 @@ +from unittest import mock + +import numpy as np +import pytest + +from bec_widgets.widgets.lmfit_dialog.lmfit_dialog import LMFitDialog + +from .client_mocks import mocked_client +from .conftest import create_widget + + +@pytest.fixture(scope="function") +def lmfit_dialog(qtbot, mocked_client): + """Fixture for LMFitDialog widget""" + db = create_widget(qtbot, LMFitDialog, client=mocked_client) + yield db + + +@pytest.fixture(scope="function") +def lmfit_message(): + """Fixture for lmfit summary message""" + yield { + "model": "Model(breit_wigner)", + "method": "leastsq", + "ndata": 4, + "nvarys": 4, + "nfree": 0, + "chisqr": 1.2583132407517716, + "redchi": 1.2583132407517716, + "aic": 3.3739110606840716, + "bic": 0.9190885051636339, + "rsquared": 0.9650468544235619, + "nfev": 2498, + "max_nfev": 10000, + "aborted": False, + "errorbars": True, + "success": True, + "message": "Fit succeeded.", + "lmdif_message": "Both actual and predicted relative reductions in the sum of squares\n are at most 0.000000", + "ier": 1, + "nan_policy": "raise", + "scale_covar": True, + "calc_covar": True, + "ci_out": None, + "col_deriv": False, + "flatchain": None, + "call_kws": { + "Dfun": None, + "full_output": 1, + "col_deriv": 0, + "ftol": 1.5e-08, + "xtol": 1.5e-08, + "gtol": 0.0, + "maxfev": 20000, + "epsfcn": 1e-10, + "factor": 100, + "diag": None, + }, + "var_names": ["amplitude", "center", "sigma", "q"], + "user_options": None, + "kws": {}, + "init_values": {"amplitude": 1.0, "center": 0.0, "sigma": 1.0, "q": 1.0}, + "best_values": { + "amplitude": 1.5824142042890903, + "center": -2.8415356591834326, + "sigma": 0.0002550847234503717, + "q": -259.8514775889427, + }, + "params": [ + [ + "amplitude", + 1.5824142042890903, + True, + None, + -np.inf, + np.inf, + None, + 1.3249185295752495, + { + "center": 0.8429146627203449, + "sigma": -0.8362891947010586, + "q": -0.8362890089256452, + }, + 1.0, + None, + ], + [ + "center", + -2.8415356591834326, + True, + None, + -np.inf, + np.inf, + None, + 0.5077201488266584, + { + "amplitude": 0.8429146627203449, + "sigma": -0.9987662050702767, + "q": -0.9987662962832818, + }, + 0.0, + None, + ], + [ + "sigma", + 0.0002550847234503717, + True, + None, + 0.0, + np.inf, + None, + 113.2533064536711, + { + "amplitude": -0.8362891947010586, + "center": -0.9987662050702767, + "q": 0.999999999997876, + }, + 1.0, + None, + ], + [ + "q", + -259.8514775889427, + True, + None, + -np.inf, + np.inf, + None, + 114893884.64553572, + { + "amplitude": -0.8362890089256452, + "center": -0.9987662962832818, + "sigma": 0.999999999997876, + }, + 1.0, + None, + ], + ], + } + + +def test_fit_curve_id(lmfit_dialog): + """Test hide_curve_selection property""" + my_callback = mock.MagicMock() + lmfit_dialog.selected_fit.connect(my_callback) + assert lmfit_dialog.fit_curve_id is None + lmfit_dialog.fit_curve_id = "test_curve_id" + assert lmfit_dialog.fit_curve_id == "test_curve_id" + assert my_callback.call_count == 1 + assert my_callback.call_args == mock.call("test_curve_id") + + +def test_remove_dap_data(lmfit_dialog): + """Test remove_dap_data method""" + lmfit_dialog.summary_data = {"test": "data", "test2": "data2"} + lmfit_dialog.refresh_curve_list() + # Only 2 items + assert lmfit_dialog.ui.curve_list.count() == 2 + lmfit_dialog.remove_dap_data("test") + assert lmfit_dialog.summary_data == {"test2": "data2"} + assert lmfit_dialog.ui.curve_list.count() == 1 + # Test removing non-existing data + # Nothing should happen + lmfit_dialog.remove_dap_data("test_not_there") + assert lmfit_dialog.summary_data == {"test2": "data2"} + assert lmfit_dialog.ui.curve_list.count() == 1 + + +def test_update_summary_tree(lmfit_dialog, lmfit_message): + """Test display_fit_details method""" + lmfit_dialog.update_summary_tree(data=lmfit_message, metadata={"curve_id": "test_curve_id"}) + # Check if the data is updated + assert lmfit_dialog.summary_data == {"test_curve_id": lmfit_message} + # Check if the curve list is updated + assert lmfit_dialog.ui.curve_list.count() == 1 + # Check summary tree is updated + assert lmfit_dialog.ui.summary_tree.topLevelItemCount() == 6 + assert lmfit_dialog.ui.summary_tree.topLevelItem(0).text(0) == "Model" + assert lmfit_dialog.ui.summary_tree.topLevelItem(0).text(1) == "Model(breit_wigner)" + # Check fit params tree is updated + assert lmfit_dialog.ui.param_tree.topLevelItemCount() == 4 + assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(0) == "amplitude" + assert lmfit_dialog.ui.param_tree.topLevelItem(0).text(1) == "1.582" diff --git a/tests/unit_tests/test_waveform_widget.py b/tests/unit_tests/test_waveform_widget.py index abe31e8a..1446e015 100644 --- a/tests/unit_tests/test_waveform_widget.py +++ b/tests/unit_tests/test_waveform_widget.py @@ -9,9 +9,13 @@ from bec_widgets.qt_utils.settings_dialog import SettingsDialog from bec_widgets.utils.colors import apply_theme, get_theme_palette, set_theme from bec_widgets.widgets.figure.plots.axis_settings import AxisSettings from bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog import CurveSettings +from bec_widgets.widgets.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog import ( + FitSummaryWidget, +) from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget from .client_mocks import mocked_client +from .conftest import create_widget @pytest.fixture @@ -97,7 +101,7 @@ def test_waveform_plot_scan_curves(waveform_widget, mock_waveform): def test_waveform_widget_add_dap(waveform_widget, mock_waveform): - waveform_widget.add_dap(x_name="samx", y_name="bpm4i") + waveform_widget.add_dap(x_name="samx", y_name="bpm4i", dap="GaussianModel") waveform_widget.waveform.add_dap.assert_called_once_with( x_name="samx", y_name="bpm4i", @@ -252,7 +256,7 @@ def test_toolbar_fit_params_action_triggered(qtbot, waveform_widget): ) as MockFitSummaryWidget: mock_dialog_instance = MockFitSummaryWidget.return_value action.trigger() - mock_dialog_instance.show.assert_called_once() + mock_dialog_instance.exec.assert_called_once() def test_enable_mouse_pan_mode(qtbot, waveform_widget): @@ -378,6 +382,14 @@ def test_curve_dialog_dap(qtbot, waveform_widget): assert len(waveform_widget.curves) == 2 +def test_fit_dialog_summary(qtbot, waveform_widget): + """Test the fit dialog summary widget""" + waveform_widget.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel") + fit_dialog_summary = create_widget(qtbot, FitSummaryWidget, target_widget=waveform_widget) + assert fit_dialog_summary.dap_dialog.fit_curve_id == "bpm4i-bpm4i-GaussianModel" + assert fit_dialog_summary.dap_dialog.ui.curve_list.count() == 1 + + ################################### # Axis Dialog Tests ###################################