From c7bbd3435e8da03a657c6c90558e41cdaf1dbc2c Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 18 Oct 2024 19:07:40 +0200 Subject: [PATCH 1/7] Starting work on PyQt6 interactive --- src/iminuit/minuit.py | 23 ++- src/iminuit/qtwidget.py | 336 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 src/iminuit/qtwidget.py diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 63d6deca..9cc4f3f1 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2347,10 +2347,27 @@ def interactive( -------- Minuit.visualize """ - from iminuit.ipywidget import make_widget + is_jupyter = True + try: + from IPython import get_ipython + if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" + and "IPKernelApp" in get_ipython().config): + is_jupyter = True + else: + is_jupyter = False + except NameError: + is_jupyter = False + + if is_jupyter: + from iminuit.ipywidget import make_widget + + plot = self._visualize(plot) + return make_widget(self, plot, kwargs, raise_on_exception) + else: + from iminuit.qtwidget import make_widget - plot = self._visualize(plot) - return make_widget(self, plot, kwargs, raise_on_exception) + plot = self._visualize(plot) + return make_widget(self, plot, kwargs, raise_on_exception) def _free_parameters(self) -> Set[str]: return set(mp.name for mp in self._last_state if not mp.is_fixed) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py new file mode 100644 index 00000000..672c0b38 --- /dev/null +++ b/src/iminuit/qtwidget.py @@ -0,0 +1,336 @@ +"""Interactive fitting widget using PyQt6.""" + +import warnings +import numpy as np +from typing import Dict, Any, Callable +import sys + +with warnings.catch_warnings(): + # ipywidgets produces deprecation warnings through use of internal APIs :( + warnings.simplefilter("ignore") + try: + from PyQt6 import QtCore, QtGui, QtWidgets + from matplotlib.figure import Figure + from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg + from matplotlib import pyplot as plt + except ModuleNotFoundError as e: + e.msg += ( + "\n\nPlease install PyQt6, and matplotlib to enable interactive " + "outside of Jupyter notebooks." + ) + raise + + +def make_widget( + minuit: Any, + plot: Callable[..., None], + kwargs: Dict[str, Any], + raise_on_exception: bool, +): + """Make interactive fitting widget.""" + original_values = minuit.values[:] + original_limits = minuit.limits[:] + + def plot_with_frame(from_fit, report_success): + trans = plt.gca().transAxes + try: + with warnings.catch_warnings(): + minuit.visualize(plot, **kwargs) + except Exception: + if raise_on_exception: + raise + + import traceback + + plt.figtext( + 0, + 0.5, + traceback.format_exc(limit=-1), + fontdict={"family": "monospace", "size": "x-small"}, + va="center", + color="r", + backgroundcolor="w", + wrap=True, + ) + return + + fval = minuit.fmin.fval if from_fit else minuit._fcn(minuit.values) + plt.text( + 0.05, + 1.05, + f"FCN = {fval:.3f}", + transform=trans, + fontsize="x-large", + ) + if from_fit and report_success: + plt.text( + 0.95, + 1.05, + f"{'success' if minuit.valid and minuit.accurate else 'FAILURE'}", + transform=trans, + fontsize="x-large", + ha="right", + ) + + def fit(): + if algo_choice.value == "Migrad": + minuit.migrad() + elif algo_choice.value == "Scipy": + minuit.scipy() + elif algo_choice.value == "Simplex": + minuit.simplex() + return False + else: + assert False # pragma: no cover, should never happen + return True + + def do_fit(change): + report_success = fit() + for i, x in enumerate(parameters): + x.reset(minuit.values[i]) + if change is None: + return report_success + OnParameterChange()({"from_fit": True, "report_success": report_success}) + + def on_update_button_clicked(change): + for x in parameters: + x.slider.continuous_update = not x.slider.continuous_update + + def on_reset_button_clicked(change): + minuit.reset() + minuit.values = original_values + minuit.limits = original_limits + for i, x in enumerate(parameters): + x.reset(minuit.values[i], minuit.limits[i]) + OnParameterChange()() + + def on_parameter_change(value): + pass + + + class FloatSlider(QtWidgets.QSlider): + floatValueChanged = QtCore.pyqtSignal(float) + + def __init__(self, label): + super().__init__(QtCore.Qt.Orientation.Horizontal) + super().setMinimum(0) + super().setMaximum(1000) + super().setValue(500) + self._min = 0.0 + self._max = 1.0 + self._label = label + self.valueChanged.connect(self._emit_float_value_changed) + + def _emit_float_value_changed(self, value): + float_value = self._int_to_float(value) + self._label.setText(str(float_value)) + self.floatValueChanged.emit(float_value) + + def _int_to_float(self, value): + return self._min + (value / 1000) * (self._max - self._min) + + def _float_to_int(self, value): + return int((value - self._min) / (self._max - self._min) * 1000) + + def setMinimum(self, min_value): + self._min = min_value + + def setMaximum(self, max_value): + self._max = max_value + + def setValue(self, value): + super().setValue(self._float_to_int(value)) + + def value(self): + return self._int_to_float(super().value()) + + def setSliderPosition(self, value): + super().setSliderPosition(self._float_to_int(value)) + + + class Parameter(QtWidgets.QGroupBox): + def __init__(self, minuit, par) -> None: + super().__init__(par) + self.par = par + # Set up the Qt Widget + layout = QtWidgets.QGridLayout() + self.setLayout(layout) + # Add line edit to display slider value + self.value_label = QtWidgets.QLabel() + # Add value slider + self.slider = FloatSlider(line_edit=self.value_label) + self.slider.floatValueChanged.connect() + # Add line edit for changing the limits + self.vmin = QtWidgets.QLineEdit() + self.vmin.returnPressed.connect(self.on_limit_changed) + self.vmax = QtWidgets.QLineEdit() + self.vmax.returnPressed.connect(self.on_limit_changed) + # Add buttons + self.fix = QtWidgets.QPushButton("Fix") + self.fix.setCheckable(True) + self.fix.setChecked(minuit.fixed[par]) + self.fix.clicked.connect(self.on_fix_toggled) + self.fit = QtWidgets.QPushButton("Fit") + self.fit.setCheckable(True) + self.fit.setChecked(False) + self.fit.clicked.connect(self.on_fit_toggled) + # Add widgets to the layout + layout.addWidget(self.slider, 0, 0) + layout.addWidget(self.value_label, 0, 1) + layout.addWidget(self.vmin, 1, 0) + layout.addWidget(self.vmax, 1, 1) + layout.addWidget(self.fix, 2, 0) + layout.addWidget(self.fit, 2, 1) + # Add tooltips + self.slider.setToolTip("Parameter Value") + self.value_label.setToolTip("Parameter Value") + self.vmin.setToolTip("Lower Limit") + self.vmax.setToolTip("Upper Limit") + self.fix.setToolTip("Fix Parameter") + self.fit.setToolTip("Fit Parameter") + # Set initial value and limits + val = minuit.values[par] + vmin, vmax = minuit.limits[par] + step = _guess_initial_step(val, vmin, vmax) + vmin2 = vmin if np.isfinite(vmin) else val - 100 * step + vmax2 = vmax if np.isfinite(vmax) else val + 100 * step + self.slider.setMinimum(vmin2) + self.slider.setMaximum(vmax2) + self.slider.setValue(val) + self.value_label.setText(f"{val:.1g}") + self.vmin.setText(f"{vmin2:.1g}") + self.vmax.setText(f"{vmax2:.1g}") + + def on_val_changed(self, val): + self.minuit.values[self.par] = val + self.value_label.setText(f"{val:.1g}") + on_parameter_change() + + def on_limit_changed(self): + vmin = float(self.vmin.text()) + vmax = float(self.vmax.text()) + self.minuit.limits[self.par] = (vmin, vmax) + self.slider.setMinimum(vmin) + self.slider.setMaximum(vmax) + # Update the slider position + current_value = self.slider.value() + if current_value < vmin: + self.slider.setValue(vmin) + self.vmin.setText(f"{vmin:.1g}") + on_parameter_change() + elif current_value > vmax: + self.slider.setValue(vmax) + self.editValue.setText(f"{vmax:.1g}") + on_parameter_change() + else: + self.slider.blockSignals(True) + self.slider.setValue(vmin) + self.slider.setValue(current_value) + self.slider.blockSignals(False) + + def on_fix_toggled(self): + self.minuit.fixed[self.par] = self.fix.isChecked() + if self.fix.isChecked(): + self.fit.setChecked(False) + + def on_fit_toggled(self): + self.slider.setEnabled(not self.fit.isChecked()) + if self.fit.isChecked(): + self.fix.setChecked(False) + on_parameter_change() + + # Set up the main window + main_window = QtWidgets.QMainWindow() + main_window.resize(1600, 1000) + # Set the global font + font = QtGui.QFont() + font.setPointSize(12) + main_window.setFont(font) + # Create the central widget + centralwidget = QtWidgets.QWidget(parent=main_window) + main_window.setCentralWidget(centralwidget) + central_layout = QtWidgets.QVBoxLayout(centralwidget) + # Add tabs for interactive and results + tab = QtWidgets.QTabWidget(parent=centralwidget) + interactive_tab = QtWidgets.QWidget() + tab.addTab(interactive_tab, "") + results_tab = QtWidgets.QWidget() + tab.addTab(results_tab, "") + central_layout.addWidget(tab) + # Interactive tab + interactive_layout = QtWidgets.QGridLayout(interactive_tab) + # Add the plot + plot_group = QtWidgets.QGroupBox("", parent=interactive_tab) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHeightForWidth(plot_group.sizePolicy().hasHeightForWidth()) + plot_group.setSizePolicy(sizePolicy) + plot_layout = QtWidgets.QVBoxLayout(plot_group) + canvas = FigureCanvasQTAgg(Figure()) + ax = canvas.figure.add_subplot(111) + plot_layout.addWidget(canvas) + interactive_layout.addWidget(plot_group, 0, 0, 2, 1) + # Add buttons + button_group = QtWidgets.QGroupBox("", parent=interactive_tab) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed) + sizePolicy.setHeightForWidth(button_group.sizePolicy().hasHeightForWidth()) + button_group.setSizePolicy(sizePolicy) + button_layout = QtWidgets.QHBoxLayout(button_group) + fit_button = QtWidgets.QPushButton(parent=button_group) + fit_button.clicked.connect(do_fit) + button_layout.addWidget(fit_button) + update_button = QtWidgets.QPushButton(parent=button_group) + update_button.clicked.connect(on_update_button_clicked) + button_layout.addWidget(update_button) + reset_button = QtWidgets.QPushButton(parent=button_group) + reset_button.clicked.connect(on_reset_button_clicked) + button_layout.addWidget(reset_button) + algo_choice = QtWidgets.QComboBox(parent=button_group) + algo_choice.setStyleSheet("QComboBox { text-align: center; }") + algo_choice.addItems(["Migrad", "Scipy", "Simplex"]) + button_layout.addWidget(algo_choice) + interactive_layout.addWidget(button_group, 0, 1, 1, 1) + # Add the parameters + parameter_group = QtWidgets.QGroupBox("", parent=interactive_tab) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Expanding) + sizePolicy.setHeightForWidth( + parameter_group.sizePolicy().hasHeightForWidth()) + parameter_group.setSizePolicy(sizePolicy) + parameter_group_layout = QtWidgets.QVBoxLayout(parameter_group) + scroll_area = QtWidgets.QScrollArea(parent=parameter_group) + scroll_area.setWidgetResizable(True) + scroll_area_widget_contents = QtWidgets.QWidget() + scroll_area_widget_contents.setGeometry(QtCore.QRect(0, 0, 751, 830)) + parameter_layout = QtWidgets.QVBoxLayout(scroll_area_widget_contents) + scroll_area.setWidget(scroll_area_widget_contents) + parameter_group_layout.addWidget(scroll_area) + interactive_layout.addWidget(parameter_group, 1, 1, 1, 1) + # Results tab + results_layout = QtWidgets.QVBoxLayout(results_tab) + results_text = QtWidgets.QPlainTextEdit(parent=results_tab) + font = QtGui.QFont() + font.setFamily("FreeMono") + results_text.setFont(font) + results_text.setReadOnly(True) + results_layout.addWidget(results_text) + + parameters = [Parameter(minuit, par) for par in minuit.parameters] + + +def _make_finite(x: float) -> float: + sign = -1 if x < 0 else 1 + if abs(x) == np.inf: + return sign * sys.float_info.max + return x + + +def _guess_initial_step(val: float, vmin: float, vmax: float) -> float: + if np.isfinite(vmin) and np.isfinite(vmax): + return 1e-2 * (vmax - vmin) + return 1e-2 + + +def _round(x: float) -> float: + return float(f"{x:.1g}") From a99f95e88f7f12e2801ab24e182f5085eccd26dd Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Sun, 20 Oct 2024 23:13:02 +0200 Subject: [PATCH 2/7] Working on PyQt6 interactive --- src/iminuit/minuit.py | 2 +- src/iminuit/qtwidget.py | 526 +++++++++++++++++++++++----------------- 2 files changed, 306 insertions(+), 222 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 9cc4f3f1..f685e29d 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2355,7 +2355,7 @@ def interactive( is_jupyter = True else: is_jupyter = False - except NameError: + except Exception: is_jupyter = False if is_jupyter: diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 672c0b38..9f3547c9 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -28,115 +28,43 @@ def make_widget( raise_on_exception: bool, ): """Make interactive fitting widget.""" - original_values = minuit.values[:] - original_limits = minuit.limits[:] - - def plot_with_frame(from_fit, report_success): - trans = plt.gca().transAxes - try: - with warnings.catch_warnings(): - minuit.visualize(plot, **kwargs) - except Exception: - if raise_on_exception: - raise - - import traceback - - plt.figtext( - 0, - 0.5, - traceback.format_exc(limit=-1), - fontdict={"family": "monospace", "size": "x-small"}, - va="center", - color="r", - backgroundcolor="w", - wrap=True, - ) - return - - fval = minuit.fmin.fval if from_fit else minuit._fcn(minuit.values) - plt.text( - 0.05, - 1.05, - f"FCN = {fval:.3f}", - transform=trans, - fontsize="x-large", - ) - if from_fit and report_success: - plt.text( - 0.95, - 1.05, - f"{'success' if minuit.valid and minuit.accurate else 'FAILURE'}", - transform=trans, - fontsize="x-large", - ha="right", - ) - - def fit(): - if algo_choice.value == "Migrad": - minuit.migrad() - elif algo_choice.value == "Scipy": - minuit.scipy() - elif algo_choice.value == "Simplex": - minuit.simplex() - return False - else: - assert False # pragma: no cover, should never happen - return True - - def do_fit(change): - report_success = fit() - for i, x in enumerate(parameters): - x.reset(minuit.values[i]) - if change is None: - return report_success - OnParameterChange()({"from_fit": True, "report_success": report_success}) - - def on_update_button_clicked(change): - for x in parameters: - x.slider.continuous_update = not x.slider.continuous_update - - def on_reset_button_clicked(change): - minuit.reset() - minuit.values = original_values - minuit.limits = original_limits - for i, x in enumerate(parameters): - x.reset(minuit.values[i], minuit.limits[i]) - OnParameterChange()() - - def on_parameter_change(value): - pass class FloatSlider(QtWidgets.QSlider): floatValueChanged = QtCore.pyqtSignal(float) - def __init__(self, label): + def __init__(self): super().__init__(QtCore.Qt.Orientation.Horizontal) super().setMinimum(0) - super().setMaximum(1000) - super().setValue(500) + super().setMaximum(10000) + super().setValue(5000) self._min = 0.0 self._max = 1.0 - self._label = label self.valueChanged.connect(self._emit_float_value_changed) def _emit_float_value_changed(self, value): float_value = self._int_to_float(value) - self._label.setText(str(float_value)) self.floatValueChanged.emit(float_value) def _int_to_float(self, value): - return self._min + (value / 1000) * (self._max - self._min) + return self._min + (value / 10000) * (self._max - self._min) def _float_to_int(self, value): - return int((value - self._min) / (self._max - self._min) * 1000) + return int((value - self._min) / (self._max - self._min) * 10000) def setMinimum(self, min_value): + val = self.value() + if val <= min_value: + val = min_value self._min = min_value + self.setValue(val) def setMaximum(self, max_value): + val = self.value() + if val >= max_value: + val = max_value self._max = max_value + self.setValue(val) def setValue(self, value): super().setValue(self._float_to_int(value)) @@ -144,91 +72,126 @@ def setValue(self, value): def value(self): return self._int_to_float(super().value()) - def setSliderPosition(self, value): - super().setSliderPosition(self._float_to_int(value)) - class Parameter(QtWidgets.QGroupBox): - def __init__(self, minuit, par) -> None: - super().__init__(par) + def __init__(self, minuit, par, callback): + super().__init__("") self.par = par + self.callback = callback + self.minuit = minuit + # Set the size policy of the group box + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + QtWidgets.QSizePolicy.Policy.Fixed) + self.setSizePolicy(sizePolicy) # Set up the Qt Widget - layout = QtWidgets.QGridLayout() + layout = QtWidgets.QVBoxLayout() self.setLayout(layout) - # Add line edit to display slider value - self.value_label = QtWidgets.QLabel() + # Add label + label = QtWidgets.QLabel( + par, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + label.setMinimumSize(QtCore.QSize(50, 0)) + # Add label to display slider value + self.value_label = QtWidgets.QLabel( + alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + self.value_label.setMinimumSize(QtCore.QSize(50, 0)) # Add value slider - self.slider = FloatSlider(line_edit=self.value_label) - self.slider.floatValueChanged.connect() - # Add line edit for changing the limits - self.vmin = QtWidgets.QLineEdit() - self.vmin.returnPressed.connect(self.on_limit_changed) - self.vmax = QtWidgets.QLineEdit() - self.vmax.returnPressed.connect(self.on_limit_changed) + self.slider = FloatSlider() + # Add spin boxes for changing the limits + self.tmin = QtWidgets.QDoubleSpinBox( + alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + self.tmin.setRange(_make_finite(-np.inf), _make_finite(np.inf)) + self.tmax = QtWidgets.QDoubleSpinBox( + alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + self.tmax.setRange(_make_finite(-np.inf), _make_finite(np.inf)) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + QtWidgets.QSizePolicy.Policy.Fixed) + self.tmin.setSizePolicy(sizePolicy) + self.tmax.setSizePolicy(sizePolicy) # Add buttons self.fix = QtWidgets.QPushButton("Fix") self.fix.setCheckable(True) self.fix.setChecked(minuit.fixed[par]) - self.fix.clicked.connect(self.on_fix_toggled) self.fit = QtWidgets.QPushButton("Fit") self.fit.setCheckable(True) self.fit.setChecked(False) - self.fit.clicked.connect(self.on_fit_toggled) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.Fixed, + QtWidgets.QSizePolicy.Policy.Fixed) + self.fix.setSizePolicy(sizePolicy) + self.fit.setSizePolicy(sizePolicy) # Add widgets to the layout - layout.addWidget(self.slider, 0, 0) - layout.addWidget(self.value_label, 0, 1) - layout.addWidget(self.vmin, 1, 0) - layout.addWidget(self.vmax, 1, 1) - layout.addWidget(self.fix, 2, 0) - layout.addWidget(self.fit, 2, 1) + layout1 = QtWidgets.QHBoxLayout() + layout.addLayout(layout1) + layout1.addWidget(label) + layout1.addWidget(self.slider) + layout1.addWidget(self.value_label) + layout1.addWidget(self.fix) + layout2 = QtWidgets.QHBoxLayout() + layout.addLayout(layout2) + layout2.addWidget(self.tmin) + layout2.addWidget(self.tmax) + layout2.addWidget(self.fit) # Add tooltips self.slider.setToolTip("Parameter Value") self.value_label.setToolTip("Parameter Value") - self.vmin.setToolTip("Lower Limit") - self.vmax.setToolTip("Upper Limit") + self.tmin.setToolTip("Lower Limit") + self.tmax.setToolTip("Upper Limit") self.fix.setToolTip("Fix Parameter") self.fit.setToolTip("Fit Parameter") # Set initial value and limits val = minuit.values[par] vmin, vmax = minuit.limits[par] - step = _guess_initial_step(val, vmin, vmax) - vmin2 = vmin if np.isfinite(vmin) else val - 100 * step - vmax2 = vmax if np.isfinite(vmax) else val + 100 * step + self.step = _guess_initial_step(val, vmin, vmax) + vmin2 = vmin if np.isfinite(vmin) else val - 100 * self.step + vmax2 = vmax if np.isfinite(vmax) else val + 100 * self.step + # Set up the spin boxes + self.tmin.setValue(vmin2) + self.tmin.setSingleStep(1e-1 * (vmax2 - vmin2)) + self.tmax.setValue(vmax2) + self.tmax.setSingleStep(1e-1 * (vmax2 - vmin2)) + # Set up the slider self.slider.setMinimum(vmin2) self.slider.setMaximum(vmax2) self.slider.setValue(val) - self.value_label.setText(f"{val:.1g}") - self.vmin.setText(f"{vmin2:.1g}") - self.vmax.setText(f"{vmax2:.1g}") + self.value_label.setText(f"{val:.3g}") + # Set limits for the spin boxes + self.tmin.setMinimum(_make_finite(vmin)) + self.tmax.setMaximum(_make_finite(vmax)) + # Connect signals + self.slider.floatValueChanged.connect(self.on_val_change) + self.fix.clicked.connect(self.on_fix_toggled) + self.tmin.valueChanged.connect(self.on_min_change) + self.tmax.valueChanged.connect(self.on_max_change) + self.fit.clicked.connect(self.on_fit_toggled) - def on_val_changed(self, val): + def on_val_change(self, val): + print("val change", val) self.minuit.values[self.par] = val - self.value_label.setText(f"{val:.1g}") - on_parameter_change() - - def on_limit_changed(self): - vmin = float(self.vmin.text()) - vmax = float(self.vmax.text()) - self.minuit.limits[self.par] = (vmin, vmax) - self.slider.setMinimum(vmin) - self.slider.setMaximum(vmax) - # Update the slider position - current_value = self.slider.value() - if current_value < vmin: - self.slider.setValue(vmin) - self.vmin.setText(f"{vmin:.1g}") - on_parameter_change() - elif current_value > vmax: - self.slider.setValue(vmax) - self.editValue.setText(f"{vmax:.1g}") - on_parameter_change() - else: - self.slider.blockSignals(True) - self.slider.setValue(vmin) - self.slider.setValue(current_value) - self.slider.blockSignals(False) + self.value_label.setText(f"{val:.3g}") + self.callback() + + def on_min_change(self): + tmin = self.tmin.value() + if tmin >= self.tmax.value(): + self.tmin.setValue(self.minuit.limits[self.par][0]) + return + self.slider.setMinimum(tmin) + lim = self.minuit.limits[self.par] + minuit.limits[self.par] = (tmin, lim[1]) + + def on_max_change(self): + tmax = self.tmax.value() + if tmax <= self.tmin.value(): + self.tmax.setValue(self.minuit.limits[self.par][1]) + return + self.slider.setMaximum(tmax) + lim = self.minuit.limits[self.par] + minuit.limits[self.par] = (lim[0], tmax) def on_fix_toggled(self): + print("fix toggled") self.minuit.fixed[self.par] = self.fix.isChecked() if self.fix.isChecked(): self.fit.setChecked(False) @@ -237,86 +200,207 @@ def on_fit_toggled(self): self.slider.setEnabled(not self.fit.isChecked()) if self.fit.isChecked(): self.fix.setChecked(False) - on_parameter_change() - - # Set up the main window - main_window = QtWidgets.QMainWindow() - main_window.resize(1600, 1000) - # Set the global font - font = QtGui.QFont() - font.setPointSize(12) - main_window.setFont(font) - # Create the central widget - centralwidget = QtWidgets.QWidget(parent=main_window) - main_window.setCentralWidget(centralwidget) - central_layout = QtWidgets.QVBoxLayout(centralwidget) - # Add tabs for interactive and results - tab = QtWidgets.QTabWidget(parent=centralwidget) - interactive_tab = QtWidgets.QWidget() - tab.addTab(interactive_tab, "") - results_tab = QtWidgets.QWidget() - tab.addTab(results_tab, "") - central_layout.addWidget(tab) - # Interactive tab - interactive_layout = QtWidgets.QGridLayout(interactive_tab) - # Add the plot - plot_group = QtWidgets.QGroupBox("", parent=interactive_tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHeightForWidth(plot_group.sizePolicy().hasHeightForWidth()) - plot_group.setSizePolicy(sizePolicy) - plot_layout = QtWidgets.QVBoxLayout(plot_group) - canvas = FigureCanvasQTAgg(Figure()) - ax = canvas.figure.add_subplot(111) - plot_layout.addWidget(canvas) - interactive_layout.addWidget(plot_group, 0, 0, 2, 1) - # Add buttons - button_group = QtWidgets.QGroupBox("", parent=interactive_tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Fixed) - sizePolicy.setHeightForWidth(button_group.sizePolicy().hasHeightForWidth()) - button_group.setSizePolicy(sizePolicy) - button_layout = QtWidgets.QHBoxLayout(button_group) - fit_button = QtWidgets.QPushButton(parent=button_group) - fit_button.clicked.connect(do_fit) - button_layout.addWidget(fit_button) - update_button = QtWidgets.QPushButton(parent=button_group) - update_button.clicked.connect(on_update_button_clicked) - button_layout.addWidget(update_button) - reset_button = QtWidgets.QPushButton(parent=button_group) - reset_button.clicked.connect(on_reset_button_clicked) - button_layout.addWidget(reset_button) - algo_choice = QtWidgets.QComboBox(parent=button_group) - algo_choice.setStyleSheet("QComboBox { text-align: center; }") - algo_choice.addItems(["Migrad", "Scipy", "Simplex"]) - button_layout.addWidget(algo_choice) - interactive_layout.addWidget(button_group, 0, 1, 1, 1) - # Add the parameters - parameter_group = QtWidgets.QGroupBox("", parent=interactive_tab) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, - QtWidgets.QSizePolicy.Policy.Expanding) - sizePolicy.setHeightForWidth( - parameter_group.sizePolicy().hasHeightForWidth()) - parameter_group.setSizePolicy(sizePolicy) - parameter_group_layout = QtWidgets.QVBoxLayout(parameter_group) - scroll_area = QtWidgets.QScrollArea(parent=parameter_group) - scroll_area.setWidgetResizable(True) - scroll_area_widget_contents = QtWidgets.QWidget() - scroll_area_widget_contents.setGeometry(QtCore.QRect(0, 0, 751, 830)) - parameter_layout = QtWidgets.QVBoxLayout(scroll_area_widget_contents) - scroll_area.setWidget(scroll_area_widget_contents) - parameter_group_layout.addWidget(scroll_area) - interactive_layout.addWidget(parameter_group, 1, 1, 1, 1) - # Results tab - results_layout = QtWidgets.QVBoxLayout(results_tab) - results_text = QtWidgets.QPlainTextEdit(parent=results_tab) - font = QtGui.QFont() - font.setFamily("FreeMono") - results_text.setFont(font) - results_text.setReadOnly(True) - results_layout.addWidget(results_text) - - parameters = [Parameter(minuit, par) for par in minuit.parameters] + self.on_fix_toggled() + self.callback() + + def reset(self, val, limits=None): + self.slider.blockSignals(True) + self.slider.setValue(val) + self.value_label.setText(f"{val:.3g}") + if limits: + self.slider.setMinimum(limits[0]) + self.slider.setMaximum(limits[1]) + self.tmin.setValue(limits[0]) + self.tmax.setValue(limits[1]) + self.slider.blockSignals(False) + + + class MainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.resize(1200, 600) + # Set the global font + font = QtGui.QFont() + font.setPointSize(12) + self.setFont(font) + # Create the central widget + centralwidget = QtWidgets.QWidget(parent=self) + self.setCentralWidget(centralwidget) + central_layout = QtWidgets.QVBoxLayout(centralwidget) + # Add tabs for interactive and results + tab = QtWidgets.QTabWidget(parent=centralwidget) + interactive_tab = QtWidgets.QWidget() + tab.addTab(interactive_tab, "Interactive") + results_tab = QtWidgets.QWidget() + tab.addTab(results_tab, "Results") + central_layout.addWidget(tab) + # Interactive tab + interactive_layout = QtWidgets.QGridLayout(interactive_tab) + # Add the plot + plot_group = QtWidgets.QGroupBox("", parent=interactive_tab) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + QtWidgets.QSizePolicy.Policy.MinimumExpanding) + plot_group.setSizePolicy(sizePolicy) + plot_layout = QtWidgets.QVBoxLayout(plot_group) + fig, self.ax = plt.subplots() + self.canvas = FigureCanvasQTAgg(fig) + plot_layout.addWidget(self.canvas) + plot_layout.addStretch() + interactive_layout.addWidget(plot_group, 0, 0, 2, 1) + # Add buttons + button_group = QtWidgets.QGroupBox("", parent=interactive_tab) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed) + button_group.setSizePolicy(sizePolicy) + button_layout = QtWidgets.QHBoxLayout(button_group) + self.fit_button = QtWidgets.QPushButton("Fit", parent=button_group) + self.fit_button.setStyleSheet("background-color: #2196F3; color: white") + self.fit_button.clicked.connect(self.do_fit) + button_layout.addWidget(self.fit_button) + self.update_button = QtWidgets.QPushButton("Continuous", parent=button_group) + self.update_button.setCheckable(True) + self.update_button.setChecked(True) + self.update_button.clicked.connect(self.on_update_button_clicked) + button_layout.addWidget(self.update_button) + self.reset_button = QtWidgets.QPushButton("Reset", parent=button_group) + self.reset_button.setStyleSheet("background-color: #F44336; color: white") + self.reset_button.clicked.connect(self.on_reset_button_clicked) + button_layout.addWidget(self.reset_button) + self.algo_choice = QtWidgets.QComboBox(parent=button_group) + self.algo_choice.setStyleSheet("QComboBox { text-align: center; }") + self.algo_choice.addItems(["Migrad", "Scipy", "Simplex"]) + button_layout.addWidget(self.algo_choice) + interactive_layout.addWidget(button_group, 0, 1, 1, 1) + # Add the parameters + scroll_area = QtWidgets.QScrollArea() + scroll_area.setWidgetResizable(True) + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + QtWidgets.QSizePolicy.Policy.MinimumExpanding) + scroll_area.setSizePolicy(sizePolicy) + scroll_area_widget_contents = QtWidgets.QWidget() + parameter_layout = QtWidgets.QVBoxLayout(scroll_area_widget_contents) + scroll_area.setWidget(scroll_area_widget_contents) + interactive_layout.addWidget(scroll_area, 1, 1, 1, 1) + self.parameters = [] + for par in minuit.parameters: + parameter = Parameter(minuit, par, self.on_parameter_change) + self.parameters.append(parameter) + parameter_layout.addWidget(parameter) + parameter_layout.addStretch() + # Results tab + results_layout = QtWidgets.QVBoxLayout(results_tab) + results_text = QtWidgets.QPlainTextEdit(parent=results_tab) + font = QtGui.QFont() + font.setFamily("FreeMono") + results_text.setFont(font) + results_text.setReadOnly(True) + results_layout.addWidget(results_text) + # Remember the original values and limits + self.original_values = minuit.values[:] + self.original_limits = minuit.limits[:] + # Set the initial plot + self.plot_with_frame(from_fit=False, report_success=True) + + def fit(self): + if self.algo_choice.currentText() == "Migrad": + minuit.migrad() + elif self.algo_choice.currentText() == "Scipy": + minuit.scipy() + elif self.algo_choice.currentText() == "Simplex": + minuit.simplex() + return False + else: + assert False # pragma: no cover, should never happen + return True + + def on_parameter_change(self, from_fit=False, + report_success=False): + if not from_fit: + if any(x.fit.isChecked() for x in self.parameters): + saved = minuit.fixed[:] + for i, x in enumerate(self.parameters): + minuit.fixed[i] = not x.fit.isChecked() + from_fit = True + report_success = self.do_fit(plot=False) + minuit.fixed = saved + + self.canvas.figure.clear() + self.plot_with_frame(from_fit, report_success) + self.canvas.draw_idle() + + def do_fit(self, plot=True): + report_success = self.fit() + for i, x in enumerate(self.parameters): + x.reset(minuit.values[i]) + if not plot: + return report_success + self.on_parameter_change( + from_fit=True, report_success=report_success) + + def on_update_button_clicked(self): + for x in self.parameters: + x.slider.setTracking(self.update_button.isChecked()) + + def on_reset_button_clicked(self): + minuit.reset() + minuit.values = self.original_values + minuit.limits = self.original_limits + for i, x in enumerate(self.parameters): + x.reset(minuit.values[i], minuit.limits[i]) + self.on_parameter_change() + + def plot_with_frame(self, from_fit, report_success): + trans = plt.gca().transAxes + try: + with warnings.catch_warnings(): + minuit.visualize(plot, **kwargs) + except Exception: + if raise_on_exception: + raise + + import traceback + + plt.figtext( + 0, + 0.5, + traceback.format_exc(limit=-1), + fontdict={"family": "monospace", "size": "x-small"}, + va="center", + color="r", + backgroundcolor="w", + wrap=True, + ) + return + + fval = minuit.fmin.fval if from_fit else minuit._fcn(minuit.values) + plt.text( + 0.05, + 1.05, + f"FCN = {fval:.3f}", + transform=trans, + fontsize="x-large", + ) + if from_fit and report_success: + plt.text( + 0.95, + 1.05, + f"{'success' if minuit.valid and minuit.accurate else 'FAILURE'}", + transform=trans, + fontsize="x-large", + ha="right", + ) + + + # Set up the Qt application + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + main_window = MainWindow() + main_window.show() + app.exec() def _make_finite(x: float) -> float: From db6c6bb9a694fcd6b3f4e43620c3ac1d994bc075 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Tue, 22 Oct 2024 14:20:10 +0200 Subject: [PATCH 3/7] Fixed bugs and added results tab --- src/iminuit/qtwidget.py | 134 ++++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 52 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 9f3547c9..df8d4d73 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -4,21 +4,18 @@ import numpy as np from typing import Dict, Any, Callable import sys +from functools import partial -with warnings.catch_warnings(): - # ipywidgets produces deprecation warnings through use of internal APIs :( - warnings.simplefilter("ignore") - try: - from PyQt6 import QtCore, QtGui, QtWidgets - from matplotlib.figure import Figure - from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg - from matplotlib import pyplot as plt - except ModuleNotFoundError as e: - e.msg += ( - "\n\nPlease install PyQt6, and matplotlib to enable interactive " - "outside of Jupyter notebooks." - ) - raise +try: + from PyQt6 import QtCore, QtGui, QtWidgets + from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg + from matplotlib import pyplot as plt +except ModuleNotFoundError as e: + e.msg += ( + "\n\nPlease install PyQt6, and matplotlib to enable interactive " + "outside of Jupyter notebooks." + ) + raise def make_widget( @@ -31,46 +28,62 @@ def make_widget( class FloatSlider(QtWidgets.QSlider): + # Qt sadly does not have a float slider, so we have to + # implement one ourselves. floatValueChanged = QtCore.pyqtSignal(float) - def __init__(self): + def __init__(self, label): super().__init__(QtCore.Qt.Orientation.Horizontal) super().setMinimum(0) - super().setMaximum(10000) - super().setValue(5000) + super().setMaximum(int(1e8)) + super().setValue(int(5e7)) self._min = 0.0 self._max = 1.0 + self._value = 0.5 + self._label = label self.valueChanged.connect(self._emit_float_value_changed) - def _emit_float_value_changed(self, value): - float_value = self._int_to_float(value) - self.floatValueChanged.emit(float_value) + def _emit_float_value_changed(self, value=None): + if value is not None: + self._value = self._int_to_float(value) + self._label.setText(f"{self._value:.3g}") + self.floatValueChanged.emit(self._value) def _int_to_float(self, value): - return self._min + (value / 10000) * (self._max - self._min) + return self._min + (value / 1e8) * (self._max - self._min) def _float_to_int(self, value): - return int((value - self._min) / (self._max - self._min) * 10000) + return int((value - self._min) / (self._max - self._min) * 1e8) def setMinimum(self, min_value): - val = self.value() - if val <= min_value: - val = min_value + if self._max <= min_value: + return self._min = min_value - self.setValue(val) + self.setValue(self._value) def setMaximum(self, max_value): - val = self.value() - if val >= max_value: - val = max_value + if self._min >= max_value: + return self._max = max_value - self.setValue(val) + self.setValue(self._value) def setValue(self, value): - super().setValue(self._float_to_int(value)) + if value < self._min: + self._value = self._min + super().setValue(0) + self._emit_float_value_changed() + elif value > self._max: + self._value = self._max + super().setValue(int(1e8)) + self._emit_float_value_changed() + else: + self._value = value + self.blockSignals(True) + super().setValue(self._float_to_int(value)) + self.blockSignals(False) def value(self): - return self._int_to_float(super().value()) + return self._value class Parameter(QtWidgets.QGroupBox): @@ -96,7 +109,7 @@ def __init__(self, minuit, par, callback): alignment=QtCore.Qt.AlignmentFlag.AlignCenter) self.value_label.setMinimumSize(QtCore.QSize(50, 0)) # Add value slider - self.slider = FloatSlider() + self.slider = FloatSlider(self.value_label) # Add spin boxes for changing the limits self.tmin = QtWidgets.QDoubleSpinBox( alignment=QtCore.Qt.AlignmentFlag.AlignCenter) @@ -151,6 +164,9 @@ def __init__(self, minuit, par, callback): self.tmin.setSingleStep(1e-1 * (vmax2 - vmin2)) self.tmax.setValue(vmax2) self.tmax.setSingleStep(1e-1 * (vmax2 - vmin2)) + # Remember the original values and limits + self.original_value = val + self.original_limits = (vmin2, vmax2) # Set up the slider self.slider.setMinimum(vmin2) self.slider.setMaximum(vmax2) @@ -167,15 +183,15 @@ def __init__(self, minuit, par, callback): self.fit.clicked.connect(self.on_fit_toggled) def on_val_change(self, val): - print("val change", val) self.minuit.values[self.par] = val - self.value_label.setText(f"{val:.3g}") self.callback() def on_min_change(self): tmin = self.tmin.value() if tmin >= self.tmax.value(): + self.tmin.blockSignals(True) self.tmin.setValue(self.minuit.limits[self.par][0]) + self.tmin.blockSignals(False) return self.slider.setMinimum(tmin) lim = self.minuit.limits[self.par] @@ -184,14 +200,15 @@ def on_min_change(self): def on_max_change(self): tmax = self.tmax.value() if tmax <= self.tmin.value(): + self.tmax.blockSignals(True) self.tmax.setValue(self.minuit.limits[self.par][1]) + self.tmax.blockSignals(False) return self.slider.setMaximum(tmax) lim = self.minuit.limits[self.par] minuit.limits[self.par] = (lim[0], tmax) def on_fix_toggled(self): - print("fix toggled") self.minuit.fixed[self.par] = self.fix.isChecked() if self.fix.isChecked(): self.fit.setChecked(False) @@ -200,18 +217,26 @@ def on_fit_toggled(self): self.slider.setEnabled(not self.fit.isChecked()) if self.fit.isChecked(): self.fix.setChecked(False) - self.on_fix_toggled() + self.minuit.fixed[self.par] = False self.callback() - def reset(self, val, limits=None): + def reset(self, val=None, limits=False): + if limits: + self.slider.blockSignals(True) + self.slider.setMinimum(self.original_limits[0]) + self.slider.blockSignals(True) + self.slider.setMaximum(self.original_limits[1]) + self.tmin.blockSignals(True) + self.tmin.setValue(self.original_limits[0]) + self.tmin.blockSignals(False) + self.tmax.blockSignals(True) + self.tmax.setValue(self.original_limits[1]) + self.tmax.blockSignals(False) + if val is None: + val = self.original_value self.slider.blockSignals(True) self.slider.setValue(val) self.value_label.setText(f"{val:.3g}") - if limits: - self.slider.setMinimum(limits[0]) - self.slider.setMaximum(limits[1]) - self.tmin.setValue(limits[0]) - self.tmax.setValue(limits[1]) self.slider.blockSignals(False) @@ -257,7 +282,7 @@ def __init__(self): button_layout = QtWidgets.QHBoxLayout(button_group) self.fit_button = QtWidgets.QPushButton("Fit", parent=button_group) self.fit_button.setStyleSheet("background-color: #2196F3; color: white") - self.fit_button.clicked.connect(self.do_fit) + self.fit_button.clicked.connect(partial(self.do_fit, plot=True)) button_layout.addWidget(self.fit_button) self.update_button = QtWidgets.QPushButton("Continuous", parent=button_group) self.update_button.setCheckable(True) @@ -292,12 +317,12 @@ def __init__(self): parameter_layout.addStretch() # Results tab results_layout = QtWidgets.QVBoxLayout(results_tab) - results_text = QtWidgets.QPlainTextEdit(parent=results_tab) - font = QtGui.QFont() - font.setFamily("FreeMono") - results_text.setFont(font) - results_text.setReadOnly(True) - results_layout.addWidget(results_text) + self.results_text = QtWidgets.QTextEdit(parent=results_tab) + #font = QtGui.QFont() + #font.setFamily("FreeMono") + #self.results_text.setFont(font) + self.results_text.setReadOnly(True) + results_layout.addWidget(self.results_text) # Remember the original values and limits self.original_values = minuit.values[:] self.original_limits = minuit.limits[:] @@ -325,7 +350,12 @@ def on_parameter_change(self, from_fit=False, minuit.fixed[i] = not x.fit.isChecked() from_fit = True report_success = self.do_fit(plot=False) + self.results_text.clear() + self.results_text.setHtml(minuit._repr_html_()) minuit.fixed = saved + else: + self.results_text.clear() + self.results_text.setHtml(minuit._repr_html_()) self.canvas.figure.clear() self.plot_with_frame(from_fit, report_success) @@ -334,7 +364,7 @@ def on_parameter_change(self, from_fit=False, def do_fit(self, plot=True): report_success = self.fit() for i, x in enumerate(self.parameters): - x.reset(minuit.values[i]) + x.reset(val=minuit.values[i]) if not plot: return report_success self.on_parameter_change( @@ -349,7 +379,7 @@ def on_reset_button_clicked(self): minuit.values = self.original_values minuit.limits = self.original_limits for i, x in enumerate(self.parameters): - x.reset(minuit.values[i], minuit.limits[i]) + x.reset(val=minuit.values[i], limits=True) self.on_parameter_change() def plot_with_frame(self, from_fit, report_success): From cc5cbd4e520ee6488bc9b2abaa68b8a2ac183796 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 25 Oct 2024 03:47:04 +0200 Subject: [PATCH 4/7] Clean up --- src/iminuit/qtwidget.py | 243 +++++++++++++++++----------------------- 1 file changed, 104 insertions(+), 139 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index df8d4d73..502182bd 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -25,7 +25,49 @@ def make_widget( raise_on_exception: bool, ): """Make interactive fitting widget.""" - + original_values = minuit.values[:] + original_limits = minuit.limits[:] + + def plot_with_frame(from_fit, report_success): + trans = plt.gca().transAxes + try: + with warnings.catch_warnings(): + minuit.visualize(plot, **kwargs) + except Exception: + if raise_on_exception: + raise + + import traceback + + plt.figtext( + 0, + 0.5, + traceback.format_exc(limit=-1), + fontdict={"family": "monospace", "size": "x-small"}, + va="center", + color="r", + backgroundcolor="w", + wrap=True, + ) + return + + fval = minuit.fmin.fval if from_fit else minuit._fcn(minuit.values) + plt.text( + 0.05, + 1.05, + f"FCN = {fval:.3f}", + transform=trans, + fontsize="x-large", + ) + if from_fit and report_success: + plt.text( + 0.95, + 1.05, + f"{'success' if minuit.valid and minuit.accurate else 'FAILURE'}", + transform=trans, + fontsize="x-large", + ha="right", + ) class FloatSlider(QtWidgets.QSlider): # Qt sadly does not have a float slider, so we have to @@ -85,56 +127,45 @@ def setValue(self, value): def value(self): return self._value - class Parameter(QtWidgets.QGroupBox): def __init__(self, minuit, par, callback): super().__init__("") self.par = par self.callback = callback - self.minuit = minuit - # Set the size policy of the group box - sizePolicy = QtWidgets.QSizePolicy( + + size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.Fixed) - self.setSizePolicy(sizePolicy) - # Set up the Qt Widget + self.setSizePolicy(size_policy) layout = QtWidgets.QVBoxLayout() self.setLayout(layout) - # Add label + label = QtWidgets.QLabel( par, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) label.setMinimumSize(QtCore.QSize(50, 0)) - # Add label to display slider value self.value_label = QtWidgets.QLabel( alignment=QtCore.Qt.AlignmentFlag.AlignCenter) self.value_label.setMinimumSize(QtCore.QSize(50, 0)) - # Add value slider self.slider = FloatSlider(self.value_label) - # Add spin boxes for changing the limits self.tmin = QtWidgets.QDoubleSpinBox( alignment=QtCore.Qt.AlignmentFlag.AlignCenter) self.tmin.setRange(_make_finite(-np.inf), _make_finite(np.inf)) self.tmax = QtWidgets.QDoubleSpinBox( alignment=QtCore.Qt.AlignmentFlag.AlignCenter) self.tmax.setRange(_make_finite(-np.inf), _make_finite(np.inf)) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Policy.MinimumExpanding, - QtWidgets.QSizePolicy.Policy.Fixed) - self.tmin.setSizePolicy(sizePolicy) - self.tmax.setSizePolicy(sizePolicy) - # Add buttons + self.tmin.setSizePolicy(size_policy) + self.tmax.setSizePolicy(size_policy) self.fix = QtWidgets.QPushButton("Fix") self.fix.setCheckable(True) self.fix.setChecked(minuit.fixed[par]) self.fit = QtWidgets.QPushButton("Fit") self.fit.setCheckable(True) self.fit.setChecked(False) - sizePolicy = QtWidgets.QSizePolicy( + size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed) - self.fix.setSizePolicy(sizePolicy) - self.fit.setSizePolicy(sizePolicy) - # Add widgets to the layout + self.fix.setSizePolicy(size_policy) + self.fit.setSizePolicy(size_policy) layout1 = QtWidgets.QHBoxLayout() layout.addLayout(layout1) layout1.addWidget(label) @@ -146,36 +177,18 @@ def __init__(self, minuit, par, callback): layout2.addWidget(self.tmin) layout2.addWidget(self.tmax) layout2.addWidget(self.fit) - # Add tooltips - self.slider.setToolTip("Parameter Value") - self.value_label.setToolTip("Parameter Value") - self.tmin.setToolTip("Lower Limit") - self.tmax.setToolTip("Upper Limit") - self.fix.setToolTip("Fix Parameter") - self.fit.setToolTip("Fit Parameter") - # Set initial value and limits + val = minuit.values[par] vmin, vmax = minuit.limits[par] self.step = _guess_initial_step(val, vmin, vmax) vmin2 = vmin if np.isfinite(vmin) else val - 100 * self.step vmax2 = vmax if np.isfinite(vmax) else val + 100 * self.step - # Set up the spin boxes - self.tmin.setValue(vmin2) self.tmin.setSingleStep(1e-1 * (vmax2 - vmin2)) - self.tmax.setValue(vmax2) self.tmax.setSingleStep(1e-1 * (vmax2 - vmin2)) - # Remember the original values and limits - self.original_value = val - self.original_limits = (vmin2, vmax2) - # Set up the slider - self.slider.setMinimum(vmin2) - self.slider.setMaximum(vmax2) - self.slider.setValue(val) - self.value_label.setText(f"{val:.3g}") - # Set limits for the spin boxes self.tmin.setMinimum(_make_finite(vmin)) self.tmax.setMaximum(_make_finite(vmax)) - # Connect signals + self.reset(val, limits=(vmin, vmax)) + self.slider.floatValueChanged.connect(self.on_val_change) self.fix.clicked.connect(self.on_fix_toggled) self.tmin.valueChanged.connect(self.on_min_change) @@ -183,33 +196,33 @@ def __init__(self, minuit, par, callback): self.fit.clicked.connect(self.on_fit_toggled) def on_val_change(self, val): - self.minuit.values[self.par] = val + minuit.values[self.par] = val self.callback() def on_min_change(self): tmin = self.tmin.value() if tmin >= self.tmax.value(): self.tmin.blockSignals(True) - self.tmin.setValue(self.minuit.limits[self.par][0]) + self.tmin.setValue(minuit.limits[self.par][0]) self.tmin.blockSignals(False) return self.slider.setMinimum(tmin) - lim = self.minuit.limits[self.par] + lim = minuit.limits[self.par] minuit.limits[self.par] = (tmin, lim[1]) def on_max_change(self): tmax = self.tmax.value() if tmax <= self.tmin.value(): self.tmax.blockSignals(True) - self.tmax.setValue(self.minuit.limits[self.par][1]) + self.tmax.setValue(minuit.limits[self.par][1]) self.tmax.blockSignals(False) return self.slider.setMaximum(tmax) - lim = self.minuit.limits[self.par] + lim = minuit.limits[self.par] minuit.limits[self.par] = (lim[0], tmax) def on_fix_toggled(self): - self.minuit.fixed[self.par] = self.fix.isChecked() + minuit.fixed[self.par] = self.fix.isChecked() if self.fix.isChecked(): self.fit.setChecked(False) @@ -217,80 +230,84 @@ def on_fit_toggled(self): self.slider.setEnabled(not self.fit.isChecked()) if self.fit.isChecked(): self.fix.setChecked(False) - self.minuit.fixed[self.par] = False + minuit.fixed[self.par] = False self.callback() - def reset(self, val=None, limits=False): - if limits: + def reset(self, val, limits=None): + # Set limits first so that the value won't be changed by the + # FloatSlider + if limits is not None: + vmin, vmax = limits + vmin = vmin if np.isfinite(vmin) else val - 100 * self.step + vmax = vmax if np.isfinite(vmax) else val + 100 * self.step self.slider.blockSignals(True) - self.slider.setMinimum(self.original_limits[0]) + self.slider.setMinimum(vmin) self.slider.blockSignals(True) - self.slider.setMaximum(self.original_limits[1]) + self.slider.setMaximum(vmax) self.tmin.blockSignals(True) - self.tmin.setValue(self.original_limits[0]) + self.tmin.setValue(vmin) self.tmin.blockSignals(False) self.tmax.blockSignals(True) - self.tmax.setValue(self.original_limits[1]) + self.tmax.setValue(vmax) self.tmax.blockSignals(False) - if val is None: - val = self.original_value + self.slider.blockSignals(True) self.slider.setValue(val) self.value_label.setText(f"{val:.3g}") self.slider.blockSignals(False) - class MainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.resize(1200, 600) - # Set the global font font = QtGui.QFont() font.setPointSize(12) self.setFont(font) - # Create the central widget centralwidget = QtWidgets.QWidget(parent=self) self.setCentralWidget(centralwidget) central_layout = QtWidgets.QVBoxLayout(centralwidget) - # Add tabs for interactive and results tab = QtWidgets.QTabWidget(parent=centralwidget) interactive_tab = QtWidgets.QWidget() tab.addTab(interactive_tab, "Interactive") results_tab = QtWidgets.QWidget() tab.addTab(results_tab, "Results") central_layout.addWidget(tab) - # Interactive tab + interactive_layout = QtWidgets.QGridLayout(interactive_tab) - # Add the plot + plot_group = QtWidgets.QGroupBox("", parent=interactive_tab) - sizePolicy = QtWidgets.QSizePolicy( + size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding) - plot_group.setSizePolicy(sizePolicy) + plot_group.setSizePolicy(size_policy) plot_layout = QtWidgets.QVBoxLayout(plot_group) - fig, self.ax = plt.subplots() + fig, ax = plt.subplots() self.canvas = FigureCanvasQTAgg(fig) plot_layout.addWidget(self.canvas) plot_layout.addStretch() interactive_layout.addWidget(plot_group, 0, 0, 2, 1) - # Add buttons + button_group = QtWidgets.QGroupBox("", parent=interactive_tab) - sizePolicy = QtWidgets.QSizePolicy( + size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) - button_group.setSizePolicy(sizePolicy) + button_group.setSizePolicy(size_policy) button_layout = QtWidgets.QHBoxLayout(button_group) self.fit_button = QtWidgets.QPushButton("Fit", parent=button_group) - self.fit_button.setStyleSheet("background-color: #2196F3; color: white") + self.fit_button.setStyleSheet( + "background-color: #2196F3; color: white") self.fit_button.clicked.connect(partial(self.do_fit, plot=True)) button_layout.addWidget(self.fit_button) - self.update_button = QtWidgets.QPushButton("Continuous", parent=button_group) + self.update_button = QtWidgets.QPushButton( + "Continuous", parent=button_group) self.update_button.setCheckable(True) self.update_button.setChecked(True) self.update_button.clicked.connect(self.on_update_button_clicked) button_layout.addWidget(self.update_button) - self.reset_button = QtWidgets.QPushButton("Reset", parent=button_group) - self.reset_button.setStyleSheet("background-color: #F44336; color: white") + self.reset_button = QtWidgets.QPushButton( + "Reset", parent=button_group) + self.reset_button.setStyleSheet( + "background-color: #F44336; color: white") self.reset_button.clicked.connect(self.on_reset_button_clicked) button_layout.addWidget(self.reset_button) self.algo_choice = QtWidgets.QComboBox(parent=button_group) @@ -298,16 +315,16 @@ def __init__(self): self.algo_choice.addItems(["Migrad", "Scipy", "Simplex"]) button_layout.addWidget(self.algo_choice) interactive_layout.addWidget(button_group, 0, 1, 1, 1) - # Add the parameters + scroll_area = QtWidgets.QScrollArea() scroll_area.setWidgetResizable(True) - sizePolicy = QtWidgets.QSizePolicy( + size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding) - scroll_area.setSizePolicy(sizePolicy) - scroll_area_widget_contents = QtWidgets.QWidget() - parameter_layout = QtWidgets.QVBoxLayout(scroll_area_widget_contents) - scroll_area.setWidget(scroll_area_widget_contents) + scroll_area.setSizePolicy(size_policy) + scroll_area_contents = QtWidgets.QWidget() + parameter_layout = QtWidgets.QVBoxLayout(scroll_area_contents) + scroll_area.setWidget(scroll_area_contents) interactive_layout.addWidget(scroll_area, 1, 1, 1, 1) self.parameters = [] for par in minuit.parameters: @@ -315,19 +332,13 @@ def __init__(self): self.parameters.append(parameter) parameter_layout.addWidget(parameter) parameter_layout.addStretch() - # Results tab + results_layout = QtWidgets.QVBoxLayout(results_tab) self.results_text = QtWidgets.QTextEdit(parent=results_tab) - #font = QtGui.QFont() - #font.setFamily("FreeMono") - #self.results_text.setFont(font) self.results_text.setReadOnly(True) results_layout.addWidget(self.results_text) - # Remember the original values and limits - self.original_values = minuit.values[:] - self.original_limits = minuit.limits[:] - # Set the initial plot - self.plot_with_frame(from_fit=False, report_success=True) + + plot_with_frame(from_fit=False, report_success=False) def fit(self): if self.algo_choice.currentText() == "Migrad": @@ -358,8 +369,8 @@ def on_parameter_change(self, from_fit=False, self.results_text.setHtml(minuit._repr_html_()) self.canvas.figure.clear() - self.plot_with_frame(from_fit, report_success) - self.canvas.draw_idle() + plot_with_frame(from_fit, report_success) + self.canvas.draw_idle() def do_fit(self, plot=True): report_success = self.fit() @@ -376,54 +387,12 @@ def on_update_button_clicked(self): def on_reset_button_clicked(self): minuit.reset() - minuit.values = self.original_values - minuit.limits = self.original_limits + minuit.values = original_values + minuit.limits = original_limits for i, x in enumerate(self.parameters): - x.reset(val=minuit.values[i], limits=True) + x.reset(val=minuit.values[i], limits=original_limits[i]) self.on_parameter_change() - def plot_with_frame(self, from_fit, report_success): - trans = plt.gca().transAxes - try: - with warnings.catch_warnings(): - minuit.visualize(plot, **kwargs) - except Exception: - if raise_on_exception: - raise - - import traceback - - plt.figtext( - 0, - 0.5, - traceback.format_exc(limit=-1), - fontdict={"family": "monospace", "size": "x-small"}, - va="center", - color="r", - backgroundcolor="w", - wrap=True, - ) - return - - fval = minuit.fmin.fval if from_fit else minuit._fcn(minuit.values) - plt.text( - 0.05, - 1.05, - f"FCN = {fval:.3f}", - transform=trans, - fontsize="x-large", - ) - if from_fit and report_success: - plt.text( - 0.95, - 1.05, - f"{'success' if minuit.valid and minuit.accurate else 'FAILURE'}", - transform=trans, - fontsize="x-large", - ha="right", - ) - - # Set up the Qt application app = QtWidgets.QApplication.instance() if app is None: @@ -444,7 +413,3 @@ def _guess_initial_step(val: float, vmin: float, vmax: float) -> float: if np.isfinite(vmin) and np.isfinite(vmax): return 1e-2 * (vmax - vmin) return 1e-2 - - -def _round(x: float) -> float: - return float(f"{x:.1g}") From dc7d7c9536df952f0ed05b68e1861d0643b47a70 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 25 Oct 2024 04:32:40 +0200 Subject: [PATCH 5/7] Clean up --- src/iminuit/minuit.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index f685e29d..8ba7cf9b 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2349,7 +2349,6 @@ def interactive( """ is_jupyter = True try: - from IPython import get_ipython if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" and "IPKernelApp" in get_ipython().config): is_jupyter = True @@ -2360,14 +2359,11 @@ def interactive( if is_jupyter: from iminuit.ipywidget import make_widget - - plot = self._visualize(plot) - return make_widget(self, plot, kwargs, raise_on_exception) else: from iminuit.qtwidget import make_widget - plot = self._visualize(plot) - return make_widget(self, plot, kwargs, raise_on_exception) + plot = self._visualize(plot) + return make_widget(self, plot, kwargs, raise_on_exception) def _free_parameters(self) -> Set[str]: return set(mp.name for mp in self._last_state if not mp.is_fixed) From d9660b3b97a210aff2344259599f5c909d95963e Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 25 Oct 2024 04:38:32 +0200 Subject: [PATCH 6/7] Clean up --- src/iminuit/minuit.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 8ba7cf9b..1181a49c 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2347,7 +2347,6 @@ def interactive( -------- Minuit.visualize """ - is_jupyter = True try: if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" and "IPKernelApp" in get_ipython().config): From fd7c748ead2204f73274d74e550e5543c92d2b98 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 25 Oct 2024 08:15:09 +0000 Subject: [PATCH 7/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/iminuit/minuit.py | 6 ++++-- src/iminuit/qtwidget.py | 46 +++++++++++++++++++++-------------------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 1181a49c..398a8c71 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2348,8 +2348,10 @@ def interactive( Minuit.visualize """ try: - if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" - and "IPKernelApp" in get_ipython().config): + if ( + get_ipython().__class__.__name__ == "ZMQInteractiveShell" + and "IPKernelApp" in get_ipython().config + ): is_jupyter = True else: is_jupyter = False diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 502182bd..8856a954 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -135,23 +135,26 @@ def __init__(self, minuit, par, callback): size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, - QtWidgets.QSizePolicy.Policy.Fixed) + QtWidgets.QSizePolicy.Policy.Fixed, + ) self.setSizePolicy(size_policy) layout = QtWidgets.QVBoxLayout() self.setLayout(layout) - label = QtWidgets.QLabel( - par, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + label = QtWidgets.QLabel(par, alignment=QtCore.Qt.AlignmentFlag.AlignCenter) label.setMinimumSize(QtCore.QSize(50, 0)) self.value_label = QtWidgets.QLabel( - alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + alignment=QtCore.Qt.AlignmentFlag.AlignCenter + ) self.value_label.setMinimumSize(QtCore.QSize(50, 0)) self.slider = FloatSlider(self.value_label) self.tmin = QtWidgets.QDoubleSpinBox( - alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + alignment=QtCore.Qt.AlignmentFlag.AlignCenter + ) self.tmin.setRange(_make_finite(-np.inf), _make_finite(np.inf)) self.tmax = QtWidgets.QDoubleSpinBox( - alignment=QtCore.Qt.AlignmentFlag.AlignCenter) + alignment=QtCore.Qt.AlignmentFlag.AlignCenter + ) self.tmax.setRange(_make_finite(-np.inf), _make_finite(np.inf)) self.tmin.setSizePolicy(size_policy) self.tmax.setSizePolicy(size_policy) @@ -162,8 +165,8 @@ def __init__(self, minuit, par, callback): self.fit.setCheckable(True) self.fit.setChecked(False) size_policy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Policy.Fixed, - QtWidgets.QSizePolicy.Policy.Fixed) + QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed + ) self.fix.setSizePolicy(size_policy) self.fit.setSizePolicy(size_policy) layout1 = QtWidgets.QHBoxLayout() @@ -278,7 +281,8 @@ def __init__(self): plot_group = QtWidgets.QGroupBox("", parent=interactive_tab) size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, - QtWidgets.QSizePolicy.Policy.MinimumExpanding) + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + ) plot_group.setSizePolicy(size_policy) plot_layout = QtWidgets.QVBoxLayout(plot_group) fig, ax = plt.subplots() @@ -290,24 +294,23 @@ def __init__(self): button_group = QtWidgets.QGroupBox("", parent=interactive_tab) size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Fixed) + QtWidgets.QSizePolicy.Policy.Fixed, + ) button_group.setSizePolicy(size_policy) button_layout = QtWidgets.QHBoxLayout(button_group) self.fit_button = QtWidgets.QPushButton("Fit", parent=button_group) - self.fit_button.setStyleSheet( - "background-color: #2196F3; color: white") + self.fit_button.setStyleSheet("background-color: #2196F3; color: white") self.fit_button.clicked.connect(partial(self.do_fit, plot=True)) button_layout.addWidget(self.fit_button) self.update_button = QtWidgets.QPushButton( - "Continuous", parent=button_group) + "Continuous", parent=button_group + ) self.update_button.setCheckable(True) self.update_button.setChecked(True) self.update_button.clicked.connect(self.on_update_button_clicked) button_layout.addWidget(self.update_button) - self.reset_button = QtWidgets.QPushButton( - "Reset", parent=button_group) - self.reset_button.setStyleSheet( - "background-color: #F44336; color: white") + self.reset_button = QtWidgets.QPushButton("Reset", parent=button_group) + self.reset_button.setStyleSheet("background-color: #F44336; color: white") self.reset_button.clicked.connect(self.on_reset_button_clicked) button_layout.addWidget(self.reset_button) self.algo_choice = QtWidgets.QComboBox(parent=button_group) @@ -320,7 +323,8 @@ def __init__(self): scroll_area.setWidgetResizable(True) size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, - QtWidgets.QSizePolicy.Policy.MinimumExpanding) + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + ) scroll_area.setSizePolicy(size_policy) scroll_area_contents = QtWidgets.QWidget() parameter_layout = QtWidgets.QVBoxLayout(scroll_area_contents) @@ -352,8 +356,7 @@ def fit(self): assert False # pragma: no cover, should never happen return True - def on_parameter_change(self, from_fit=False, - report_success=False): + def on_parameter_change(self, from_fit=False, report_success=False): if not from_fit: if any(x.fit.isChecked() for x in self.parameters): saved = minuit.fixed[:] @@ -378,8 +381,7 @@ def do_fit(self, plot=True): x.reset(val=minuit.values[i]) if not plot: return report_success - self.on_parameter_change( - from_fit=True, report_success=report_success) + self.on_parameter_change(from_fit=True, report_success=report_success) def on_update_button_clicked(self): for x in self.parameters: