From 0aa85f2cca34f33c62b9e24a148edc5dd7d33484 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Mon, 28 Oct 2024 06:02:31 +0100 Subject: [PATCH 01/49] Added PyQt6 widget and added ax kwarg to the visualize methods --- src/iminuit/cost.py | 107 +++++++--- src/iminuit/minuit.py | 24 ++- src/iminuit/qtwidget.py | 430 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 525 insertions(+), 36 deletions(-) create mode 100644 src/iminuit/qtwidget.py diff --git a/src/iminuit/cost.py b/src/iminuit/cost.py index 76a75436..31e3235c 100644 --- a/src/iminuit/cost.py +++ b/src/iminuit/cost.py @@ -109,7 +109,11 @@ class documentation for details. TypeVar, Callable, cast, + TYPE_CHECKING, ) +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.figure import Figure import warnings from ._deprecated import deprecated_parameter @@ -739,7 +743,10 @@ def __getitem__(self, key): return self._items.__getitem__(key) def visualize( - self, args: Sequence[float], component_kwargs: Dict[int, Dict[str, Any]] = None + self, + args: Sequence[float], + component_kwargs: Dict[int, Dict[str, Any]] = None, + fig: Figure = None, ): """ Visualize data and model agreement (requires matplotlib). @@ -757,16 +764,26 @@ def visualize( Dict that maps an index to dict of keyword arguments. This can be used to pass keyword arguments to a visualize method of a component with that index. - **kwargs : - Other keyword arguments are forwarded to all components. + fig : Figure, optional + The matplotlib figure into which the visualization is drawn. + If None is passed, the current figure is used. If the passed figure + has the same number of axes as there are components with a visualize + method, the axes are reused. Otherwise, new axes are created. """ - from matplotlib import pyplot as plt + if fig is None: + from matplotlib import pyplot as plt + fig = plt.gcf() n = sum(hasattr(comp, "visualize") for comp in self) - fig = plt.gcf() fig.set_figwidth(n * fig.get_figwidth() / 1.5) - _, ax = plt.subplots(1, n, num=fig.number) + ax = fig.get_axes() + if len(ax) != n: + fig.clear() + _, ax = fig.subplots(1, n) + # For some reason fig.subplots does not return axes array but only + # a single axes even for n > 1 + ax = fig.get_axes() if component_kwargs is None: component_kwargs = {} @@ -776,8 +793,7 @@ def visualize( if not hasattr(comp, "visualize"): continue kwargs = component_kwargs.get(k, {}) - plt.sca(ax[i]) - comp.visualize(cargs, **kwargs) + comp.visualize(cargs, ax=ax[i], **kwargs) i += 1 @@ -931,6 +947,7 @@ def visualize( args: Sequence[float], model_points: Union[int, Sequence[float]] = 0, bins: int = 50, + ax: Axes = None, ): """ Visualize data and model agreement (requires matplotlib). @@ -947,8 +964,13 @@ def visualize( it is interpreted as the point locations. bins : int, optional number of bins. Default is 50 bins. + ax : Axes, optional + The matplotlib axes into which the visualization is drawn. + If None is passed, the current axes is used. """ - from matplotlib import pyplot as plt + if ax is None: + from matplotlib import pyplot as plt + ax = plt.gca() x = np.sort(self.data) @@ -974,8 +996,8 @@ def visualize( cx = 0.5 * (xe[1:] + xe[:-1]) dx = xe[1] - xe[0] - plt.errorbar(cx, n, n**0.5, fmt="ok") - plt.fill_between(xm, 0, ym * dx, fc="C0") + ax.errorbar(cx, n, n**0.5, fmt="ok") + ax.fill_between(xm, 0, ym * dx, fc="C0") def fisher_information(self, *args: float) -> NDArray: """ @@ -1375,7 +1397,7 @@ def prediction( """ return self._pred(args) - def visualize(self, args: Sequence[float]) -> None: + def visualize(self, args: Sequence[float], ax: Axes = None) -> None: """ Visualize data and model agreement (requires matplotlib). @@ -1385,6 +1407,9 @@ def visualize(self, args: Sequence[float]) -> None: ---------- args : sequence of float Parameter values. + ax : Axes, optional + The matplotlib axes into which the visualization is drawn. + If None is passed, the current axes is used. Notes ----- @@ -1394,10 +1419,12 @@ def visualize(self, args: Sequence[float]) -> None: comparison to a model, the visualization shows all data bins as a single sequence. """ - return self._visualize(args) + return self._visualize(args, ax) - def _visualize(self, args: Sequence[float]) -> None: - from matplotlib import pyplot as plt + def _visualize(self, args: Sequence[float], ax: Axes) -> None: + if ax is None: + from matplotlib import pyplot as plt + ax = plt.gca() n, ne = self._n_err() mu = self.prediction(args) @@ -1414,8 +1441,9 @@ def _visualize(self, args: Sequence[float]) -> None: else: xe = self.xe cx = 0.5 * (xe[1:] + xe[:-1]) - plt.errorbar(cx, n, ne, fmt="ok") - plt.stairs(mu, xe, fill=True, color="C0") + + ax.errorbar(cx, n, ne, fmt="ok") + ax.stairs(mu, xe, fill=True, color="C0") @abc.abstractmethod def _pred( @@ -1858,8 +1886,10 @@ def prediction(self, args: Sequence[float]) -> Tuple[NDArray, NDArray]: mu, mu_var = self._pred(args) return mu, np.sqrt(mu_var) - def _visualize(self, args: Sequence[float]) -> None: - from matplotlib import pyplot as plt + def _visualize(self, args: Sequence[float], ax: Axes) -> None: + if ax is None: + from matplotlib import pyplot as plt + ax = plt.gca() n, ne = self._n_err() mu, mue = self.prediction(args) # type: ignore @@ -1876,11 +1906,11 @@ def _visualize(self, args: Sequence[float]) -> None: xe = self.xe cx = 0.5 * (xe[1:] + xe[:-1]) - plt.errorbar(cx, n, ne, fmt="ok") + ax.errorbar(cx, n, ne, fmt="ok") # need fill=True and fill=False so that bins with mue=0 show up for fill in (False, True): - plt.stairs(mu + mue, xe, baseline=mu - mue, fill=fill, color="C0") + ax.stairs(mu + mue, xe, baseline=mu - mue, fill=fill, color="C0") def _pulls(self, args: Sequence[float]) -> NDArray: mu, mue = self.prediction(args) @@ -2281,7 +2311,10 @@ def _ndata(self): return len(self._masked) def visualize( - self, args: ArrayLike, model_points: Union[int, Sequence[float]] = 0 + self, + args: ArrayLike, + model_points: Union[int, Sequence[float]] = 0, + ax: Axes = None, ) -> Tuple[Tuple[NDArray, NDArray, NDArray], Tuple[NDArray, NDArray]]: """ Visualize data and model agreement (requires matplotlib). @@ -2297,14 +2330,19 @@ def visualize( How many points to use to draw the model. Default is 0, in this case an smart sampling algorithm selects the number of points. If array-like, it is interpreted as the point locations. + ax : Axes, optional + The matplotlib axes into which the visualization is drawn. + If None is passed, the current axes is used. """ - from matplotlib import pyplot as plt + if ax is None: + from matplotlib import pyplot as plt + ax = plt.gca() if self._ndim > 1: raise ValueError("visualize is not implemented for multi-dimensional data") x, y, ye = self._masked.T - plt.errorbar(x, y, ye, fmt="ok") + ax.errorbar(x, y, ye, fmt="ok") if isinstance(model_points, Iterable): xm = np.array(model_points) ym = self.model(xm, *args) @@ -2316,7 +2354,7 @@ def visualize( ym = self.model(xm, *args) else: xm, ym = _smart_sampling(lambda x: self.model(x, *args), x[0], x[-1]) - plt.plot(xm, ym) + ax.plot(xm, ym) return (x, y, ye), (xm, ym) def prediction(self, args: Sequence[float]) -> NDArray: @@ -2486,7 +2524,7 @@ def _has_grad(self) -> bool: def _ndata(self): return len(self._expected) - def visualize(self, args: ArrayLike): + def visualize(self, args: ArrayLike, ax: Axes = None): """ Visualize data and model agreement (requires matplotlib). @@ -2496,8 +2534,13 @@ def visualize(self, args: ArrayLike): ---------- args : array-like Parameter values. + ax : Axes, optional + The matplotlib axes into which the visualization is drawn. + If None is passed, the current axes is used. """ - from matplotlib import pyplot as plt + if ax is None: + from matplotlib import pyplot as plt + ax = plt.gca() args = np.atleast_1d(args) @@ -2515,14 +2558,14 @@ def visualize(self, args: ArrayLike): for v, e, a in zip(val, err, args): pull = (a - v) / e max_pull = max(abs(pull), max_pull) - plt.errorbar(pull, -i, 0, 1, fmt="o", color="C0") + ax.errorbar(pull, -i, 0, 1, fmt="o", color="C0") i += 1 - plt.axvline(0, color="k") - plt.xlim(-max_pull - 1.1, max_pull + 1.1) - yaxis = plt.gca().yaxis + ax.axvline(0, color="k") + ax.set_xlim(-max_pull - 1.1, max_pull + 1.1) + yaxis = ax.yaxis yaxis.set_ticks(-np.arange(n)) yaxis.set_ticklabels(par) - plt.ylim(-n + 0.5, 0.5) + ax.set_ylim(-n + 0.5, 0.5) def _norm(value: ArrayLike) -> NDArray: diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 63d6deca..76c2b1bb 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2317,10 +2317,14 @@ def interactive( **kwargs, ): """ - Return fitting widget (requires ipywidgets, IPython, matplotlib). + Interactive GUI for fitting. - A fitting widget is returned which can be displayed and manipulated in a - Jupyter notebook to find good starting parameters and to debug the fit. + Starts a fitting application (requires PyQt6, matplotlib) in which the + fit is visualized and the parameters can be manipulated to find good + starting parameters and to debug the fit. + + When called in a Jupyter notebook (requires ipywidgets, IPython, matplotlib), + a fitting widget is returned instead, which can be displayed. Parameters ---------- @@ -2347,7 +2351,19 @@ def interactive( -------- Minuit.visualize """ - from iminuit.ipywidget import make_widget + try: + if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" + and "IPKernelApp" in get_ipython().config): + is_jupyter = True + else: + is_jupyter = False + except Exception: + is_jupyter = False + + if is_jupyter: + from iminuit.ipywidget import make_widget + else: + from iminuit.qtwidget import make_widget plot = self._visualize(plot) return make_widget(self, plot, kwargs, raise_on_exception) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py new file mode 100644 index 00000000..3e875308 --- /dev/null +++ b/src/iminuit/qtwidget.py @@ -0,0 +1,430 @@ +"""Interactive fitting widget using PyQt6.""" + +import warnings +import numpy as np +from typing import Dict, Any, Callable +import sys +from functools import partial +import inspect + +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( + 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[:] + + 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, label): + super().__init__(QtCore.Qt.Orientation.Horizontal) + super().setMinimum(0) + 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=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 / 1e8) * (self._max - self._min) + + def _float_to_int(self, value): + return int((value - self._min) / (self._max - self._min) * 1e8) + + def setMinimum(self, min_value): + if self._max <= min_value: + return + self._min = min_value + self.setValue(self._value) + + def setMaximum(self, max_value): + if self._min >= max_value: + return + self._max = max_value + self.setValue(self._value) + + def setValue(self, 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._value + + class Parameter(QtWidgets.QGroupBox): + def __init__(self, minuit, par, callback): + super().__init__("") + self.par = par + self.callback = callback + + size_policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + QtWidgets.QSizePolicy.Policy.Fixed) + self.setSizePolicy(size_policy) + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + + 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) + self.value_label.setMinimumSize(QtCore.QSize(50, 0)) + self.slider = FloatSlider(self.value_label) + 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)) + 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) + size_policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.Fixed, + QtWidgets.QSizePolicy.Policy.Fixed) + self.fix.setSizePolicy(size_policy) + self.fit.setSizePolicy(size_policy) + 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) + + 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 + self.tmin.setSingleStep(1e-1 * (vmax2 - vmin2)) + self.tmax.setSingleStep(1e-1 * (vmax2 - vmin2)) + self.tmin.setMinimum(_make_finite(vmin)) + self.tmax.setMaximum(_make_finite(vmax)) + 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) + self.tmax.valueChanged.connect(self.on_max_change) + self.fit.clicked.connect(self.on_fit_toggled) + + def on_val_change(self, 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(minuit.limits[self.par][0]) + self.tmin.blockSignals(False) + return + self.slider.setMinimum(tmin) + 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(minuit.limits[self.par][1]) + self.tmax.blockSignals(False) + return + self.slider.setMaximum(tmax) + lim = minuit.limits[self.par] + minuit.limits[self.par] = (lim[0], tmax) + + def on_fix_toggled(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) + minuit.fixed[self.par] = False + self.callback() + + 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(vmin) + self.slider.blockSignals(True) + self.slider.setMaximum(vmax) + self.tmin.blockSignals(True) + self.tmin.setValue(vmin) + self.tmin.blockSignals(False) + self.tmax.blockSignals(True) + self.tmax.setValue(vmax) + self.tmax.blockSignals(False) + + 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) + font = QtGui.QFont() + font.setPointSize(12) + self.setFont(font) + centralwidget = QtWidgets.QWidget(parent=self) + self.setCentralWidget(centralwidget) + central_layout = QtWidgets.QVBoxLayout(centralwidget) + 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_layout = QtWidgets.QGridLayout(interactive_tab) + + plot_group = QtWidgets.QGroupBox("", parent=interactive_tab) + size_policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + QtWidgets.QSizePolicy.Policy.MinimumExpanding) + plot_group.setSizePolicy(size_policy) + plot_layout = QtWidgets.QVBoxLayout(plot_group) + # Use pyplot here to allow users to use pyplot in the plot + # function (not recommended / unstable) + self.fig, ax = plt.subplots() + self.canvas = FigureCanvasQTAgg(self.fig) + plot_layout.addWidget(self.canvas) + plot_layout.addStretch() + interactive_layout.addWidget(plot_group, 0, 0, 2, 1) + try: + plot(minuit.values, fig=self.fig) + kwargs["fig"] = self.fig + except Exception: + pass + try: + plot(minuit.values, ax=ax) + kwargs["ax"] = ax + except Exception: + pass + self.fig_width = self.fig.get_figwidth() + + button_group = QtWidgets.QGroupBox("", parent=interactive_tab) + size_policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + 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.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) + 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) + + scroll_area = QtWidgets.QScrollArea() + scroll_area.setWidgetResizable(True) + size_policy = QtWidgets.QSizePolicy( + 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) + scroll_area.setWidget(scroll_area_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_layout = QtWidgets.QVBoxLayout(results_tab) + self.results_text = QtWidgets.QTextEdit(parent=results_tab) + self.results_text.setReadOnly(True) + results_layout.addWidget(self.results_text) + + self.plot_with_frame(from_fit=False, report_success=False) + + def plot_with_frame(self, from_fit, report_success): + self.fig.set_figwidth(self.fig_width) + try: + with warnings.catch_warnings(): + minuit.visualize(plot, **kwargs) + except Exception: + if raise_on_exception: + raise + + import traceback + + self.fig.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) + self.fig.get_axes()[0].text( + 0.05, + 1.05, + f"FCN = {fval:.3f}", + transform=self.fig.get_axes()[0].transAxes, + fontsize="x-large", + ) + if from_fit and report_success: + self.fig.get_axes()[-1].text( + 0.95, + 1.05, + f"{'success' if minuit.valid and minuit.accurate else 'FAILURE'}", + transform=self.fig.get_axes()[-1].transAxes, + fontsize="x-large", + ha="right", + ) + + 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) + 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_()) + + for ax in self.fig.get_axes(): + ax.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(val=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 = original_values + minuit.limits = original_limits + for i, x in enumerate(self.parameters): + x.reset(val=minuit.values[i], limits=original_limits[i]) + self.on_parameter_change() + + # 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: + 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 From 59c3bf89c86cae5a8d69284c782b52e4b1f765d0 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Tue, 29 Oct 2024 06:13:56 +0100 Subject: [PATCH 02/49] Added PyQt6 optional dependency --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7b8aa6d9..ad8940da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ test = [ "ipywidgets", # needed by ipywidgets >= 8.0.6 "ipykernel", + "PyQt6", "joblib", "jacobi", "matplotlib", @@ -52,6 +53,7 @@ test = [ "numba-stats; platform_python_implementation=='CPython'", "pytest", "pytest-xdist", + "pytest-qt", "scipy", "tabulate", "boost_histogram", From 51bbf1faadbf5e003432983d7c3ce05f488bdf9c Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Tue, 29 Oct 2024 06:14:47 +0100 Subject: [PATCH 03/49] Added tests --- src/iminuit/minuit.py | 14 +- src/iminuit/qtwidget.py | 9 +- tests/test_draw.py | 221 ++++++++++++++++++++++++++----- tests/test_without_ipywidgets.py | 16 ++- tests/test_without_pyqt6.py | 22 +++ 5 files changed, 234 insertions(+), 48 deletions(-) create mode 100644 tests/test_without_pyqt6.py diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 76c2b1bb..e66c8d57 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2314,6 +2314,7 @@ def interactive( self, plot: Callable = None, raise_on_exception=False, + qt_exec=True, **kwargs, ): """ @@ -2321,7 +2322,8 @@ def interactive( Starts a fitting application (requires PyQt6, matplotlib) in which the fit is visualized and the parameters can be manipulated to find good - starting parameters and to debug the fit. + starting parameters and to debug the fit. The QApplication is executed + by default, but can be disabled by setting qt_exec=False. When called in a Jupyter notebook (requires ipywidgets, IPython, matplotlib), a fitting widget is returned instead, which can be displayed. @@ -2339,6 +2341,8 @@ def interactive( The default is to catch exceptions in the plot function and convert them into a plotted message. In unit tests, raise_on_exception should be set to True to allow detecting errors. + qt_exec : bool, optional + Whether the PyQt6 application is executed. This is ignored in Jupyter notebooks. **kwargs : Any other keyword arguments are forwarded to the plot function. @@ -2352,6 +2356,7 @@ def interactive( Minuit.visualize """ try: + from IPython import get_ipython if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" and "IPKernelApp" in get_ipython().config): is_jupyter = True @@ -2360,13 +2365,14 @@ def interactive( except Exception: is_jupyter = False + plot = self._visualize(plot) + if is_jupyter: from iminuit.ipywidget import make_widget + 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) + return make_widget(self, plot, kwargs, raise_on_exception, qt_exec) 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 index 3e875308..cafca11a 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -5,7 +5,6 @@ from typing import Dict, Any, Callable import sys from functools import partial -import inspect try: from PyQt6 import QtCore, QtGui, QtWidgets @@ -24,6 +23,7 @@ def make_widget( plot: Callable[..., None], kwargs: Dict[str, Any], raise_on_exception: bool, + qt_exec: bool, ): """Make interactive fitting widget.""" original_values = minuit.values[:] @@ -324,7 +324,7 @@ def plot_with_frame(self, from_fit, report_success): import traceback - self.fig.figtext( + self.fig.text( 0, 0.5, traceback.format_exc(limit=-1), @@ -414,7 +414,10 @@ def on_reset_button_clicked(self): app = QtWidgets.QApplication([]) main_window = MainWindow() main_window.show() - app.exec() + + if qt_exec: + app.exec() + return app, main_window def _make_finite(x: float) -> float: diff --git a/tests/test_draw.py b/tests/test_draw.py index b4e9e088..916449b5 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -4,6 +4,7 @@ import numpy as np from numpy.testing import assert_allclose import contextlib +from unittest.mock import patch, MagicMock mpl = pytest.importorskip("matplotlib") plt = pytest.importorskip("matplotlib.pyplot") @@ -136,7 +137,7 @@ def test_mnmatrix_7(fig): @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive(): +def test_interactive_ipywidgets(): ipywidgets = pytest.importorskip("ipywidgets") def cost(a, b): @@ -161,40 +162,137 @@ def assert_call(self): plot = Plot() m = Minuit(cost, 1, 1) + + with patch("IPython.get_ipython") as mock_get_ipython: + mock_shell = MagicMock() + mock_shell.__class__.__name__ = "ZMQInteractiveShell" + mock_shell.config = {"IPKernelApp": True} + mock_get_ipython.return_value = mock_shell + + with pytest.raises(AttributeError, match="no visualize method"): + m.interactive(raise_on_exception=True) + + with plot.assert_call(): + out1 = m.interactive(plot) + assert isinstance(out1, ipywidgets.HBox) + + # manipulate state to also check this code + ui = out1.children[1] + header, parameters = ui.children + fit_button, update_button, reset_button, algo_select = header.children + with plot.assert_call(): + fit_button.click() + assert_allclose(m.values, (0, 0), atol=1e-5) + with plot.assert_call(): + reset_button.click() + assert_allclose(m.values, (1, 1), atol=1e-5) + + algo_select.value = "Scipy" + with plot.assert_call(): + fit_button.click() + + algo_select.value = "Simplex" + with plot.assert_call(): + fit_button.click() + + update_button.value = False + with plot.assert_call(): + # because of implementation details, we have to trigger the slider several times + for i in range(5): + parameters.children[0].slider.value = i # change first slider + parameters.children[0].fix.value = True + with plot.assert_call(): + parameters.children[0].fit.value = True + + class Cost: + def visualize(self, args): + return plot(args) + + def __call__(self, a, b): + return (a - 100) ** 2 + (b + 100) ** 2 + + c = Cost() + m = Minuit(c, 0, 0) + with plot.assert_call(): + out = m.interactive(raise_on_exception=True) + + # this should modify slider range + ui = out.children[1] + header, parameters = ui.children + fit_button, update_button, reset_button, algo_select = header.children + assert parameters.children[0].slider.max == 1 + assert parameters.children[1].slider.min == -1 + with plot.assert_call(): + fit_button.click() + assert_allclose(m.values, (100, -100), atol=1e-5) + # this should trigger an exception + plot.raises = True + with plot.assert_call(): + fit_button.click() + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_pyqt6(qtbot): + PyQt6 = pytest.importorskip("PyQt6") + + def cost(a, b): + return a**2 + b**2 + + class Plot: + def __init__(self): + self.called = False + self.raises = False + + def __call__(self, args): + self.called = True + if self.raises: + raise ValueError("foo") + + @contextlib.contextmanager + def assert_call(self): + self.called = False + yield + assert self.called + + plot = Plot() + + m = Minuit(cost, 1, 1) + with pytest.raises(AttributeError, match="no visualize method"): - m.interactive(raise_on_exception=True) + mw = m.interactive(raise_on_exception=True, qt_exec=False)[1] + qtbot.addWidget(mw) + mw.close() + mw.deleteLater() with plot.assert_call(): - out1 = m.interactive(plot) - assert isinstance(out1, ipywidgets.HBox) + mw1 = m.interactive(plot, qt_exec=False)[1] + qtbot.addWidget(mw1) + assert isinstance(mw1, PyQt6.QtWidgets.QMainWindow) # manipulate state to also check this code - ui = out1.children[1] - header, parameters = ui.children - fit_button, update_button, reset_button, algo_select = header.children with plot.assert_call(): - fit_button.click() + mw1.fit_button.click() assert_allclose(m.values, (0, 0), atol=1e-5) with plot.assert_call(): - reset_button.click() + mw1.reset_button.click() assert_allclose(m.values, (1, 1), atol=1e-5) - algo_select.value = "Scipy" + mw1.algo_choice.setCurrentText("Scipy") with plot.assert_call(): - fit_button.click() + mw1.fit_button.click() - algo_select.value = "Simplex" + mw1.algo_choice.setCurrentText("Simplex") with plot.assert_call(): - fit_button.click() + mw1.fit_button.click() - update_button.value = False + mw1.update_button.click() with plot.assert_call(): - # because of implementation details, we have to trigger the slider several times - for i in range(5): - parameters.children[0].slider.value = i # change first slider - parameters.children[0].fix.value = True + mw1.parameters[0].slider.valueChanged.emit(int(5e7)) + mw1.parameters[0].fix.click() with plot.assert_call(): - parameters.children[0].fit.value = True + mw1.parameters[0].fit.click() + + mw1.close() + mw1.deleteLater() class Cost: def visualize(self, args): @@ -206,25 +304,24 @@ def __call__(self, a, b): c = Cost() m = Minuit(c, 0, 0) with plot.assert_call(): - out = m.interactive(raise_on_exception=True) + mw = m.interactive(raise_on_exception=True, qt_exec=False)[1] + qtbot.addWidget(mw) # this should modify slider range - ui = out.children[1] - header, parameters = ui.children - fit_button, update_button, reset_button, algo_select = header.children - assert parameters.children[0].slider.max == 1 - assert parameters.children[1].slider.min == -1 + assert mw.parameters[0].slider._max == 1 + assert mw.parameters[1].slider._min == -1 with plot.assert_call(): - fit_button.click() + mw.fit_button.click() assert_allclose(m.values, (100, -100), atol=1e-5) # this should trigger an exception - plot.raises = True - with plot.assert_call(): - fit_button.click() - + #plot.raises = True + #with plot.assert_call(): + # mw.fit_button.click() + mw.close() + mw.deleteLater() @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_raises(): +def test_interactive_ipywidgets_raises(): pytest.importorskip("ipywidgets") def raiser(args): @@ -233,14 +330,40 @@ def raiser(args): m = Minuit(lambda x, y: 0, 0, 1) # by default do not raise - m.interactive(raiser) + with patch("IPython.get_ipython") as mock_get_ipython: + mock_shell = MagicMock() + mock_shell.__class__.__name__ = "ZMQInteractiveShell" + mock_shell.config = {"IPKernelApp": True} + mock_get_ipython.return_value = mock_shell - with pytest.raises(ValueError): - m.interactive(raiser, raise_on_exception=True) + m.interactive(raiser) + with pytest.raises(ValueError): + m.interactive(raiser, raise_on_exception=True) @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_with_array_func(): +def test_interactive_pyqt6_raises(qtbot): + pytest.importorskip("PyQt6") + + def raiser(args): + raise ValueError + + m = Minuit(lambda x, y: 0, 0, 1) + + # by default do not raise + mw = m.interactive(raiser, qt_exec=False)[1] + qtbot.addWidget(mw) + mw.close() + mw.deleteLater() + + with pytest.raises(ValueError): + mw = m.interactive(raiser, raise_on_exception=True, qt_exec=False)[1] + qtbot.addWidget(mw) + mw.close() + mw.deleteLater() + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_ipywidgets_with_array_func(): pytest.importorskip("ipywidgets") def cost(par): @@ -254,5 +377,31 @@ def __call__(self, par): trace_args = TraceArgs() m = Minuit(cost, (1, 2)) - m.interactive(trace_args) + + with patch("IPython.get_ipython") as mock_get_ipython: + mock_shell = MagicMock() + mock_shell.__class__.__name__ = "ZMQInteractiveShell" + mock_shell.config = {"IPKernelApp": True} + mock_get_ipython.return_value = mock_shell + + m.interactive(trace_args) + assert trace_args.nargs > 0 + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_pyqt6_with_array_func(qtbot): + pytest.importorskip("PyQt6") + + def cost(par): + return par[0] ** 2 + (par[1] / 2) ** 2 + + class TraceArgs: + nargs = 0 + + def __call__(self, par): + self.nargs = len(par) + + trace_args = TraceArgs() + m = Minuit(cost, (1, 2)) + + qtbot.addWidget(m.interactive(trace_args, qt_exec=False)[1]) assert trace_args.nargs > 0 diff --git a/tests/test_without_ipywidgets.py b/tests/test_without_ipywidgets.py index fbd9b508..54365db0 100644 --- a/tests/test_without_ipywidgets.py +++ b/tests/test_without_ipywidgets.py @@ -1,18 +1,24 @@ from iminuit._hide_modules import hide_modules from iminuit.cost import LeastSquares import pytest +from unittest.mock import patch, MagicMock pytest.importorskip("ipywidgets") - def test_interactive(): pytest.importorskip("matplotlib") import iminuit cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) - iminuit.Minuit(cost, 1).interactive() + with patch("IPython.get_ipython") as mock_get_ipython: + mock_shell = MagicMock() + mock_shell.__class__.__name__ = "ZMQInteractiveShell" + mock_shell.config = {"IPKernelApp": True} + mock_get_ipython.return_value = mock_shell + + iminuit.Minuit(cost, 1).interactive() - with hide_modules("ipywidgets", reload="iminuit.ipywidget"): - with pytest.raises(ModuleNotFoundError, match="Please install"): - iminuit.Minuit(cost, 1).interactive() + with hide_modules("ipywidgets", reload="iminuit.ipywidget"): + with pytest.raises(ModuleNotFoundError, match="Please install"): + iminuit.Minuit(cost, 1).interactive() diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyqt6.py new file mode 100644 index 00000000..0bc59486 --- /dev/null +++ b/tests/test_without_pyqt6.py @@ -0,0 +1,22 @@ +from iminuit._hide_modules import hide_modules +from iminuit.cost import LeastSquares +import pytest + +pytest.importorskip("PyQt6") + +def test_interactive(qtbot): + pytest.importorskip("matplotlib") + import iminuit + + cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) + mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False)[1] + qtbot.addWidget(mw) + mw.close() + mw.deleteLater() + + with hide_modules("PyQt6", reload="iminuit.qtwidget"): + with pytest.raises(ModuleNotFoundError, match="Please install"): + mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False)[1] + qtbot.addWidget(mw) + mw.close() + mw.deleteLater() From 1367a880b589a5044c89e2a5415b20e347ac301d Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 1 Nov 2024 19:24:30 +0100 Subject: [PATCH 04/49] Bug fixes --- src/iminuit/minuit.py | 1 - src/iminuit/qtwidget.py | 185 +++++++++++++++++----------------------- 2 files changed, 80 insertions(+), 106 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index e66c8d57..3c81f2ee 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2356,7 +2356,6 @@ def interactive( Minuit.visualize """ try: - from IPython import get_ipython if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" and "IPKernelApp" in get_ipython().config): is_jupyter = True diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index cafca11a..4c188556 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -29,64 +29,6 @@ def make_widget( original_values = minuit.values[:] original_limits = minuit.limits[:] - 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, label): - super().__init__(QtCore.Qt.Orientation.Horizontal) - super().setMinimum(0) - 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=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 / 1e8) * (self._max - self._min) - - def _float_to_int(self, value): - return int((value - self._min) / (self._max - self._min) * 1e8) - - def setMinimum(self, min_value): - if self._max <= min_value: - return - self._min = min_value - self.setValue(self._value) - - def setMaximum(self, max_value): - if self._min >= max_value: - return - self._max = max_value - self.setValue(self._value) - - def setValue(self, 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._value - class Parameter(QtWidgets.QGroupBox): def __init__(self, minuit, par, callback): super().__init__("") @@ -106,7 +48,9 @@ def __init__(self, minuit, par, callback): self.value_label = QtWidgets.QLabel( alignment=QtCore.Qt.AlignmentFlag.AlignCenter) self.value_label.setMinimumSize(QtCore.QSize(50, 0)) - self.slider = FloatSlider(self.value_label) + self.slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.slider.setMinimum(0) + self.slider.setMaximum(int(1e8)) self.tmin = QtWidgets.QDoubleSpinBox( alignment=QtCore.Qt.AlignmentFlag.AlignCenter) self.tmin.setRange(_make_finite(-np.inf), _make_finite(np.inf)) @@ -138,46 +82,70 @@ def __init__(self, minuit, par, callback): layout2.addWidget(self.tmax) layout2.addWidget(self.fit) - 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 - self.tmin.setSingleStep(1e-1 * (vmax2 - vmin2)) - self.tmax.setSingleStep(1e-1 * (vmax2 - vmin2)) - self.tmin.setMinimum(_make_finite(vmin)) - self.tmax.setMaximum(_make_finite(vmax)) - self.reset(val, limits=(vmin, vmax)) - - self.slider.floatValueChanged.connect(self.on_val_change) + self.reset(minuit.values[par], limits=minuit.limits[par]) + + self.tmin.setSingleStep(1e-1 * (self.vmax - self.vmin)) + self.tmax.setSingleStep(1e-1 * (self.vmax - self.vmin)) + self.tmin.setMinimum(_make_finite(minuit.limits[par][0])) + self.tmax.setMaximum(_make_finite(minuit.limits[par][1])) + + self.slider.valueChanged.connect(self.on_val_changed) 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.tmin.valueChanged.connect(self.on_min_changed) + self.tmax.valueChanged.connect(self.on_max_changed) self.fit.clicked.connect(self.on_fit_toggled) - def on_val_change(self, val): + def _int_to_float(self, value): + return self.vmin + (value / 1e8) * (self.vmax - self.vmin) + + def _float_to_int(self, value): + return int((value - self.vmin) / (self.vmax - self.vmin) * 1e8) + + def on_val_changed(self, val): + val = self._int_to_float(val) + self.value_label.setText(f"{val:.3g}") minuit.values[self.par] = val self.callback() - def on_min_change(self): + def on_min_changed(self): tmin = self.tmin.value() - if tmin >= self.tmax.value(): + if tmin >= self.vmax: self.tmin.blockSignals(True) - self.tmin.setValue(minuit.limits[self.par][0]) + self.tmin.setValue(self.vmin) self.tmin.blockSignals(False) return - self.slider.setMinimum(tmin) + self.vmin = tmin + self.slider.blockSignals(True) + if tmin > self.val: + self.val = tmin + minuit.values[self.par] = tmin + self.slider.setValue(0) + self.value_label.setText(f"{self.val:.3g}") + self.callback() + else: + self.slider.setValue(self._float_to_int(self.val)) + self.slider.blockSignals(False) lim = minuit.limits[self.par] minuit.limits[self.par] = (tmin, lim[1]) - def on_max_change(self): + def on_max_changed(self): tmax = self.tmax.value() if tmax <= self.tmin.value(): self.tmax.blockSignals(True) - self.tmax.setValue(minuit.limits[self.par][1]) + self.tmax.setValue(self.vmax) self.tmax.blockSignals(False) return - self.slider.setMaximum(tmax) + self.vmax = tmax + self.slider.blockSignals(True) + if tmax < self.val: + self.val = tmax + minuit.values[self.par] = tmax + self.slider.setValue(int(1e8)) + self.value_label.setText(f"{self.val:.3g}") + self.callback() + else: + self.slider.setValue(self._float_to_int(self.val)) + self.slider.blockSignals(False) lim = minuit.limits[self.par] minuit.limits[self.par] = (lim[0], tmax) @@ -190,31 +158,37 @@ def on_fit_toggled(self): self.slider.setEnabled(not self.fit.isChecked()) if self.fit.isChecked(): self.fix.setChecked(False) - minuit.fixed[self.par] = False self.callback() 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(vmin) - self.slider.blockSignals(True) - self.slider.setMaximum(vmax) + step = _guess_initial_step(val, vmin, vmax) + self.vmin = vmin if np.isfinite(vmin) else val - 100 * step + self.vmax = vmax if np.isfinite(vmax) else val + 100 * step self.tmin.blockSignals(True) - self.tmin.setValue(vmin) + self.tmin.setValue(self.vmin) self.tmin.blockSignals(False) self.tmax.blockSignals(True) - self.tmax.setValue(vmax) + self.tmax.setValue(self.vmax) + self.tmax.blockSignals(False) + + self.val = val + if self.val < self.vmin: + self.vmin = self.val + self.tmin.blockSignals(True) + self.tmin.setValue(self.vmin) + self.tmin.blockSignals(False) + elif self.val > self.vmax: + self.vmax = self.val + self.tmax.blockSignals(True) + self.tmax.setValue(self.vmax) self.tmax.blockSignals(False) self.slider.blockSignals(True) - self.slider.setValue(val) - self.value_label.setText(f"{val:.3g}") + self.slider.setValue(self._float_to_int(self.val)) self.slider.blockSignals(False) + self.value_label.setText(f"{self.val:.3g}") class MainWindow(QtWidgets.QMainWindow): def __init__(self): @@ -368,19 +342,20 @@ def fit(self): 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) - self.results_text.clear() - self.results_text.setHtml(minuit._repr_html_()) - minuit.fixed = saved - else: + 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) self.results_text.clear() self.results_text.setHtml(minuit._repr_html_()) + minuit.fixed = saved + elif from_fit: + self.results_text.clear() + self.results_text.setHtml(minuit._repr_html_()) + else: + self.results_text.clear() for ax in self.fig.get_axes(): ax.clear() From 7f22e492265f2829548f2eeece0ffa5a3a0d1d25 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 1 Nov 2024 19:40:57 +0100 Subject: [PATCH 05/49] Added get_ipython import for tests --- src/iminuit/minuit.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 3c81f2ee..e66c8d57 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2356,6 +2356,7 @@ def interactive( Minuit.visualize """ try: + from IPython import get_ipython if (get_ipython().__class__.__name__ == "ZMQInteractiveShell" and "IPKernelApp" in get_ipython().config): is_jupyter = True From dae61d0d52f72bc051c9297d8e9b54903640fcf2 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 1 Nov 2024 19:53:41 +0100 Subject: [PATCH 06/49] Fixed tests --- tests/test_draw.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_draw.py b/tests/test_draw.py index 916449b5..3fd759be 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -308,8 +308,8 @@ def __call__(self, a, b): qtbot.addWidget(mw) # this should modify slider range - assert mw.parameters[0].slider._max == 1 - assert mw.parameters[1].slider._min == -1 + assert mw.parameters[0].vmax == 1 + assert mw.parameters[1].vmin == -1 with plot.assert_call(): mw.fit_button.click() assert_allclose(m.values, (100, -100), atol=1e-5) @@ -403,5 +403,8 @@ def __call__(self, par): trace_args = TraceArgs() m = Minuit(cost, (1, 2)) - qtbot.addWidget(m.interactive(trace_args, qt_exec=False)[1]) + mw = m.interactive(trace_args, qt_exec=False)[1] + qtbot.addWidget(mw) assert trace_args.nargs > 0 + mw.close() + mw.deleteLater() From aa29da3397e8e2601a5315d8891ef1e201299348 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 19:58:23 +0000 Subject: [PATCH 07/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/iminuit/cost.py | 7 +++++ src/iminuit/minuit.py | 9 +++++-- src/iminuit/qtwidget.py | 46 +++++++++++++++++--------------- tests/test_draw.py | 9 +++++-- tests/test_without_ipywidgets.py | 1 + tests/test_without_pyqt6.py | 1 + 6 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/iminuit/cost.py b/src/iminuit/cost.py index 31e3235c..c873f788 100644 --- a/src/iminuit/cost.py +++ b/src/iminuit/cost.py @@ -111,6 +111,7 @@ class documentation for details. cast, TYPE_CHECKING, ) + if TYPE_CHECKING: from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -772,6 +773,7 @@ def visualize( """ if fig is None: from matplotlib import pyplot as plt + fig = plt.gcf() n = sum(hasattr(comp, "visualize") for comp in self) @@ -970,6 +972,7 @@ def visualize( """ if ax is None: from matplotlib import pyplot as plt + ax = plt.gca() x = np.sort(self.data) @@ -1424,6 +1427,7 @@ def visualize(self, args: Sequence[float], ax: Axes = None) -> None: def _visualize(self, args: Sequence[float], ax: Axes) -> None: if ax is None: from matplotlib import pyplot as plt + ax = plt.gca() n, ne = self._n_err() @@ -1889,6 +1893,7 @@ def prediction(self, args: Sequence[float]) -> Tuple[NDArray, NDArray]: def _visualize(self, args: Sequence[float], ax: Axes) -> None: if ax is None: from matplotlib import pyplot as plt + ax = plt.gca() n, ne = self._n_err() @@ -2336,6 +2341,7 @@ def visualize( """ if ax is None: from matplotlib import pyplot as plt + ax = plt.gca() if self._ndim > 1: @@ -2540,6 +2546,7 @@ def visualize(self, args: ArrayLike, ax: Axes = None): """ if ax is None: from matplotlib import pyplot as plt + ax = plt.gca() args = np.atleast_1d(args) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index e66c8d57..18f74e41 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2357,8 +2357,11 @@ def interactive( """ try: from IPython import get_ipython - 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 @@ -2369,9 +2372,11 @@ def interactive( if is_jupyter: from iminuit.ipywidget import make_widget + return make_widget(self, plot, kwargs, raise_on_exception) else: from iminuit.qtwidget import make_widget + return make_widget(self, plot, kwargs, raise_on_exception, qt_exec) def _free_parameters(self) -> Set[str]: diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 4c188556..e5013042 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -37,25 +37,28 @@ 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 = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.slider.setMinimum(0) self.slider.setMaximum(int(1e8)) 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) @@ -66,8 +69,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() @@ -212,7 +215,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) # Use pyplot here to allow users to use pyplot in the plot @@ -237,24 +241,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) @@ -267,7 +270,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) @@ -340,8 +344,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 any(x.fit.isChecked() for x in self.parameters): saved = minuit.fixed[:] for i, x in enumerate(self.parameters): @@ -368,8 +371,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: diff --git a/tests/test_draw.py b/tests/test_draw.py index 3fd759be..647bbaa1 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -230,6 +230,7 @@ def __call__(self, a, b): with plot.assert_call(): fit_button.click() + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_interactive_pyqt6(qtbot): PyQt6 = pytest.importorskip("PyQt6") @@ -314,12 +315,13 @@ def __call__(self, a, b): mw.fit_button.click() assert_allclose(m.values, (100, -100), atol=1e-5) # this should trigger an exception - #plot.raises = True - #with plot.assert_call(): + # plot.raises = True + # with plot.assert_call(): # mw.fit_button.click() mw.close() mw.deleteLater() + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_interactive_ipywidgets_raises(): pytest.importorskip("ipywidgets") @@ -341,6 +343,7 @@ def raiser(args): with pytest.raises(ValueError): m.interactive(raiser, raise_on_exception=True) + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_interactive_pyqt6_raises(qtbot): pytest.importorskip("PyQt6") @@ -362,6 +365,7 @@ def raiser(args): mw.close() mw.deleteLater() + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_interactive_ipywidgets_with_array_func(): pytest.importorskip("ipywidgets") @@ -387,6 +391,7 @@ def __call__(self, par): m.interactive(trace_args) assert trace_args.nargs > 0 + @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_interactive_pyqt6_with_array_func(qtbot): pytest.importorskip("PyQt6") diff --git a/tests/test_without_ipywidgets.py b/tests/test_without_ipywidgets.py index 54365db0..ee1bcf0c 100644 --- a/tests/test_without_ipywidgets.py +++ b/tests/test_without_ipywidgets.py @@ -5,6 +5,7 @@ pytest.importorskip("ipywidgets") + def test_interactive(): pytest.importorskip("matplotlib") import iminuit diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyqt6.py index 0bc59486..f47bdace 100644 --- a/tests/test_without_pyqt6.py +++ b/tests/test_without_pyqt6.py @@ -4,6 +4,7 @@ pytest.importorskip("PyQt6") + def test_interactive(qtbot): pytest.importorskip("matplotlib") import iminuit From b90fdb1186349de3ba9fa88cf5ecd80200e37615 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Sun, 3 Nov 2024 17:28:55 +0100 Subject: [PATCH 08/49] Only return QMainWindow --- src/iminuit/qtwidget.py | 15 +++++++-------- tests/test_draw.py | 12 ++++++------ tests/test_without_pyqt6.py | 4 ++-- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index e5013042..d7c2cfbf 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -385,16 +385,15 @@ def on_reset_button_clicked(self): x.reset(val=minuit.values[i], limits=original_limits[i]) self.on_parameter_change() - # Set up the Qt application - app = QtWidgets.QApplication.instance() - if app is None: - app = QtWidgets.QApplication([]) - main_window = MainWindow() - main_window.show() - if qt_exec: + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + main_window = MainWindow() + main_window.show() app.exec() - return app, main_window + else: + return MainWindow() def _make_finite(x: float) -> float: diff --git a/tests/test_draw.py b/tests/test_draw.py index 647bbaa1..b60a867e 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -259,13 +259,13 @@ def assert_call(self): m = Minuit(cost, 1, 1) with pytest.raises(AttributeError, match="no visualize method"): - mw = m.interactive(raise_on_exception=True, qt_exec=False)[1] + mw = m.interactive(raise_on_exception=True, qt_exec=False) qtbot.addWidget(mw) mw.close() mw.deleteLater() with plot.assert_call(): - mw1 = m.interactive(plot, qt_exec=False)[1] + mw1 = m.interactive(plot, qt_exec=False) qtbot.addWidget(mw1) assert isinstance(mw1, PyQt6.QtWidgets.QMainWindow) @@ -305,7 +305,7 @@ def __call__(self, a, b): c = Cost() m = Minuit(c, 0, 0) with plot.assert_call(): - mw = m.interactive(raise_on_exception=True, qt_exec=False)[1] + mw = m.interactive(raise_on_exception=True, qt_exec=False) qtbot.addWidget(mw) # this should modify slider range @@ -354,13 +354,13 @@ def raiser(args): m = Minuit(lambda x, y: 0, 0, 1) # by default do not raise - mw = m.interactive(raiser, qt_exec=False)[1] + mw = m.interactive(raiser, qt_exec=False) qtbot.addWidget(mw) mw.close() mw.deleteLater() with pytest.raises(ValueError): - mw = m.interactive(raiser, raise_on_exception=True, qt_exec=False)[1] + mw = m.interactive(raiser, raise_on_exception=True, qt_exec=False) qtbot.addWidget(mw) mw.close() mw.deleteLater() @@ -408,7 +408,7 @@ def __call__(self, par): trace_args = TraceArgs() m = Minuit(cost, (1, 2)) - mw = m.interactive(trace_args, qt_exec=False)[1] + mw = m.interactive(trace_args, qt_exec=False) qtbot.addWidget(mw) assert trace_args.nargs > 0 mw.close() diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyqt6.py index f47bdace..54020c73 100644 --- a/tests/test_without_pyqt6.py +++ b/tests/test_without_pyqt6.py @@ -10,14 +10,14 @@ def test_interactive(qtbot): import iminuit cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) - mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False)[1] + mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False) qtbot.addWidget(mw) mw.close() mw.deleteLater() with hide_modules("PyQt6", reload="iminuit.qtwidget"): with pytest.raises(ModuleNotFoundError, match="Please install"): - mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False)[1] + mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False) qtbot.addWidget(mw) mw.close() mw.deleteLater() From 02fc7929701f18fc532ec62fa30222453eba5cc2 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Sun, 3 Nov 2024 22:36:56 +0100 Subject: [PATCH 09/49] Fixed centering in ComboBox --- src/iminuit/qtwidget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index d7c2cfbf..96776b76 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -261,7 +261,9 @@ def __init__(self): 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.setEditable(True) + self.algo_choice.lineEdit().setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.algo_choice.lineEdit().setReadOnly(True) self.algo_choice.addItems(["Migrad", "Scipy", "Simplex"]) button_layout.addWidget(self.algo_choice) interactive_layout.addWidget(button_group, 0, 1, 1, 1) From cba417d59dda7a7e2a06074eacece2fe8b9f73d1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:37:21 +0000 Subject: [PATCH 10/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/iminuit/qtwidget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 96776b76..3682a7ac 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -262,7 +262,9 @@ def __init__(self): button_layout.addWidget(self.reset_button) self.algo_choice = QtWidgets.QComboBox(parent=button_group) self.algo_choice.setEditable(True) - self.algo_choice.lineEdit().setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.algo_choice.lineEdit().setAlignment( + QtCore.Qt.AlignmentFlag.AlignCenter + ) self.algo_choice.lineEdit().setReadOnly(True) self.algo_choice.addItems(["Migrad", "Scipy", "Simplex"]) button_layout.addWidget(self.algo_choice) From 9485f85c203db9d276942ecd88a8a1ceff6295df Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 6 Dec 2024 16:39:30 +0100 Subject: [PATCH 11/49] Added context manager for signal blocking --- src/iminuit/qtwidget.py | 84 +++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 3682a7ac..bf409de3 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -5,6 +5,7 @@ from typing import Dict, Any, Callable import sys from functools import partial +from contextlib import contextmanager try: from PyQt6 import QtCore, QtGui, QtWidgets @@ -113,42 +114,38 @@ def on_val_changed(self, val): def on_min_changed(self): tmin = self.tmin.value() if tmin >= self.vmax: - self.tmin.blockSignals(True) - self.tmin.setValue(self.vmin) - self.tmin.blockSignals(False) + with block_signals(self.tmin): + self.tmin.setValue(self.vmin) return self.vmin = tmin - self.slider.blockSignals(True) - if tmin > self.val: - self.val = tmin - minuit.values[self.par] = tmin - self.slider.setValue(0) - self.value_label.setText(f"{self.val:.3g}") - self.callback() - else: - self.slider.setValue(self._float_to_int(self.val)) - self.slider.blockSignals(False) + with block_signals(self.slider): + if tmin > self.val: + self.val = tmin + minuit.values[self.par] = tmin + self.slider.setValue(0) + self.value_label.setText(f"{self.val:.3g}") + self.callback() + else: + self.slider.setValue(self._float_to_int(self.val)) lim = minuit.limits[self.par] minuit.limits[self.par] = (tmin, lim[1]) def on_max_changed(self): tmax = self.tmax.value() if tmax <= self.tmin.value(): - self.tmax.blockSignals(True) - self.tmax.setValue(self.vmax) - self.tmax.blockSignals(False) + with block_signals(self.tmax): + self.tmax.setValue(self.vmax) return self.vmax = tmax - self.slider.blockSignals(True) - if tmax < self.val: - self.val = tmax - minuit.values[self.par] = tmax - self.slider.setValue(int(1e8)) - self.value_label.setText(f"{self.val:.3g}") - self.callback() - else: - self.slider.setValue(self._float_to_int(self.val)) - self.slider.blockSignals(False) + with block_signals(self.slider): + if tmax < self.val: + self.val = tmax + minuit.values[self.par] = tmax + self.slider.setValue(int(1e8)) + self.value_label.setText(f"{self.val:.3g}") + self.callback() + else: + self.slider.setValue(self._float_to_int(self.val)) lim = minuit.limits[self.par] minuit.limits[self.par] = (lim[0], tmax) @@ -169,28 +166,22 @@ def reset(self, val, limits=None): step = _guess_initial_step(val, vmin, vmax) self.vmin = vmin if np.isfinite(vmin) else val - 100 * step self.vmax = vmax if np.isfinite(vmax) else val + 100 * step - self.tmin.blockSignals(True) - self.tmin.setValue(self.vmin) - self.tmin.blockSignals(False) - self.tmax.blockSignals(True) - self.tmax.setValue(self.vmax) - self.tmax.blockSignals(False) + with block_signals(self.tmin, self.tmax): + self.tmin.setValue(self.vmin) + self.tmax.setValue(self.vmax) self.val = val if self.val < self.vmin: self.vmin = self.val - self.tmin.blockSignals(True) - self.tmin.setValue(self.vmin) - self.tmin.blockSignals(False) + with block_signals(self.tmin): + self.tmin.setValue(self.vmin) elif self.val > self.vmax: self.vmax = self.val - self.tmax.blockSignals(True) - self.tmax.setValue(self.vmax) - self.tmax.blockSignals(False) + with block_signals(self.tmax): + self.tmax.setValue(self.vmax) - self.slider.blockSignals(True) - self.slider.setValue(self._float_to_int(self.val)) - self.slider.blockSignals(False) + with block_signals(self.slider): + self.slider.setValue(self._float_to_int(self.val)) self.value_label.setText(f"{self.val:.3g}") class MainWindow(QtWidgets.QMainWindow): @@ -400,6 +391,17 @@ def on_reset_button_clicked(self): return MainWindow() +@contextmanager +def block_signals(*widgets): + for w in widgets: + w.blockSignals(True) + try: + yield + finally: + for w in widgets: + w.blockSignals(False) + + def _make_finite(x: float) -> float: sign = -1 if x < 0 else 1 if abs(x) == np.inf: From e20f357b5734260ac69a74a0e50287850bd44d19 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 6 Dec 2024 16:50:53 +0100 Subject: [PATCH 12/49] Changed partial to lambda --- src/iminuit/qtwidget.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index bf409de3..979023ad 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -4,7 +4,6 @@ import numpy as np from typing import Dict, Any, Callable import sys -from functools import partial from contextlib import contextmanager try: @@ -238,7 +237,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(partial(self.do_fit, plot=True)) + self.fit_button.clicked.connect(lambda: self.do_fit(plot=True)) button_layout.addWidget(self.fit_button) self.update_button = QtWidgets.QPushButton( "Continuous", parent=button_group From 2342b7de04cd52bd47c36f170c4d26a8a5b5ec21 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 6 Dec 2024 21:22:51 +0100 Subject: [PATCH 13/49] Removed qt_exec arg and changed QMainWindow to QWidget --- src/iminuit/minuit.py | 11 ++++------- src/iminuit/qtwidget.py | 16 ++++++---------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 18f74e41..92270c7e 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2314,7 +2314,6 @@ def interactive( self, plot: Callable = None, raise_on_exception=False, - qt_exec=True, **kwargs, ): """ @@ -2322,8 +2321,9 @@ def interactive( Starts a fitting application (requires PyQt6, matplotlib) in which the fit is visualized and the parameters can be manipulated to find good - starting parameters and to debug the fit. The QApplication is executed - by default, but can be disabled by setting qt_exec=False. + starting parameters and to debug the fit. If there is a QApplication + already running, the widget will be returned instead of starting the + application. When called in a Jupyter notebook (requires ipywidgets, IPython, matplotlib), a fitting widget is returned instead, which can be displayed. @@ -2341,8 +2341,6 @@ def interactive( The default is to catch exceptions in the plot function and convert them into a plotted message. In unit tests, raise_on_exception should be set to True to allow detecting errors. - qt_exec : bool, optional - Whether the PyQt6 application is executed. This is ignored in Jupyter notebooks. **kwargs : Any other keyword arguments are forwarded to the plot function. @@ -2373,11 +2371,10 @@ def interactive( if is_jupyter: from iminuit.ipywidget import make_widget - return make_widget(self, plot, kwargs, raise_on_exception) else: from iminuit.qtwidget import make_widget - return make_widget(self, plot, kwargs, raise_on_exception, qt_exec) + 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 index 979023ad..b3dd603d 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -23,7 +23,6 @@ def make_widget( plot: Callable[..., None], kwargs: Dict[str, Any], raise_on_exception: bool, - qt_exec: bool, ): """Make interactive fitting widget.""" original_values = minuit.values[:] @@ -183,7 +182,7 @@ def reset(self, val, limits=None): self.slider.setValue(self._float_to_int(self.val)) self.value_label.setText(f"{self.val:.3g}") - class MainWindow(QtWidgets.QMainWindow): + class Widget(QtWidgets.QWidget): def __init__(self): super().__init__() self.resize(1200, 600) @@ -191,7 +190,6 @@ def __init__(self): font.setPointSize(12) self.setFont(font) centralwidget = QtWidgets.QWidget(parent=self) - self.setCentralWidget(centralwidget) central_layout = QtWidgets.QVBoxLayout(centralwidget) tab = QtWidgets.QTabWidget(parent=centralwidget) interactive_tab = QtWidgets.QWidget() @@ -379,15 +377,13 @@ def on_reset_button_clicked(self): x.reset(val=minuit.values[i], limits=original_limits[i]) self.on_parameter_change() - if qt_exec: - app = QtWidgets.QApplication.instance() - if app is None: - app = QtWidgets.QApplication([]) - main_window = MainWindow() - main_window.show() + if QtWidgets.QApplication.instance() is None: + app = QtWidgets.QApplication([]) + widget = Widget() + widget.show() app.exec() else: - return MainWindow() + return Widget() @contextmanager From 92f7c832df87b05e4f6a1b12739e6f250e26dd14 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 6 Dec 2024 22:36:20 +0100 Subject: [PATCH 14/49] Removed tabs and added more decimals to SpinBox --- src/iminuit/qtwidget.py | 48 ++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index b3dd603d..2d31aa29 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -86,8 +86,12 @@ def __init__(self, minuit, par, callback): self.reset(minuit.values[par], limits=minuit.limits[par]) - self.tmin.setSingleStep(1e-1 * (self.vmax - self.vmin)) - self.tmax.setSingleStep(1e-1 * (self.vmax - self.vmin)) + step_size = 1e-1 * (self.vmax - self.vmin) + decimals = max(int(-np.log10(step_size)) + 2, 0) + self.tmin.setSingleStep(step_size) + self.tmin.setDecimals(decimals) + self.tmax.setSingleStep(step_size) + self.tmax.setDecimals(decimals) self.tmin.setMinimum(_make_finite(minuit.limits[par][0])) self.tmax.setMaximum(_make_finite(minuit.limits[par][1])) @@ -185,22 +189,15 @@ def reset(self, val, limits=None): class Widget(QtWidgets.QWidget): def __init__(self): super().__init__() - self.resize(1200, 600) + self.resize(1200, 800) font = QtGui.QFont() font.setPointSize(12) self.setFont(font) - centralwidget = QtWidgets.QWidget(parent=self) - central_layout = QtWidgets.QVBoxLayout(centralwidget) - 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_layout = QtWidgets.QGridLayout(interactive_tab) - - plot_group = QtWidgets.QGroupBox("", parent=interactive_tab) + self.setWindowTitle("iminuit") + + interactive_layout = QtWidgets.QGridLayout(self) + + plot_group = QtWidgets.QGroupBox("", parent=self) size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding, @@ -226,7 +223,7 @@ def __init__(self): pass self.fig_width = self.fig.get_figwidth() - button_group = QtWidgets.QGroupBox("", parent=interactive_tab) + button_group = QtWidgets.QGroupBox("", parent=self) size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed, @@ -258,17 +255,17 @@ def __init__(self): button_layout.addWidget(self.algo_choice) interactive_layout.addWidget(button_group, 0, 1, 1, 1) - scroll_area = QtWidgets.QScrollArea() - scroll_area.setWidgetResizable(True) + par_scroll_area = QtWidgets.QScrollArea() + par_scroll_area.setWidgetResizable(True) size_policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding, ) - scroll_area.setSizePolicy(size_policy) + par_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) + par_scroll_area.setWidget(scroll_area_contents) + interactive_layout.addWidget(par_scroll_area, 1, 1, 2, 1) self.parameters = [] for par in minuit.parameters: parameter = Parameter(minuit, par, self.on_parameter_change) @@ -276,10 +273,13 @@ def __init__(self): parameter_layout.addWidget(parameter) parameter_layout.addStretch() - results_layout = QtWidgets.QVBoxLayout(results_tab) - self.results_text = QtWidgets.QTextEdit(parent=results_tab) + results_scroll_area = QtWidgets.QScrollArea() + results_scroll_area.setWidgetResizable(True) + results_scroll_area.setSizePolicy(size_policy) + self.results_text = QtWidgets.QTextEdit(parent=self) self.results_text.setReadOnly(True) - results_layout.addWidget(self.results_text) + results_scroll_area.setWidget(self.results_text) + interactive_layout.addWidget(results_scroll_area, 2, 0, 1, 1) self.plot_with_frame(from_fit=False, report_success=False) From cf0ee819eab450e420997af2b0fc190204d223f5 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Wed, 11 Dec 2024 18:09:14 +0100 Subject: [PATCH 15/49] Moved _make_finite and _guess_initial_step to util --- src/iminuit/ipywidget.py | 16 ++-------------- src/iminuit/qtwidget.py | 16 ++-------------- src/iminuit/util.py | 13 +++++++++++++ 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/iminuit/ipywidget.py b/src/iminuit/ipywidget.py index f56bdeef..f006cf01 100644 --- a/src/iminuit/ipywidget.py +++ b/src/iminuit/ipywidget.py @@ -1,9 +1,10 @@ """Interactive fitting widget for Jupyter notebooks.""" +from .util import _widget_guess_initial_step as _guess_initial_step +from .util import _widget_make_finite as _make_finite 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 :( @@ -277,18 +278,5 @@ def reset(self, value, limits=None): return widgets.HBox([out, ui]) -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}") diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 2d31aa29..28ccdb5d 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -1,9 +1,10 @@ """Interactive fitting widget using PyQt6.""" +from .util import _widget_guess_initial_step as _guess_initial_step +from .util import _widget_make_finite as _make_finite import warnings import numpy as np from typing import Dict, Any, Callable -import sys from contextlib import contextmanager try: @@ -395,16 +396,3 @@ def block_signals(*widgets): finally: for w in widgets: w.blockSignals(False) - - -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 diff --git a/src/iminuit/util.py b/src/iminuit/util.py index 3db8bb51..8df75095 100644 --- a/src/iminuit/util.py +++ b/src/iminuit/util.py @@ -1684,3 +1684,16 @@ def is_positive_definite(m: ArrayLike) -> bool: return False return True return False + + +def _widget_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 _widget_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 From 7a6f7afa2112432efaacecb17a7fe9d12511bf7e Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Wed, 11 Dec 2024 18:30:49 +0100 Subject: [PATCH 16/49] Changed starting of event loop --- src/iminuit/qtwidget.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 28ccdb5d..d8fab513 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -378,11 +378,17 @@ def on_reset_button_clicked(self): x.reset(val=minuit.values[i], limits=original_limits[i]) self.on_parameter_change() - if QtWidgets.QApplication.instance() is None: + app = QtWidgets.QApplication.instance() + if app is None: app = QtWidgets.QApplication([]) + app.setApplicationName("iminuit") widget = Widget() widget.show() - app.exec() + app.exec() # this blocks the main thread + elif app.applicationName() == "iminuit": + widget = Widget() + widget.show() + app.exec() # this blocks the main thread else: return Widget() From 1ba806509c8ee09633a78bfa31fb4133c5a8dfb1 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Wed, 11 Dec 2024 20:39:16 +0100 Subject: [PATCH 17/49] Removed unnecessary lines --- tests/test_without_pyqt6.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyqt6.py index 54020c73..0c746487 100644 --- a/tests/test_without_pyqt6.py +++ b/tests/test_without_pyqt6.py @@ -10,14 +10,7 @@ def test_interactive(qtbot): import iminuit cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) - mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False) - qtbot.addWidget(mw) - mw.close() - mw.deleteLater() with hide_modules("PyQt6", reload="iminuit.qtwidget"): with pytest.raises(ModuleNotFoundError, match="Please install"): - mw = iminuit.Minuit(cost, 1).interactive(qt_exec=False) - qtbot.addWidget(mw) - mw.close() - mw.deleteLater() + iminuit.Minuit(cost, 1).interactive() From 0901a462d8312672b13fd7822cf50d93f060557c Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Wed, 11 Dec 2024 21:58:13 +0100 Subject: [PATCH 18/49] Clean up --- tests/test_draw.py | 197 ++++++++++++++++++++------------------------- 1 file changed, 86 insertions(+), 111 deletions(-) diff --git a/tests/test_draw.py b/tests/test_draw.py index b60a867e..49fe7636 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -34,6 +34,16 @@ def fig(request): plt.close() +@pytest.fixture +def mock_ipython(): + with patch("IPython.get_ipython") as mock_get_ipython: + mock_shell = MagicMock() + mock_shell.__class__.__name__ = "ZMQInteractiveShell" + mock_shell.config = {"IPKernelApp": True} + mock_get_ipython.return_value = mock_shell + yield + + @pytest.mark.parametrize("arg", ("x", 1)) def test_profile_1(fig, minuit, arg): minuit.draw_profile(arg) @@ -137,7 +147,7 @@ def test_mnmatrix_7(fig): @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_ipywidgets(): +def test_interactive_ipywidgets(mock_ipython): ipywidgets = pytest.importorskip("ipywidgets") def cost(a, b): @@ -163,72 +173,66 @@ def assert_call(self): m = Minuit(cost, 1, 1) - with patch("IPython.get_ipython") as mock_get_ipython: - mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" - mock_shell.config = {"IPKernelApp": True} - mock_get_ipython.return_value = mock_shell + with pytest.raises(AttributeError, match="no visualize method"): + m.interactive(raise_on_exception=True) + + with plot.assert_call(): + out1 = m.interactive(plot) + assert isinstance(out1, ipywidgets.HBox) + + # manipulate state to also check this code + ui = out1.children[1] + header, parameters = ui.children + fit_button, update_button, reset_button, algo_select = header.children + with plot.assert_call(): + fit_button.click() + assert_allclose(m.values, (0, 0), atol=1e-5) + with plot.assert_call(): + reset_button.click() + assert_allclose(m.values, (1, 1), atol=1e-5) + + algo_select.value = "Scipy" + with plot.assert_call(): + fit_button.click() + + algo_select.value = "Simplex" + with plot.assert_call(): + fit_button.click() + + update_button.value = False + with plot.assert_call(): + # because of implementation details, we have to trigger the slider several times + for i in range(5): + parameters.children[0].slider.value = i # change first slider + parameters.children[0].fix.value = True + with plot.assert_call(): + parameters.children[0].fit.value = True + + class Cost: + def visualize(self, args): + return plot(args) + + def __call__(self, a, b): + return (a - 100) ** 2 + (b + 100) ** 2 + + c = Cost() + m = Minuit(c, 0, 0) + with plot.assert_call(): + out = m.interactive(raise_on_exception=True) - with pytest.raises(AttributeError, match="no visualize method"): - m.interactive(raise_on_exception=True) - - with plot.assert_call(): - out1 = m.interactive(plot) - assert isinstance(out1, ipywidgets.HBox) - - # manipulate state to also check this code - ui = out1.children[1] - header, parameters = ui.children - fit_button, update_button, reset_button, algo_select = header.children - with plot.assert_call(): - fit_button.click() - assert_allclose(m.values, (0, 0), atol=1e-5) - with plot.assert_call(): - reset_button.click() - assert_allclose(m.values, (1, 1), atol=1e-5) - - algo_select.value = "Scipy" - with plot.assert_call(): - fit_button.click() - - algo_select.value = "Simplex" - with plot.assert_call(): - fit_button.click() - - update_button.value = False - with plot.assert_call(): - # because of implementation details, we have to trigger the slider several times - for i in range(5): - parameters.children[0].slider.value = i # change first slider - parameters.children[0].fix.value = True - with plot.assert_call(): - parameters.children[0].fit.value = True - - class Cost: - def visualize(self, args): - return plot(args) - - def __call__(self, a, b): - return (a - 100) ** 2 + (b + 100) ** 2 - - c = Cost() - m = Minuit(c, 0, 0) - with plot.assert_call(): - out = m.interactive(raise_on_exception=True) - - # this should modify slider range - ui = out.children[1] - header, parameters = ui.children - fit_button, update_button, reset_button, algo_select = header.children - assert parameters.children[0].slider.max == 1 - assert parameters.children[1].slider.min == -1 - with plot.assert_call(): - fit_button.click() - assert_allclose(m.values, (100, -100), atol=1e-5) - # this should trigger an exception - plot.raises = True - with plot.assert_call(): - fit_button.click() + # this should modify slider range + ui = out.children[1] + header, parameters = ui.children + fit_button, update_button, reset_button, algo_select = header.children + assert parameters.children[0].slider.max == 1 + assert parameters.children[1].slider.min == -1 + with plot.assert_call(): + fit_button.click() + assert_allclose(m.values, (100, -100), atol=1e-5) + # this should trigger an exception + plot.raises = True + with plot.assert_call(): + fit_button.click() @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -259,15 +263,12 @@ def assert_call(self): m = Minuit(cost, 1, 1) with pytest.raises(AttributeError, match="no visualize method"): - mw = m.interactive(raise_on_exception=True, qt_exec=False) - qtbot.addWidget(mw) - mw.close() - mw.deleteLater() + m.interactive(raise_on_exception=True) with plot.assert_call(): - mw1 = m.interactive(plot, qt_exec=False) + mw1 = m.interactive(plot) qtbot.addWidget(mw1) - assert isinstance(mw1, PyQt6.QtWidgets.QMainWindow) + assert isinstance(mw1, PyQt6.QtWidgets.QWidget) # manipulate state to also check this code with plot.assert_call(): @@ -292,9 +293,6 @@ def assert_call(self): with plot.assert_call(): mw1.parameters[0].fit.click() - mw1.close() - mw1.deleteLater() - class Cost: def visualize(self, args): return plot(args) @@ -305,7 +303,7 @@ def __call__(self, a, b): c = Cost() m = Minuit(c, 0, 0) with plot.assert_call(): - mw = m.interactive(raise_on_exception=True, qt_exec=False) + mw = m.interactive(raise_on_exception=True) qtbot.addWidget(mw) # this should modify slider range @@ -315,15 +313,13 @@ def __call__(self, a, b): mw.fit_button.click() assert_allclose(m.values, (100, -100), atol=1e-5) # this should trigger an exception - # plot.raises = True - # with plot.assert_call(): + #plot.raises = True + #with plot.assert_call(): # mw.fit_button.click() - mw.close() - mw.deleteLater() @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_ipywidgets_raises(): +def test_interactive_ipywidgets_raises(mock_ipython): pytest.importorskip("ipywidgets") def raiser(args): @@ -332,16 +328,10 @@ def raiser(args): m = Minuit(lambda x, y: 0, 0, 1) # by default do not raise - with patch("IPython.get_ipython") as mock_get_ipython: - mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" - mock_shell.config = {"IPKernelApp": True} - mock_get_ipython.return_value = mock_shell + m.interactive(raiser) - m.interactive(raiser) - - with pytest.raises(ValueError): - m.interactive(raiser, raise_on_exception=True) + with pytest.raises(ValueError): + m.interactive(raiser, raise_on_exception=True) @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -354,20 +344,14 @@ def raiser(args): m = Minuit(lambda x, y: 0, 0, 1) # by default do not raise - mw = m.interactive(raiser, qt_exec=False) - qtbot.addWidget(mw) - mw.close() - mw.deleteLater() + m.interactive(raiser) with pytest.raises(ValueError): - mw = m.interactive(raiser, raise_on_exception=True, qt_exec=False) - qtbot.addWidget(mw) - mw.close() - mw.deleteLater() + m.interactive(raiser, raise_on_exception=True) @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_ipywidgets_with_array_func(): +def test_interactive_ipywidgets_with_array_func(mock_ipython): pytest.importorskip("ipywidgets") def cost(par): @@ -382,14 +366,8 @@ def __call__(self, par): trace_args = TraceArgs() m = Minuit(cost, (1, 2)) - with patch("IPython.get_ipython") as mock_get_ipython: - mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" - mock_shell.config = {"IPKernelApp": True} - mock_get_ipython.return_value = mock_shell - - m.interactive(trace_args) - assert trace_args.nargs > 0 + m.interactive(trace_args) + assert trace_args.nargs > 0 @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -408,8 +386,5 @@ def __call__(self, par): trace_args = TraceArgs() m = Minuit(cost, (1, 2)) - mw = m.interactive(trace_args, qt_exec=False) - qtbot.addWidget(mw) + m.interactive(trace_args) assert trace_args.nargs > 0 - mw.close() - mw.deleteLater() From f7c4fd5342e8621032a66dbe5980dd9b230de212 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Wed, 11 Dec 2024 22:17:47 +0100 Subject: [PATCH 19/49] Moved mock ipython fixture to conftest.py --- tests/conftest.py | 11 +++++++++++ tests/test_draw.py | 10 ---------- tests/test_without_ipywidgets.py | 17 +++++------------ 3 files changed, 16 insertions(+), 22 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..f8096e30 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest +from unittest.mock import patch, MagicMock + +@pytest.fixture +def mock_ipython(): + with patch("IPython.get_ipython") as mock_get_ipython: + mock_shell = MagicMock() + mock_shell.__class__.__name__ = "ZMQInteractiveShell" + mock_shell.config = {"IPKernelApp": True} + mock_get_ipython.return_value = mock_shell + yield diff --git a/tests/test_draw.py b/tests/test_draw.py index 49fe7636..b0c165cb 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -34,16 +34,6 @@ def fig(request): plt.close() -@pytest.fixture -def mock_ipython(): - with patch("IPython.get_ipython") as mock_get_ipython: - mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" - mock_shell.config = {"IPKernelApp": True} - mock_get_ipython.return_value = mock_shell - yield - - @pytest.mark.parametrize("arg", ("x", 1)) def test_profile_1(fig, minuit, arg): minuit.draw_profile(arg) diff --git a/tests/test_without_ipywidgets.py b/tests/test_without_ipywidgets.py index ee1bcf0c..7e2df799 100644 --- a/tests/test_without_ipywidgets.py +++ b/tests/test_without_ipywidgets.py @@ -1,25 +1,18 @@ from iminuit._hide_modules import hide_modules from iminuit.cost import LeastSquares import pytest -from unittest.mock import patch, MagicMock pytest.importorskip("ipywidgets") -def test_interactive(): +def test_interactive(mock_ipython): pytest.importorskip("matplotlib") import iminuit cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) - with patch("IPython.get_ipython") as mock_get_ipython: - mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" - mock_shell.config = {"IPKernelApp": True} - mock_get_ipython.return_value = mock_shell + iminuit.Minuit(cost, 1).interactive() - iminuit.Minuit(cost, 1).interactive() - - with hide_modules("ipywidgets", reload="iminuit.ipywidget"): - with pytest.raises(ModuleNotFoundError, match="Please install"): - iminuit.Minuit(cost, 1).interactive() + with hide_modules("ipywidgets", reload="iminuit.ipywidget"): + with pytest.raises(ModuleNotFoundError, match="Please install"): + iminuit.Minuit(cost, 1).interactive() From 2813c5131db0e6fe1024ba39d3725937fdc9bb31 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 11 Dec 2024 21:31:31 +0000 Subject: [PATCH 20/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/conftest.py | 1 + tests/test_draw.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f8096e30..eb4b20fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import pytest from unittest.mock import patch, MagicMock + @pytest.fixture def mock_ipython(): with patch("IPython.get_ipython") as mock_get_ipython: diff --git a/tests/test_draw.py b/tests/test_draw.py index b0c165cb..699fc415 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -4,7 +4,6 @@ import numpy as np from numpy.testing import assert_allclose import contextlib -from unittest.mock import patch, MagicMock mpl = pytest.importorskip("matplotlib") plt = pytest.importorskip("matplotlib.pyplot") @@ -303,8 +302,8 @@ def __call__(self, a, b): mw.fit_button.click() assert_allclose(m.values, (100, -100), atol=1e-5) # this should trigger an exception - #plot.raises = True - #with plot.assert_call(): + # plot.raises = True + # with plot.assert_call(): # mw.fit_button.click() From a50b5e52cff4a492f1a24ebf1c65e38ac60c4f34 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Wed, 11 Dec 2024 23:24:25 +0100 Subject: [PATCH 21/49] Removed repeated lines --- src/iminuit/qtwidget.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index d8fab513..0607fdc6 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -382,10 +382,8 @@ def on_reset_button_clicked(self): if app is None: app = QtWidgets.QApplication([]) app.setApplicationName("iminuit") - widget = Widget() - widget.show() - app.exec() # this blocks the main thread - elif app.applicationName() == "iminuit": + + if app.applicationName() == "iminuit": widget = Widget() widget.show() app.exec() # this blocks the main thread From b1fbd3f22a60d30f771c8d970933b8b2063cea15 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 12 Dec 2024 01:53:32 +0100 Subject: [PATCH 22/49] Back to pyplot --- src/iminuit/cost.py | 114 +++++++++++----------------------------- src/iminuit/qtwidget.py | 37 +++++-------- 2 files changed, 46 insertions(+), 105 deletions(-) diff --git a/src/iminuit/cost.py b/src/iminuit/cost.py index c873f788..76a75436 100644 --- a/src/iminuit/cost.py +++ b/src/iminuit/cost.py @@ -109,12 +109,7 @@ class documentation for details. TypeVar, Callable, cast, - TYPE_CHECKING, ) - -if TYPE_CHECKING: - from matplotlib.axes import Axes - from matplotlib.figure import Figure import warnings from ._deprecated import deprecated_parameter @@ -744,10 +739,7 @@ def __getitem__(self, key): return self._items.__getitem__(key) def visualize( - self, - args: Sequence[float], - component_kwargs: Dict[int, Dict[str, Any]] = None, - fig: Figure = None, + self, args: Sequence[float], component_kwargs: Dict[int, Dict[str, Any]] = None ): """ Visualize data and model agreement (requires matplotlib). @@ -765,27 +757,16 @@ def visualize( Dict that maps an index to dict of keyword arguments. This can be used to pass keyword arguments to a visualize method of a component with that index. - fig : Figure, optional - The matplotlib figure into which the visualization is drawn. - If None is passed, the current figure is used. If the passed figure - has the same number of axes as there are components with a visualize - method, the axes are reused. Otherwise, new axes are created. + **kwargs : + Other keyword arguments are forwarded to all components. """ - if fig is None: - from matplotlib import pyplot as plt - - fig = plt.gcf() + from matplotlib import pyplot as plt n = sum(hasattr(comp, "visualize") for comp in self) + fig = plt.gcf() fig.set_figwidth(n * fig.get_figwidth() / 1.5) - ax = fig.get_axes() - if len(ax) != n: - fig.clear() - _, ax = fig.subplots(1, n) - # For some reason fig.subplots does not return axes array but only - # a single axes even for n > 1 - ax = fig.get_axes() + _, ax = plt.subplots(1, n, num=fig.number) if component_kwargs is None: component_kwargs = {} @@ -795,7 +776,8 @@ def visualize( if not hasattr(comp, "visualize"): continue kwargs = component_kwargs.get(k, {}) - comp.visualize(cargs, ax=ax[i], **kwargs) + plt.sca(ax[i]) + comp.visualize(cargs, **kwargs) i += 1 @@ -949,7 +931,6 @@ def visualize( args: Sequence[float], model_points: Union[int, Sequence[float]] = 0, bins: int = 50, - ax: Axes = None, ): """ Visualize data and model agreement (requires matplotlib). @@ -966,14 +947,8 @@ def visualize( it is interpreted as the point locations. bins : int, optional number of bins. Default is 50 bins. - ax : Axes, optional - The matplotlib axes into which the visualization is drawn. - If None is passed, the current axes is used. """ - if ax is None: - from matplotlib import pyplot as plt - - ax = plt.gca() + from matplotlib import pyplot as plt x = np.sort(self.data) @@ -999,8 +974,8 @@ def visualize( cx = 0.5 * (xe[1:] + xe[:-1]) dx = xe[1] - xe[0] - ax.errorbar(cx, n, n**0.5, fmt="ok") - ax.fill_between(xm, 0, ym * dx, fc="C0") + plt.errorbar(cx, n, n**0.5, fmt="ok") + plt.fill_between(xm, 0, ym * dx, fc="C0") def fisher_information(self, *args: float) -> NDArray: """ @@ -1400,7 +1375,7 @@ def prediction( """ return self._pred(args) - def visualize(self, args: Sequence[float], ax: Axes = None) -> None: + def visualize(self, args: Sequence[float]) -> None: """ Visualize data and model agreement (requires matplotlib). @@ -1410,9 +1385,6 @@ def visualize(self, args: Sequence[float], ax: Axes = None) -> None: ---------- args : sequence of float Parameter values. - ax : Axes, optional - The matplotlib axes into which the visualization is drawn. - If None is passed, the current axes is used. Notes ----- @@ -1422,13 +1394,10 @@ def visualize(self, args: Sequence[float], ax: Axes = None) -> None: comparison to a model, the visualization shows all data bins as a single sequence. """ - return self._visualize(args, ax) - - def _visualize(self, args: Sequence[float], ax: Axes) -> None: - if ax is None: - from matplotlib import pyplot as plt + return self._visualize(args) - ax = plt.gca() + def _visualize(self, args: Sequence[float]) -> None: + from matplotlib import pyplot as plt n, ne = self._n_err() mu = self.prediction(args) @@ -1445,9 +1414,8 @@ def _visualize(self, args: Sequence[float], ax: Axes) -> None: else: xe = self.xe cx = 0.5 * (xe[1:] + xe[:-1]) - - ax.errorbar(cx, n, ne, fmt="ok") - ax.stairs(mu, xe, fill=True, color="C0") + plt.errorbar(cx, n, ne, fmt="ok") + plt.stairs(mu, xe, fill=True, color="C0") @abc.abstractmethod def _pred( @@ -1890,11 +1858,8 @@ def prediction(self, args: Sequence[float]) -> Tuple[NDArray, NDArray]: mu, mu_var = self._pred(args) return mu, np.sqrt(mu_var) - def _visualize(self, args: Sequence[float], ax: Axes) -> None: - if ax is None: - from matplotlib import pyplot as plt - - ax = plt.gca() + def _visualize(self, args: Sequence[float]) -> None: + from matplotlib import pyplot as plt n, ne = self._n_err() mu, mue = self.prediction(args) # type: ignore @@ -1911,11 +1876,11 @@ def _visualize(self, args: Sequence[float], ax: Axes) -> None: xe = self.xe cx = 0.5 * (xe[1:] + xe[:-1]) - ax.errorbar(cx, n, ne, fmt="ok") + plt.errorbar(cx, n, ne, fmt="ok") # need fill=True and fill=False so that bins with mue=0 show up for fill in (False, True): - ax.stairs(mu + mue, xe, baseline=mu - mue, fill=fill, color="C0") + plt.stairs(mu + mue, xe, baseline=mu - mue, fill=fill, color="C0") def _pulls(self, args: Sequence[float]) -> NDArray: mu, mue = self.prediction(args) @@ -2316,10 +2281,7 @@ def _ndata(self): return len(self._masked) def visualize( - self, - args: ArrayLike, - model_points: Union[int, Sequence[float]] = 0, - ax: Axes = None, + self, args: ArrayLike, model_points: Union[int, Sequence[float]] = 0 ) -> Tuple[Tuple[NDArray, NDArray, NDArray], Tuple[NDArray, NDArray]]: """ Visualize data and model agreement (requires matplotlib). @@ -2335,20 +2297,14 @@ def visualize( How many points to use to draw the model. Default is 0, in this case an smart sampling algorithm selects the number of points. If array-like, it is interpreted as the point locations. - ax : Axes, optional - The matplotlib axes into which the visualization is drawn. - If None is passed, the current axes is used. """ - if ax is None: - from matplotlib import pyplot as plt - - ax = plt.gca() + from matplotlib import pyplot as plt if self._ndim > 1: raise ValueError("visualize is not implemented for multi-dimensional data") x, y, ye = self._masked.T - ax.errorbar(x, y, ye, fmt="ok") + plt.errorbar(x, y, ye, fmt="ok") if isinstance(model_points, Iterable): xm = np.array(model_points) ym = self.model(xm, *args) @@ -2360,7 +2316,7 @@ def visualize( ym = self.model(xm, *args) else: xm, ym = _smart_sampling(lambda x: self.model(x, *args), x[0], x[-1]) - ax.plot(xm, ym) + plt.plot(xm, ym) return (x, y, ye), (xm, ym) def prediction(self, args: Sequence[float]) -> NDArray: @@ -2530,7 +2486,7 @@ def _has_grad(self) -> bool: def _ndata(self): return len(self._expected) - def visualize(self, args: ArrayLike, ax: Axes = None): + def visualize(self, args: ArrayLike): """ Visualize data and model agreement (requires matplotlib). @@ -2540,14 +2496,8 @@ def visualize(self, args: ArrayLike, ax: Axes = None): ---------- args : array-like Parameter values. - ax : Axes, optional - The matplotlib axes into which the visualization is drawn. - If None is passed, the current axes is used. """ - if ax is None: - from matplotlib import pyplot as plt - - ax = plt.gca() + from matplotlib import pyplot as plt args = np.atleast_1d(args) @@ -2565,14 +2515,14 @@ def visualize(self, args: ArrayLike, ax: Axes = None): for v, e, a in zip(val, err, args): pull = (a - v) / e max_pull = max(abs(pull), max_pull) - ax.errorbar(pull, -i, 0, 1, fmt="o", color="C0") + plt.errorbar(pull, -i, 0, 1, fmt="o", color="C0") i += 1 - ax.axvline(0, color="k") - ax.set_xlim(-max_pull - 1.1, max_pull + 1.1) - yaxis = ax.yaxis + plt.axvline(0, color="k") + plt.xlim(-max_pull - 1.1, max_pull + 1.1) + yaxis = plt.gca().yaxis yaxis.set_ticks(-np.arange(n)) yaxis.set_ticklabels(par) - ax.set_ylim(-n + 0.5, 0.5) + plt.ylim(-n + 0.5, 0.5) def _norm(value: ArrayLike) -> NDArray: diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 0607fdc6..ecdb108d 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -205,24 +205,14 @@ def __init__(self): ) plot_group.setSizePolicy(size_policy) plot_layout = QtWidgets.QVBoxLayout(plot_group) - # Use pyplot here to allow users to use pyplot in the plot - # function (not recommended / unstable) - self.fig, ax = plt.subplots() - self.canvas = FigureCanvasQTAgg(self.fig) + fig = plt.figure() + self.figsize = fig.get_size_inches() + manager = plt.get_current_fig_manager() + self.canvas = FigureCanvasQTAgg(fig) + self.canvas.manager = manager plot_layout.addWidget(self.canvas) plot_layout.addStretch() interactive_layout.addWidget(plot_group, 0, 0, 2, 1) - try: - plot(minuit.values, fig=self.fig) - kwargs["fig"] = self.fig - except Exception: - pass - try: - plot(minuit.values, ax=ax) - kwargs["ax"] = ax - except Exception: - pass - self.fig_width = self.fig.get_figwidth() button_group = QtWidgets.QGroupBox("", parent=self) size_policy = QtWidgets.QSizePolicy( @@ -285,7 +275,7 @@ def __init__(self): self.plot_with_frame(from_fit=False, report_success=False) def plot_with_frame(self, from_fit, report_success): - self.fig.set_figwidth(self.fig_width) + trans = plt.gca().transAxes try: with warnings.catch_warnings(): minuit.visualize(plot, **kwargs) @@ -295,7 +285,7 @@ def plot_with_frame(self, from_fit, report_success): import traceback - self.fig.text( + plt.figtext( 0, 0.5, traceback.format_exc(limit=-1), @@ -308,19 +298,19 @@ def plot_with_frame(self, from_fit, report_success): return fval = minuit.fmin.fval if from_fit else minuit._fcn(minuit.values) - self.fig.get_axes()[0].text( + plt.text( 0.05, 1.05, f"FCN = {fval:.3f}", - transform=self.fig.get_axes()[0].transAxes, + transform=trans, fontsize="x-large", ) if from_fit and report_success: - self.fig.get_axes()[-1].text( + plt.text( 0.95, 1.05, f"{'success' if minuit.valid and minuit.accurate else 'FAILURE'}", - transform=self.fig.get_axes()[-1].transAxes, + transform=trans, fontsize="x-large", ha="right", ) @@ -353,8 +343,9 @@ def on_parameter_change(self, from_fit=False, report_success=False): else: self.results_text.clear() - for ax in self.fig.get_axes(): - ax.clear() + + plt.clf() + plt.gcf().set_size_inches(self.figsize) self.plot_with_frame(from_fit, report_success) self.canvas.draw_idle() From c54c2d9edb6513e286b43aa346864815f68414b3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 00:55:32 +0000 Subject: [PATCH 23/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/iminuit/qtwidget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index ecdb108d..ae26b30f 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -343,7 +343,6 @@ def on_parameter_change(self, from_fit=False, report_success=False): else: self.results_text.clear() - plt.clf() plt.gcf().set_size_inches(self.figsize) self.plot_with_frame(from_fit, report_success) From 22dcf94e39a32fc3cd35c65b062633f0affe6f92 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 12 Dec 2024 13:48:05 +0100 Subject: [PATCH 24/49] Removed _widget prefix --- src/iminuit/ipywidget.py | 5 ++--- src/iminuit/qtwidget.py | 5 ++--- src/iminuit/util.py | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/iminuit/ipywidget.py b/src/iminuit/ipywidget.py index f006cf01..4e531b3e 100644 --- a/src/iminuit/ipywidget.py +++ b/src/iminuit/ipywidget.py @@ -1,7 +1,6 @@ """Interactive fitting widget for Jupyter notebooks.""" -from .util import _widget_guess_initial_step as _guess_initial_step -from .util import _widget_make_finite as _make_finite +from .util import _widget_guess_initial_step, _make_finite import warnings import numpy as np from typing import Dict, Any, Callable @@ -149,7 +148,7 @@ class Parameter(widgets.HBox): def __init__(self, minuit, par): val = minuit.values[par] vmin, vmax = minuit.limits[par] - step = _guess_initial_step(val, vmin, vmax) + step = _widget_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 diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index ae26b30f..27682422 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -1,7 +1,6 @@ """Interactive fitting widget using PyQt6.""" -from .util import _widget_guess_initial_step as _guess_initial_step -from .util import _widget_make_finite as _make_finite +from .util import _widget_guess_initial_step, _make_finite import warnings import numpy as np from typing import Dict, Any, Callable @@ -166,7 +165,7 @@ def on_fit_toggled(self): def reset(self, val, limits=None): if limits is not None: vmin, vmax = limits - step = _guess_initial_step(val, vmin, vmax) + step = _widget_guess_initial_step(val, vmin, vmax) self.vmin = vmin if np.isfinite(vmin) else val - 100 * step self.vmax = vmax if np.isfinite(vmax) else val + 100 * step with block_signals(self.tmin, self.tmax): diff --git a/src/iminuit/util.py b/src/iminuit/util.py index 8df75095..d2959f15 100644 --- a/src/iminuit/util.py +++ b/src/iminuit/util.py @@ -1686,7 +1686,7 @@ def is_positive_definite(m: ArrayLike) -> bool: return False -def _widget_make_finite(x: float) -> float: +def _make_finite(x: float) -> float: sign = -1 if x < 0 else 1 if abs(x) == np.inf: return sign * sys.float_info.max From 70e930a6d8fdc0e9556a0754f267eae4dcdbe86f Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 12 Dec 2024 14:09:48 +0100 Subject: [PATCH 25/49] Added run_event_loop argument --- src/iminuit/minuit.py | 9 +++++---- src/iminuit/qtwidget.py | 10 +++++----- tests/test_draw.py | 12 ++++++------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index 92270c7e..f57cb63e 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2314,6 +2314,7 @@ def interactive( self, plot: Callable = None, raise_on_exception=False, + run_event_loop=True, **kwargs, ): """ @@ -2321,9 +2322,7 @@ def interactive( Starts a fitting application (requires PyQt6, matplotlib) in which the fit is visualized and the parameters can be manipulated to find good - starting parameters and to debug the fit. If there is a QApplication - already running, the widget will be returned instead of starting the - application. + starting parameters and to debug the fit. When called in a Jupyter notebook (requires ipywidgets, IPython, matplotlib), a fitting widget is returned instead, which can be displayed. @@ -2371,10 +2370,12 @@ def interactive( if is_jupyter: from iminuit.ipywidget import make_widget + return make_widget(self, plot, kwargs, raise_on_exception) else: from iminuit.qtwidget import make_widget - return make_widget(self, plot, kwargs, raise_on_exception) + return make_widget(self, plot, kwargs, raise_on_exception, + run_event_loop) 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 index 27682422..a2ae2413 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -23,6 +23,7 @@ def make_widget( plot: Callable[..., None], kwargs: Dict[str, Any], raise_on_exception: bool, + run_event_loop: bool, ): """Make interactive fitting widget.""" original_values = minuit.values[:] @@ -367,12 +368,11 @@ def on_reset_button_clicked(self): x.reset(val=minuit.values[i], limits=original_limits[i]) self.on_parameter_change() - app = QtWidgets.QApplication.instance() - if app is None: - app = QtWidgets.QApplication([]) - app.setApplicationName("iminuit") + if run_event_loop: + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) - if app.applicationName() == "iminuit": widget = Widget() widget.show() app.exec() # this blocks the main thread diff --git a/tests/test_draw.py b/tests/test_draw.py index 699fc415..9bd94e39 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -252,10 +252,10 @@ def assert_call(self): m = Minuit(cost, 1, 1) with pytest.raises(AttributeError, match="no visualize method"): - m.interactive(raise_on_exception=True) + m.interactive(raise_on_exception=True, run_event_loop=False) with plot.assert_call(): - mw1 = m.interactive(plot) + mw1 = m.interactive(plot, run_event_loop=False) qtbot.addWidget(mw1) assert isinstance(mw1, PyQt6.QtWidgets.QWidget) @@ -292,7 +292,7 @@ def __call__(self, a, b): c = Cost() m = Minuit(c, 0, 0) with plot.assert_call(): - mw = m.interactive(raise_on_exception=True) + mw = m.interactive(raise_on_exception=True, run_event_loop=False) qtbot.addWidget(mw) # this should modify slider range @@ -333,10 +333,10 @@ def raiser(args): m = Minuit(lambda x, y: 0, 0, 1) # by default do not raise - m.interactive(raiser) + m.interactive(raiser, run_event_loop=False) with pytest.raises(ValueError): - m.interactive(raiser, raise_on_exception=True) + m.interactive(raiser, raise_on_exception=True, run_event_loop=False) @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -375,5 +375,5 @@ def __call__(self, par): trace_args = TraceArgs() m = Minuit(cost, (1, 2)) - m.interactive(trace_args) + m.interactive(trace_args, run_event_loop=False) assert trace_args.nargs > 0 From 9d7cc479f420f0225c14f1c1e7790e4b8faed2fc Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 12 Dec 2024 16:48:10 +0100 Subject: [PATCH 26/49] Changed layout --- src/iminuit/qtwidget.py | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index a2ae2413..9d542377 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -190,7 +190,7 @@ def reset(self, val, limits=None): class Widget(QtWidgets.QWidget): def __init__(self): super().__init__() - self.resize(1200, 800) + self.resize(1280, 720) font = QtGui.QFont() font.setPointSize(12) self.setFont(font) @@ -206,12 +206,10 @@ def __init__(self): plot_group.setSizePolicy(size_policy) plot_layout = QtWidgets.QVBoxLayout(plot_group) fig = plt.figure() - self.figsize = fig.get_size_inches() manager = plt.get_current_fig_manager() self.canvas = FigureCanvasQTAgg(fig) self.canvas.manager = manager plot_layout.addWidget(self.canvas) - plot_layout.addStretch() interactive_layout.addWidget(plot_group, 0, 0, 2, 1) button_group = QtWidgets.QGroupBox("", parent=self) @@ -220,6 +218,7 @@ def __init__(self): QtWidgets.QSizePolicy.Policy.Fixed, ) button_group.setSizePolicy(size_policy) + button_group.setMaximumWidth(500) button_layout = QtWidgets.QHBoxLayout(button_group) self.fit_button = QtWidgets.QPushButton("Fit", parent=button_group) self.fit_button.setStyleSheet("background-color: #2196F3; color: white") @@ -253,10 +252,11 @@ def __init__(self): QtWidgets.QSizePolicy.Policy.MinimumExpanding, ) par_scroll_area.setSizePolicy(size_policy) + par_scroll_area.setMaximumWidth(500) scroll_area_contents = QtWidgets.QWidget() parameter_layout = QtWidgets.QVBoxLayout(scroll_area_contents) par_scroll_area.setWidget(scroll_area_contents) - interactive_layout.addWidget(par_scroll_area, 1, 1, 2, 1) + interactive_layout.addWidget(par_scroll_area, 1, 1, 1, 1) self.parameters = [] for par in minuit.parameters: parameter = Parameter(minuit, par, self.on_parameter_change) @@ -264,21 +264,15 @@ def __init__(self): parameter_layout.addWidget(parameter) parameter_layout.addStretch() - results_scroll_area = QtWidgets.QScrollArea() - results_scroll_area.setWidgetResizable(True) - results_scroll_area.setSizePolicy(size_policy) - self.results_text = QtWidgets.QTextEdit(parent=self) - self.results_text.setReadOnly(True) - results_scroll_area.setWidget(self.results_text) - interactive_layout.addWidget(results_scroll_area, 2, 0, 1, 1) - self.plot_with_frame(from_fit=False, report_success=False) def plot_with_frame(self, from_fit, report_success): trans = plt.gca().transAxes try: with warnings.catch_warnings(): + fig_size = plt.gcf().get_size_inches() minuit.visualize(plot, **kwargs) + plt.gcf().set_size_inches(fig_size) except Exception: if raise_on_exception: raise @@ -334,17 +328,9 @@ def on_parameter_change(self, from_fit=False, report_success=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 - elif from_fit: - self.results_text.clear() - self.results_text.setHtml(minuit._repr_html_()) - else: - self.results_text.clear() plt.clf() - plt.gcf().set_size_inches(self.figsize) self.plot_with_frame(from_fit, report_success) self.canvas.draw_idle() From 811b27b950fda0993d1e716fcdd8d1cd0ecb395e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:57:44 +0000 Subject: [PATCH 27/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/iminuit/minuit.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index f57cb63e..cbc24164 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2374,8 +2374,7 @@ def interactive( else: from iminuit.qtwidget import make_widget - return make_widget(self, plot, kwargs, raise_on_exception, - run_event_loop) + return make_widget(self, plot, kwargs, raise_on_exception, run_event_loop) def _free_parameters(self) -> Set[str]: return set(mp.name for mp in self._last_state if not mp.is_fixed) From 30a11842607ecb5fccc5ded2cbe4434bcb5f97ac Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 13 Dec 2024 19:45:36 +0100 Subject: [PATCH 28/49] Added is_jupyter --- src/iminuit/util.py | 12 ++++++++++++ tests/test_without_ipywidgets.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/iminuit/util.py b/src/iminuit/util.py index d2959f15..3df7a3ea 100644 --- a/src/iminuit/util.py +++ b/src/iminuit/util.py @@ -1686,6 +1686,18 @@ def is_positive_definite(m: ArrayLike) -> bool: return False +def is_jupyter() -> bool: + try: + from IPython import get_ipython + ip = get_ipython() + return ip.has_trait('kernel') + except ImportError: + return False + except AttributeError: + return False + return False + + def _make_finite(x: float) -> float: sign = -1 if x < 0 else 1 if abs(x) == np.inf: diff --git a/tests/test_without_ipywidgets.py b/tests/test_without_ipywidgets.py index 7e2df799..48445901 100644 --- a/tests/test_without_ipywidgets.py +++ b/tests/test_without_ipywidgets.py @@ -14,5 +14,5 @@ def test_interactive(mock_ipython): iminuit.Minuit(cost, 1).interactive() with hide_modules("ipywidgets", reload="iminuit.ipywidget"): - with pytest.raises(ModuleNotFoundError, match="Please install"): + with pytest.raises(ModuleNotFoundError, match="Please install ipywidgets"): iminuit.Minuit(cost, 1).interactive() From 6e56da0ae4407138a9e9b931e62fcf6813608487 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 13 Dec 2024 19:47:10 +0100 Subject: [PATCH 29/49] Moved run_event_loop to make_widget --- src/iminuit/qtwidget.py | 2 +- tests/conftest.py | 7 +++++-- tests/test_draw.py | 20 ++++++++++++-------- tests/test_without_pyqt6.py | 2 +- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 9d542377..6732e959 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -23,7 +23,7 @@ def make_widget( plot: Callable[..., None], kwargs: Dict[str, Any], raise_on_exception: bool, - run_event_loop: bool, + run_event_loop: bool = True, ): """Make interactive fitting widget.""" original_values = minuit.values[:] diff --git a/tests/conftest.py b/tests/conftest.py index eb4b20fa..bbce2d19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,10 @@ def mock_ipython(): with patch("IPython.get_ipython") as mock_get_ipython: mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" - mock_shell.config = {"IPKernelApp": True} + + def has_trait(name): + return True + + mock_shell.has_trait.side_effect = has_trait mock_get_ipython.return_value = mock_shell yield diff --git a/tests/test_draw.py b/tests/test_draw.py index 9bd94e39..6f8aa062 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -15,6 +15,13 @@ def f1(x, y): return (1 - x) ** 2 + np.exp((y - 1) ** 2) +def qtinteractive(m, plot=None, raise_on_exception=False, **kwargs): + from iminuit.qtwidget import make_widget + + return make_widget(m, plot, kwargs, raise_on_exception, + run_event_loop=False) + + @pytest.fixture def minuit(): m = Minuit(f1, x=0, y=0) @@ -251,11 +258,8 @@ def assert_call(self): m = Minuit(cost, 1, 1) - with pytest.raises(AttributeError, match="no visualize method"): - m.interactive(raise_on_exception=True, run_event_loop=False) - with plot.assert_call(): - mw1 = m.interactive(plot, run_event_loop=False) + mw1 = qtinteractive(m, plot) qtbot.addWidget(mw1) assert isinstance(mw1, PyQt6.QtWidgets.QWidget) @@ -292,7 +296,7 @@ def __call__(self, a, b): c = Cost() m = Minuit(c, 0, 0) with plot.assert_call(): - mw = m.interactive(raise_on_exception=True, run_event_loop=False) + mw = qtinteractive(m, raise_on_exception=True) qtbot.addWidget(mw) # this should modify slider range @@ -333,10 +337,10 @@ def raiser(args): m = Minuit(lambda x, y: 0, 0, 1) # by default do not raise - m.interactive(raiser, run_event_loop=False) + qtinteractive(m, raiser) with pytest.raises(ValueError): - m.interactive(raiser, raise_on_exception=True, run_event_loop=False) + qtinteractive(m, raiser, raise_on_exception=True) @pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -375,5 +379,5 @@ def __call__(self, par): trace_args = TraceArgs() m = Minuit(cost, (1, 2)) - m.interactive(trace_args, run_event_loop=False) + qtinteractive(m, trace_args) assert trace_args.nargs > 0 diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyqt6.py index 0c746487..3886b2a8 100644 --- a/tests/test_without_pyqt6.py +++ b/tests/test_without_pyqt6.py @@ -12,5 +12,5 @@ def test_interactive(qtbot): cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) with hide_modules("PyQt6", reload="iminuit.qtwidget"): - with pytest.raises(ModuleNotFoundError, match="Please install"): + with pytest.raises(ModuleNotFoundError, match="Please install PyQt6"): iminuit.Minuit(cost, 1).interactive() From 5c866aeccdf90ec39cffc5f6becddeaaa063ffe8 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 13 Dec 2024 19:48:43 +0100 Subject: [PATCH 30/49] Use is_jupyter from util and remove run_event_loop --- src/iminuit/minuit.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index cbc24164..54ec4dcb 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2314,7 +2314,6 @@ def interactive( self, plot: Callable = None, raise_on_exception=False, - run_event_loop=True, **kwargs, ): """ @@ -2352,29 +2351,15 @@ def interactive( -------- Minuit.visualize """ - 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 Exception: - is_jupyter = False - plot = self._visualize(plot) - if is_jupyter: + if mutil.is_jupyter(): from iminuit.ipywidget import make_widget - return make_widget(self, plot, kwargs, raise_on_exception) else: from iminuit.qtwidget import make_widget - return make_widget(self, plot, kwargs, raise_on_exception, run_event_loop) + 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 0ec29697a9f9d3acb8cec3c77b015f89b4b4f94c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Dec 2024 18:49:11 +0000 Subject: [PATCH 31/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/iminuit/util.py | 11 ++++++----- tests/test_draw.py | 3 +-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/iminuit/util.py b/src/iminuit/util.py index 3df7a3ea..ff18934d 100644 --- a/src/iminuit/util.py +++ b/src/iminuit/util.py @@ -1688,13 +1688,14 @@ def is_positive_definite(m: ArrayLike) -> bool: def is_jupyter() -> bool: try: - from IPython import get_ipython - ip = get_ipython() - return ip.has_trait('kernel') + from IPython import get_ipython + + ip = get_ipython() + return ip.has_trait("kernel") except ImportError: - return False + return False except AttributeError: - return False + return False return False diff --git a/tests/test_draw.py b/tests/test_draw.py index 6f8aa062..9442a850 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -18,8 +18,7 @@ def f1(x, y): def qtinteractive(m, plot=None, raise_on_exception=False, **kwargs): from iminuit.qtwidget import make_widget - return make_widget(m, plot, kwargs, raise_on_exception, - run_event_loop=False) + return make_widget(m, plot, kwargs, raise_on_exception, run_event_loop=False) @pytest.fixture From f034929bffd078ddfb1448640ee92a0b2f52d425 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 13 Dec 2024 20:26:25 +0100 Subject: [PATCH 32/49] Added fmin display to qtwidget --- src/iminuit/qtwidget.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 6732e959..d4ae682c 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -256,7 +256,7 @@ def __init__(self): scroll_area_contents = QtWidgets.QWidget() parameter_layout = QtWidgets.QVBoxLayout(scroll_area_contents) par_scroll_area.setWidget(scroll_area_contents) - interactive_layout.addWidget(par_scroll_area, 1, 1, 1, 1) + interactive_layout.addWidget(par_scroll_area, 1, 1, 2, 1) self.parameters = [] for par in minuit.parameters: parameter = Parameter(minuit, par, self.on_parameter_change) @@ -264,6 +264,12 @@ def __init__(self): parameter_layout.addWidget(parameter) parameter_layout.addStretch() + self.results_text = QtWidgets.QTextEdit(parent=self) + self.results_text.setReadOnly(True) + self.results_text.setSizePolicy(size_policy) + self.results_text.setMaximumHeight(144) + interactive_layout.addWidget(self.results_text, 2, 0, 1, 1) + self.plot_with_frame(from_fit=False, report_success=False) def plot_with_frame(self, from_fit, report_success): @@ -330,6 +336,12 @@ def on_parameter_change(self, from_fit=False, report_success=False): report_success = self.do_fit(plot=False) minuit.fixed = saved + if from_fit: + self.results_text.clear() + self.results_text.setHtml(f"
{minuit.fmin._repr_html_()}
") + else: + self.results_text.clear() + plt.clf() self.plot_with_frame(from_fit, report_success) self.canvas.draw_idle() From 7f873eaf750467b990c0f16671439de07e5bb40c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Dec 2024 19:27:25 +0000 Subject: [PATCH 33/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/iminuit/qtwidget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index d4ae682c..94897070 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -338,7 +338,9 @@ def on_parameter_change(self, from_fit=False, report_success=False): if from_fit: self.results_text.clear() - self.results_text.setHtml(f"
{minuit.fmin._repr_html_()}
") + self.results_text.setHtml( + f"
{minuit.fmin._repr_html_()}
" + ) else: self.results_text.clear() From 24ccc77eccbc528fec87a38353d8f3d16476d410 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Fri, 13 Dec 2024 20:32:04 +0100 Subject: [PATCH 34/49] Removed success message --- src/iminuit/qtwidget.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 94897070..b4ae1759 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -306,14 +306,12 @@ def plot_with_frame(self, from_fit, report_success): 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", + self.results_text.clear() + self.results_text.setHtml( + f"
{minuit.fmin._repr_html_()}
" ) + else: + self.results_text.clear() def fit(self): if self.algo_choice.currentText() == "Migrad": @@ -336,14 +334,6 @@ def on_parameter_change(self, from_fit=False, report_success=False): report_success = self.do_fit(plot=False) minuit.fixed = saved - if from_fit: - self.results_text.clear() - self.results_text.setHtml( - f"
{minuit.fmin._repr_html_()}
" - ) - else: - self.results_text.clear() - plt.clf() self.plot_with_frame(from_fit, report_success) self.canvas.draw_idle() From 2fda76737dba484a3b1c250c1c69b331099f276d Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Mon, 16 Dec 2024 20:37:59 +0100 Subject: [PATCH 35/49] Fixed importskips --- tests/test_draw.py | 249 ------------------------------------ tests/test_ipywidget.py | 129 +++++++++++++++++++ tests/test_qtwidget.py | 126 ++++++++++++++++++ tests/test_without_pyqt6.py | 4 +- 4 files changed, 256 insertions(+), 252 deletions(-) create mode 100644 tests/test_ipywidget.py create mode 100644 tests/test_qtwidget.py diff --git a/tests/test_draw.py b/tests/test_draw.py index 9442a850..9cd3ad5f 100644 --- a/tests/test_draw.py +++ b/tests/test_draw.py @@ -2,8 +2,6 @@ from iminuit import Minuit from pathlib import Path import numpy as np -from numpy.testing import assert_allclose -import contextlib mpl = pytest.importorskip("matplotlib") plt = pytest.importorskip("matplotlib.pyplot") @@ -15,12 +13,6 @@ def f1(x, y): return (1 - x) ** 2 + np.exp((y - 1) ** 2) -def qtinteractive(m, plot=None, raise_on_exception=False, **kwargs): - from iminuit.qtwidget import make_widget - - return make_widget(m, plot, kwargs, raise_on_exception, run_event_loop=False) - - @pytest.fixture def minuit(): m = Minuit(f1, x=0, y=0) @@ -139,244 +131,3 @@ def test_mnmatrix_7(fig): m = Minuit(lambda x: abs(x) ** 2 + x**4 + 10 * x, x=0) m.migrad() m.draw_mnmatrix(cl=[1, 3]) - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_ipywidgets(mock_ipython): - ipywidgets = pytest.importorskip("ipywidgets") - - def cost(a, b): - return a**2 + b**2 - - class Plot: - def __init__(self): - self.called = False - self.raises = False - - def __call__(self, args): - self.called = True - if self.raises: - raise ValueError("foo") - - @contextlib.contextmanager - def assert_call(self): - self.called = False - yield - assert self.called - - plot = Plot() - - m = Minuit(cost, 1, 1) - - with pytest.raises(AttributeError, match="no visualize method"): - m.interactive(raise_on_exception=True) - - with plot.assert_call(): - out1 = m.interactive(plot) - assert isinstance(out1, ipywidgets.HBox) - - # manipulate state to also check this code - ui = out1.children[1] - header, parameters = ui.children - fit_button, update_button, reset_button, algo_select = header.children - with plot.assert_call(): - fit_button.click() - assert_allclose(m.values, (0, 0), atol=1e-5) - with plot.assert_call(): - reset_button.click() - assert_allclose(m.values, (1, 1), atol=1e-5) - - algo_select.value = "Scipy" - with plot.assert_call(): - fit_button.click() - - algo_select.value = "Simplex" - with plot.assert_call(): - fit_button.click() - - update_button.value = False - with plot.assert_call(): - # because of implementation details, we have to trigger the slider several times - for i in range(5): - parameters.children[0].slider.value = i # change first slider - parameters.children[0].fix.value = True - with plot.assert_call(): - parameters.children[0].fit.value = True - - class Cost: - def visualize(self, args): - return plot(args) - - def __call__(self, a, b): - return (a - 100) ** 2 + (b + 100) ** 2 - - c = Cost() - m = Minuit(c, 0, 0) - with plot.assert_call(): - out = m.interactive(raise_on_exception=True) - - # this should modify slider range - ui = out.children[1] - header, parameters = ui.children - fit_button, update_button, reset_button, algo_select = header.children - assert parameters.children[0].slider.max == 1 - assert parameters.children[1].slider.min == -1 - with plot.assert_call(): - fit_button.click() - assert_allclose(m.values, (100, -100), atol=1e-5) - # this should trigger an exception - plot.raises = True - with plot.assert_call(): - fit_button.click() - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_pyqt6(qtbot): - PyQt6 = pytest.importorskip("PyQt6") - - def cost(a, b): - return a**2 + b**2 - - class Plot: - def __init__(self): - self.called = False - self.raises = False - - def __call__(self, args): - self.called = True - if self.raises: - raise ValueError("foo") - - @contextlib.contextmanager - def assert_call(self): - self.called = False - yield - assert self.called - - plot = Plot() - - m = Minuit(cost, 1, 1) - - with plot.assert_call(): - mw1 = qtinteractive(m, plot) - qtbot.addWidget(mw1) - assert isinstance(mw1, PyQt6.QtWidgets.QWidget) - - # manipulate state to also check this code - with plot.assert_call(): - mw1.fit_button.click() - assert_allclose(m.values, (0, 0), atol=1e-5) - with plot.assert_call(): - mw1.reset_button.click() - assert_allclose(m.values, (1, 1), atol=1e-5) - - mw1.algo_choice.setCurrentText("Scipy") - with plot.assert_call(): - mw1.fit_button.click() - - mw1.algo_choice.setCurrentText("Simplex") - with plot.assert_call(): - mw1.fit_button.click() - - mw1.update_button.click() - with plot.assert_call(): - mw1.parameters[0].slider.valueChanged.emit(int(5e7)) - mw1.parameters[0].fix.click() - with plot.assert_call(): - mw1.parameters[0].fit.click() - - class Cost: - def visualize(self, args): - return plot(args) - - def __call__(self, a, b): - return (a - 100) ** 2 + (b + 100) ** 2 - - c = Cost() - m = Minuit(c, 0, 0) - with plot.assert_call(): - mw = qtinteractive(m, raise_on_exception=True) - qtbot.addWidget(mw) - - # this should modify slider range - assert mw.parameters[0].vmax == 1 - assert mw.parameters[1].vmin == -1 - with plot.assert_call(): - mw.fit_button.click() - assert_allclose(m.values, (100, -100), atol=1e-5) - # this should trigger an exception - # plot.raises = True - # with plot.assert_call(): - # mw.fit_button.click() - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_ipywidgets_raises(mock_ipython): - pytest.importorskip("ipywidgets") - - def raiser(args): - raise ValueError - - m = Minuit(lambda x, y: 0, 0, 1) - - # by default do not raise - m.interactive(raiser) - - with pytest.raises(ValueError): - m.interactive(raiser, raise_on_exception=True) - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_pyqt6_raises(qtbot): - pytest.importorskip("PyQt6") - - def raiser(args): - raise ValueError - - m = Minuit(lambda x, y: 0, 0, 1) - - # by default do not raise - qtinteractive(m, raiser) - - with pytest.raises(ValueError): - qtinteractive(m, raiser, raise_on_exception=True) - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_ipywidgets_with_array_func(mock_ipython): - pytest.importorskip("ipywidgets") - - def cost(par): - return par[0] ** 2 + (par[1] / 2) ** 2 - - class TraceArgs: - nargs = 0 - - def __call__(self, par): - self.nargs = len(par) - - trace_args = TraceArgs() - m = Minuit(cost, (1, 2)) - - m.interactive(trace_args) - assert trace_args.nargs > 0 - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_pyqt6_with_array_func(qtbot): - pytest.importorskip("PyQt6") - - def cost(par): - return par[0] ** 2 + (par[1] / 2) ** 2 - - class TraceArgs: - nargs = 0 - - def __call__(self, par): - self.nargs = len(par) - - trace_args = TraceArgs() - m = Minuit(cost, (1, 2)) - - qtinteractive(m, trace_args) - assert trace_args.nargs > 0 diff --git a/tests/test_ipywidget.py b/tests/test_ipywidget.py new file mode 100644 index 00000000..5b511af5 --- /dev/null +++ b/tests/test_ipywidget.py @@ -0,0 +1,129 @@ +import pytest +from iminuit import Minuit +from numpy.testing import assert_allclose +import contextlib + +mpl = pytest.importorskip("matplotlib") +plt = pytest.importorskip("matplotlib.pyplot") +ipywidgets = pytest.importorskip("ipywidgets") + +mpl.use("Agg") + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_ipywidgets(mock_ipython): + def cost(a, b): + return a**2 + b**2 + + class Plot: + def __init__(self): + self.called = False + self.raises = False + + def __call__(self, args): + self.called = True + if self.raises: + raise ValueError("foo") + + @contextlib.contextmanager + def assert_call(self): + self.called = False + yield + assert self.called + + plot = Plot() + + m = Minuit(cost, 1, 1) + + with pytest.raises(AttributeError, match="no visualize method"): + m.interactive(raise_on_exception=True) + + with plot.assert_call(): + out1 = m.interactive(plot) + assert isinstance(out1, ipywidgets.HBox) + + # manipulate state to also check this code + ui = out1.children[1] + header, parameters = ui.children + fit_button, update_button, reset_button, algo_select = header.children + with plot.assert_call(): + fit_button.click() + assert_allclose(m.values, (0, 0), atol=1e-5) + with plot.assert_call(): + reset_button.click() + assert_allclose(m.values, (1, 1), atol=1e-5) + + algo_select.value = "Scipy" + with plot.assert_call(): + fit_button.click() + + algo_select.value = "Simplex" + with plot.assert_call(): + fit_button.click() + + update_button.value = False + with plot.assert_call(): + # because of implementation details, we have to trigger the slider several times + for i in range(5): + parameters.children[0].slider.value = i # change first slider + parameters.children[0].fix.value = True + with plot.assert_call(): + parameters.children[0].fit.value = True + + class Cost: + def visualize(self, args): + return plot(args) + + def __call__(self, a, b): + return (a - 100) ** 2 + (b + 100) ** 2 + + c = Cost() + m = Minuit(c, 0, 0) + with plot.assert_call(): + out = m.interactive(raise_on_exception=True) + + # this should modify slider range + ui = out.children[1] + header, parameters = ui.children + fit_button, update_button, reset_button, algo_select = header.children + assert parameters.children[0].slider.max == 1 + assert parameters.children[1].slider.min == -1 + with plot.assert_call(): + fit_button.click() + assert_allclose(m.values, (100, -100), atol=1e-5) + # this should trigger an exception + plot.raises = True + with plot.assert_call(): + fit_button.click() + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_ipywidgets_raises(mock_ipython): + def raiser(args): + raise ValueError + + m = Minuit(lambda x, y: 0, 0, 1) + + # by default do not raise + m.interactive(raiser) + + with pytest.raises(ValueError): + m.interactive(raiser, raise_on_exception=True) + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_ipywidgets_with_array_func(mock_ipython): + def cost(par): + return par[0] ** 2 + (par[1] / 2) ** 2 + + class TraceArgs: + nargs = 0 + + def __call__(self, par): + self.nargs = len(par) + + trace_args = TraceArgs() + m = Minuit(cost, (1, 2)) + + m.interactive(trace_args) + assert trace_args.nargs > 0 diff --git a/tests/test_qtwidget.py b/tests/test_qtwidget.py new file mode 100644 index 00000000..9cb28589 --- /dev/null +++ b/tests/test_qtwidget.py @@ -0,0 +1,126 @@ +import pytest +from iminuit import Minuit +from numpy.testing import assert_allclose +import contextlib + +mpl = pytest.importorskip("matplotlib") +plt = pytest.importorskip("matplotlib.pyplot") +PyQt6 = pytest.importorskip("PyQt6") + +mpl.use("Agg") + + +def qtinteractive(m, plot=None, raise_on_exception=False, **kwargs): + from iminuit.qtwidget import make_widget + + return make_widget(m, plot, kwargs, raise_on_exception, run_event_loop=False) + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_pyqt6(qtbot): + def cost(a, b): + return a**2 + b**2 + + class Plot: + def __init__(self): + self.called = False + self.raises = False + + def __call__(self, args): + self.called = True + if self.raises: + raise ValueError("foo") + + @contextlib.contextmanager + def assert_call(self): + self.called = False + yield + assert self.called + + plot = Plot() + + m = Minuit(cost, 1, 1) + + with plot.assert_call(): + mw1 = qtinteractive(m, plot) + qtbot.addWidget(mw1) + assert isinstance(mw1, PyQt6.QtWidgets.QWidget) + + # manipulate state to also check this code + with plot.assert_call(): + mw1.fit_button.click() + assert_allclose(m.values, (0, 0), atol=1e-5) + with plot.assert_call(): + mw1.reset_button.click() + assert_allclose(m.values, (1, 1), atol=1e-5) + + mw1.algo_choice.setCurrentText("Scipy") + with plot.assert_call(): + mw1.fit_button.click() + + mw1.algo_choice.setCurrentText("Simplex") + with plot.assert_call(): + mw1.fit_button.click() + + mw1.update_button.click() + with plot.assert_call(): + mw1.parameters[0].slider.valueChanged.emit(int(5e7)) + mw1.parameters[0].fix.click() + with plot.assert_call(): + mw1.parameters[0].fit.click() + + class Cost: + def visualize(self, args): + return plot(args) + + def __call__(self, a, b): + return (a - 100) ** 2 + (b + 100) ** 2 + + c = Cost() + m = Minuit(c, 0, 0) + with plot.assert_call(): + mw = qtinteractive(m, raise_on_exception=True) + qtbot.addWidget(mw) + + # this should modify slider range + assert mw.parameters[0].vmax == 1 + assert mw.parameters[1].vmin == -1 + with plot.assert_call(): + mw.fit_button.click() + assert_allclose(m.values, (100, -100), atol=1e-5) + # this should trigger an exception + # plot.raises = True + # with plot.assert_call(): + # mw.fit_button.click() + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_pyqt6_raises(qtbot): + def raiser(args): + raise ValueError + + m = Minuit(lambda x, y: 0, 0, 1) + + # by default do not raise + qtinteractive(m, raiser) + + with pytest.raises(ValueError): + qtinteractive(m, raiser, raise_on_exception=True) + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_interactive_pyqt6_with_array_func(qtbot): + def cost(par): + return par[0] ** 2 + (par[1] / 2) ** 2 + + class TraceArgs: + nargs = 0 + + def __call__(self, par): + self.nargs = len(par) + + trace_args = TraceArgs() + m = Minuit(cost, (1, 2)) + + qtinteractive(m, trace_args) + assert trace_args.nargs > 0 diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyqt6.py index 3886b2a8..84e49c2b 100644 --- a/tests/test_without_pyqt6.py +++ b/tests/test_without_pyqt6.py @@ -2,10 +2,8 @@ from iminuit.cost import LeastSquares import pytest -pytest.importorskip("PyQt6") - -def test_interactive(qtbot): +def test_interactive(): pytest.importorskip("matplotlib") import iminuit From 6c5f02b0e51615954bd39188e1dcc2a36c201ba1 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Mon, 16 Dec 2024 21:10:08 +0100 Subject: [PATCH 36/49] Ruff ignore conftest.py --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ad8940da..6330a63f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ pydocstyle.convention = "numpy" [tool.ruff.lint.per-file-ignores] "test_*.py" = ["B", "D"] +"conftest.py" = ["B", "D"] "*.ipynb" = ["D"] "automatic_differentiation.ipynb" = ["F821"] "cython.ipynb" = ["F821"] From 63e008230091d8fc978a5d14f11e32b1b1a15a4f Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Mon, 16 Dec 2024 21:11:24 +0100 Subject: [PATCH 37/49] block_signals -> _block_signals --- src/iminuit/qtwidget.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index b4ae1759..3d0f3d86 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -117,11 +117,11 @@ def on_val_changed(self, val): def on_min_changed(self): tmin = self.tmin.value() if tmin >= self.vmax: - with block_signals(self.tmin): + with _block_signals(self.tmin): self.tmin.setValue(self.vmin) return self.vmin = tmin - with block_signals(self.slider): + with _block_signals(self.slider): if tmin > self.val: self.val = tmin minuit.values[self.par] = tmin @@ -136,11 +136,11 @@ def on_min_changed(self): def on_max_changed(self): tmax = self.tmax.value() if tmax <= self.tmin.value(): - with block_signals(self.tmax): + with _block_signals(self.tmax): self.tmax.setValue(self.vmax) return self.vmax = tmax - with block_signals(self.slider): + with _block_signals(self.slider): if tmax < self.val: self.val = tmax minuit.values[self.par] = tmax @@ -169,21 +169,21 @@ def reset(self, val, limits=None): step = _widget_guess_initial_step(val, vmin, vmax) self.vmin = vmin if np.isfinite(vmin) else val - 100 * step self.vmax = vmax if np.isfinite(vmax) else val + 100 * step - with block_signals(self.tmin, self.tmax): + with _block_signals(self.tmin, self.tmax): self.tmin.setValue(self.vmin) self.tmax.setValue(self.vmax) self.val = val if self.val < self.vmin: self.vmin = self.val - with block_signals(self.tmin): + with _block_signals(self.tmin): self.tmin.setValue(self.vmin) elif self.val > self.vmax: self.vmax = self.val - with block_signals(self.tmax): + with _block_signals(self.tmax): self.tmax.setValue(self.vmax) - with block_signals(self.slider): + with _block_signals(self.slider): self.slider.setValue(self._float_to_int(self.val)) self.value_label.setText(f"{self.val:.3g}") @@ -371,7 +371,7 @@ def on_reset_button_clicked(self): @contextmanager -def block_signals(*widgets): +def _block_signals(*widgets): for w in widgets: w.blockSignals(True) try: From 2d872ab9cc58717b8807e96537d4778bc19ca4ea Mon Sep 17 00:00:00 2001 From: Hans Dembinski Date: Tue, 17 Dec 2024 11:34:21 +0100 Subject: [PATCH 38/49] attempt to fix ci --- .github/workflows/coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e050b8c9..ae04885b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -33,6 +33,7 @@ jobs: with: python-version: "3.12" - uses: astral-sh/setup-uv@v4 + - run: sudo apt install libegl1-mesa:i386 libegl1:i386 - run: uv pip install --system nox - run: nox -s cov - uses: AndreMiras/coveralls-python-action@develop From 3ffd0c8f6b818a54a34889fa4896f392003925b4 Mon Sep 17 00:00:00 2001 From: Hans Dembinski Date: Tue, 17 Dec 2024 13:44:04 +0100 Subject: [PATCH 39/49] attempt to fix --- .github/workflows/coverage.yml | 46 ++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ae04885b..0d7463f2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -3,37 +3,39 @@ name: Coverage on: pull_request: paths-ignore: - - 'doc/**' - - '.ci/**' - - '*.rst' + - "doc/**" + - ".ci/**" + - "*.rst" push: branches: - main - develop - beta/* paths-ignore: - - 'doc/**' - - '.ci/**' - - '*.rst' + - "doc/**" + - ".ci/**" + - "*.rst" jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: hendrikmuhs/ccache-action@v1.2 - with: - key: ${{ github.job }}-${{ matrix.os }}-${{ matrix.python-version }} - create-symlink: true - - uses: rui314/setup-mold@v1 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: astral-sh/setup-uv@v4 - - run: sudo apt install libegl1-mesa:i386 libegl1:i386 - - run: uv pip install --system nox - - run: nox -s cov - - uses: AndreMiras/coveralls-python-action@develop + - uses: jurplel/install-qt-action@v4 + with: + version: "6.*" + - uses: actions/checkout@v4 + with: + submodules: true + - uses: hendrikmuhs/ccache-action@v1.2 + with: + key: ${{ github.job }}-${{ matrix.os }}-${{ matrix.python-version }} + create-symlink: true + - uses: rui314/setup-mold@v1 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: astral-sh/setup-uv@v4 + - run: uv pip install --system nox + - run: nox -s cov + - uses: AndreMiras/coveralls-python-action@develop From 7ec04b20c1f9a513a2b3d847e779d07b94fd4519 Mon Sep 17 00:00:00 2001 From: Hans Dembinski Date: Tue, 17 Dec 2024 14:08:20 +0100 Subject: [PATCH 40/49] attempt to fix ci --- .github/workflows/coverage.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0d7463f2..b4a62cfd 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -21,9 +21,18 @@ jobs: runs-on: ubuntu-latest steps: - - uses: jurplel/install-qt-action@v4 + # install-qt-action also does setup-python + - name: Install Qt + uses: jurplel/install-qt-action@v3 with: - version: "6.*" + aqtversion: "==3.1.*" + version: "6.5.3" + host: "linux" + target: "desktop" + arch: "gcc_64" + # - uses: actions/setup-python@v5 + # with: + # python-version: "3.12" - uses: actions/checkout@v4 with: submodules: true @@ -32,9 +41,6 @@ jobs: key: ${{ github.job }}-${{ matrix.os }}-${{ matrix.python-version }} create-symlink: true - uses: rui314/setup-mold@v1 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - uses: astral-sh/setup-uv@v4 - run: uv pip install --system nox - run: nox -s cov From 8d50cbee1b6afba85f00c9ee31b50dbcd225b616 Mon Sep 17 00:00:00 2001 From: Hans Dembinski Date: Tue, 17 Dec 2024 14:14:01 +0100 Subject: [PATCH 41/49] attempt to fix ci --- .github/workflows/coverage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b4a62cfd..490abd83 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -26,10 +26,10 @@ jobs: uses: jurplel/install-qt-action@v3 with: aqtversion: "==3.1.*" - version: "6.5.3" + version: "6.8.1" host: "linux" target: "desktop" - arch: "gcc_64" + arch: "linux_gcc_64" # - uses: actions/setup-python@v5 # with: # python-version: "3.12" From 17abd162c6763cd7668b802fb72bab8690e3f3d7 Mon Sep 17 00:00:00 2001 From: Hans Dembinski Date: Tue, 17 Dec 2024 14:31:14 +0100 Subject: [PATCH 42/49] attempt to fix ci --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 3710a3f9..f6f99b27 100644 --- a/noxfile.py +++ b/noxfile.py @@ -67,7 +67,7 @@ def pypy(session: nox.Session) -> None: # Python-3.12 provides coverage info faster -@nox.session(python="3.12", venv_backend="uv", reuse_venv=True) +@nox.session(venv_backend="uv", reuse_venv=True) def cov(session: nox.Session) -> None: """Run covage and place in 'htmlcov' directory.""" session.install("--only-binary=:all:", "-e.[test,doc]") From dffbcf0398af8a5da7256fc5123626635a9ad144 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 19 Dec 2024 15:25:50 +0100 Subject: [PATCH 43/49] Added comment --- src/iminuit/util.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/iminuit/util.py b/src/iminuit/util.py index ff18934d..b040a084 100644 --- a/src/iminuit/util.py +++ b/src/iminuit/util.py @@ -1695,6 +1695,7 @@ def is_jupyter() -> bool: except ImportError: return False except AttributeError: + # get_ipython() returns None if no InteractiveShell instance is registered. return False return False From 42032322cf796b9484db2394c3379e23dd654ee2 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 19 Dec 2024 15:30:57 +0100 Subject: [PATCH 44/49] Exclude run_event_loop from cov --- src/iminuit/qtwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index 3d0f3d86..f54e0207 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -358,7 +358,7 @@ def on_reset_button_clicked(self): x.reset(val=minuit.values[i], limits=original_limits[i]) self.on_parameter_change() - if run_event_loop: + if run_event_loop: # pragma: no cover, should not be executed in tests app = QtWidgets.QApplication.instance() if app is None: app = QtWidgets.QApplication([]) From a8ad0dc0de40d13088cc62e58cdfdab408e31313 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 19 Dec 2024 15:53:37 +0100 Subject: [PATCH 45/49] Test both AttributeError and ImportError in is_jupyter --- tests/test_without_pyqt6.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyqt6.py index 84e49c2b..49345770 100644 --- a/tests/test_without_pyqt6.py +++ b/tests/test_without_pyqt6.py @@ -2,9 +2,11 @@ from iminuit.cost import LeastSquares import pytest +pytest.importorskip("matplotlib") -def test_interactive(): - pytest.importorskip("matplotlib") + +def test_pyqt6_interactive_with_ipython(): + pytest.importorskip("IPython") import iminuit cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) @@ -12,3 +14,13 @@ def test_interactive(): with hide_modules("PyQt6", reload="iminuit.qtwidget"): with pytest.raises(ModuleNotFoundError, match="Please install PyQt6"): iminuit.Minuit(cost, 1).interactive() + + +def test_pyqt6_interactive_without_ipython(): + import iminuit + + cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) + + with hide_modules("PyQt6", "IPython", reload="iminuit.qtwidget"): + with pytest.raises(ModuleNotFoundError, match="Please install PyQt6"): + iminuit.Minuit(cost, 1).interactive() From 594a89c8b4d557cca9a9608f6e47265c0de4e653 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 19 Dec 2024 16:53:05 +0100 Subject: [PATCH 46/49] Added tests for changing limits --- tests/test_qtwidget.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_qtwidget.py b/tests/test_qtwidget.py index 9cb28589..4dc89fb6 100644 --- a/tests/test_qtwidget.py +++ b/tests/test_qtwidget.py @@ -69,6 +69,28 @@ def assert_call(self): with plot.assert_call(): mw1.parameters[0].fit.click() + # check changing of limits + mw1.parameters[0].fit.click() + mw1.parameters[0].tmin.setValue(-1) + mw1.parameters[0].tmax.setValue(1) + assert_allclose(m.limits["a"], (-1, 1), atol=1e-5) + with plot.assert_call(): + mw1.parameters[0].tmin.setValue(0.5) + assert_allclose(m.limits["a"], (0.5, 1), atol=1e-5) + assert_allclose(m.values, (0.5, 0), atol=1e-5) + mw1.parameters[0].tmin.setValue(2) + assert_allclose(m.limits["a"], (0.5, 1), atol=1e-5) + assert_allclose(m.values, (0.5, 0), atol=1e-5) + mw1.parameters[0].tmin.setValue(-1) + with plot.assert_call(): + mw1.parameters[0].tmax.setValue(0) + assert_allclose(m.limits["a"], (-1, 0), atol=1e-5) + assert_allclose(m.values, (0, 0), atol=1e-5) + mw1.parameters[0].tmax.setValue(-2) + assert_allclose(m.limits["a"], (-1, 0), atol=1e-5) + assert_allclose(m.values, (0, 0), atol=1e-5) + + class Cost: def visualize(self, args): return plot(args) From 3167f0f595ec4f39f2981110a49570e91fc06e38 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 16:01:18 +0000 Subject: [PATCH 47/49] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_qtwidget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_qtwidget.py b/tests/test_qtwidget.py index 4dc89fb6..e9416f82 100644 --- a/tests/test_qtwidget.py +++ b/tests/test_qtwidget.py @@ -90,7 +90,6 @@ def assert_call(self): assert_allclose(m.limits["a"], (-1, 0), atol=1e-5) assert_allclose(m.values, (0, 0), atol=1e-5) - class Cost: def visualize(self, args): return plot(args) From b2f370bc3767900a99df06f1c281419de8e45ec7 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 19 Dec 2024 17:24:03 +0100 Subject: [PATCH 48/49] Cover all cases in _make_finite and _widget_guess_initial_step --- tests/test_qtwidget.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_qtwidget.py b/tests/test_qtwidget.py index e9416f82..0e3e16aa 100644 --- a/tests/test_qtwidget.py +++ b/tests/test_qtwidget.py @@ -70,23 +70,26 @@ def assert_call(self): mw1.parameters[0].fit.click() # check changing of limits - mw1.parameters[0].fit.click() - mw1.parameters[0].tmin.setValue(-1) - mw1.parameters[0].tmax.setValue(1) + m = Minuit(cost, 0, 0) + m.limits["a"] = (-2, 2) + mw2 = qtinteractive(m, plot) + qtbot.addWidget(mw2) + mw2.parameters[0].tmin.setValue(-1) + mw2.parameters[0].tmax.setValue(1) assert_allclose(m.limits["a"], (-1, 1), atol=1e-5) with plot.assert_call(): - mw1.parameters[0].tmin.setValue(0.5) + mw2.parameters[0].tmin.setValue(0.5) assert_allclose(m.limits["a"], (0.5, 1), atol=1e-5) assert_allclose(m.values, (0.5, 0), atol=1e-5) - mw1.parameters[0].tmin.setValue(2) + mw2.parameters[0].tmin.setValue(2) assert_allclose(m.limits["a"], (0.5, 1), atol=1e-5) assert_allclose(m.values, (0.5, 0), atol=1e-5) - mw1.parameters[0].tmin.setValue(-1) + mw2.parameters[0].tmin.setValue(-1) with plot.assert_call(): - mw1.parameters[0].tmax.setValue(0) + mw2.parameters[0].tmax.setValue(0) assert_allclose(m.limits["a"], (-1, 0), atol=1e-5) assert_allclose(m.values, (0, 0), atol=1e-5) - mw1.parameters[0].tmax.setValue(-2) + mw2.parameters[0].tmax.setValue(-2) assert_allclose(m.limits["a"], (-1, 0), atol=1e-5) assert_allclose(m.values, (0, 0), atol=1e-5) From a817bc984148f61b25db8c58a59c5ac86f13c545 Mon Sep 17 00:00:00 2001 From: Adrian Peter Krone Date: Thu, 19 Dec 2024 17:47:33 +0100 Subject: [PATCH 49/49] PyQt6 -> PySide6 --- pyproject.toml | 2 +- src/iminuit/minuit.py | 2 +- src/iminuit/qtwidget.py | 6 +++--- tests/test_qtwidget.py | 10 +++++----- ...test_without_pyqt6.py => test_without_pyside6.py} | 12 ++++++------ 5 files changed, 16 insertions(+), 16 deletions(-) rename tests/{test_without_pyqt6.py => test_without_pyside6.py} (69%) diff --git a/pyproject.toml b/pyproject.toml index 6330a63f..ca2b8d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ test = [ "ipywidgets", # needed by ipywidgets >= 8.0.6 "ipykernel", - "PyQt6", + "PySide6", "joblib", "jacobi", "matplotlib", diff --git a/src/iminuit/minuit.py b/src/iminuit/minuit.py index ee63b064..ff98bd4b 100644 --- a/src/iminuit/minuit.py +++ b/src/iminuit/minuit.py @@ -2343,7 +2343,7 @@ def interactive( """ Interactive GUI for fitting. - Starts a fitting application (requires PyQt6, matplotlib) in which the + Starts a fitting application (requires PySide6, matplotlib) in which the fit is visualized and the parameters can be manipulated to find good starting parameters and to debug the fit. diff --git a/src/iminuit/qtwidget.py b/src/iminuit/qtwidget.py index f54e0207..ee8639af 100644 --- a/src/iminuit/qtwidget.py +++ b/src/iminuit/qtwidget.py @@ -1,4 +1,4 @@ -"""Interactive fitting widget using PyQt6.""" +"""Interactive fitting widget using PySide6.""" from .util import _widget_guess_initial_step, _make_finite import warnings @@ -7,12 +7,12 @@ from contextlib import contextmanager try: - from PyQt6 import QtCore, QtGui, QtWidgets + from PySide6 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 " + "\n\nPlease install PySide6, and matplotlib to enable interactive " "outside of Jupyter notebooks." ) raise diff --git a/tests/test_qtwidget.py b/tests/test_qtwidget.py index 0e3e16aa..b5bd3677 100644 --- a/tests/test_qtwidget.py +++ b/tests/test_qtwidget.py @@ -5,7 +5,7 @@ mpl = pytest.importorskip("matplotlib") plt = pytest.importorskip("matplotlib.pyplot") -PyQt6 = pytest.importorskip("PyQt6") +PySide6 = pytest.importorskip("PySide6") mpl.use("Agg") @@ -17,7 +17,7 @@ def qtinteractive(m, plot=None, raise_on_exception=False, **kwargs): @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_pyqt6(qtbot): +def test_interactive_pyside6(qtbot): def cost(a, b): return a**2 + b**2 @@ -44,7 +44,7 @@ def assert_call(self): with plot.assert_call(): mw1 = qtinteractive(m, plot) qtbot.addWidget(mw1) - assert isinstance(mw1, PyQt6.QtWidgets.QWidget) + assert isinstance(mw1, PySide6.QtWidgets.QWidget) # manipulate state to also check this code with plot.assert_call(): @@ -119,7 +119,7 @@ def __call__(self, a, b): @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_pyqt6_raises(qtbot): +def test_interactive_pyside6_raises(qtbot): def raiser(args): raise ValueError @@ -133,7 +133,7 @@ def raiser(args): @pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_interactive_pyqt6_with_array_func(qtbot): +def test_interactive_pyside6_with_array_func(qtbot): def cost(par): return par[0] ** 2 + (par[1] / 2) ** 2 diff --git a/tests/test_without_pyqt6.py b/tests/test_without_pyside6.py similarity index 69% rename from tests/test_without_pyqt6.py rename to tests/test_without_pyside6.py index 49345770..db048811 100644 --- a/tests/test_without_pyqt6.py +++ b/tests/test_without_pyside6.py @@ -5,22 +5,22 @@ pytest.importorskip("matplotlib") -def test_pyqt6_interactive_with_ipython(): +def test_pyside6_interactive_with_ipython(): pytest.importorskip("IPython") import iminuit cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) - with hide_modules("PyQt6", reload="iminuit.qtwidget"): - with pytest.raises(ModuleNotFoundError, match="Please install PyQt6"): + with hide_modules("PySide6", reload="iminuit.qtwidget"): + with pytest.raises(ModuleNotFoundError, match="Please install PySide6"): iminuit.Minuit(cost, 1).interactive() -def test_pyqt6_interactive_without_ipython(): +def test_pyside6_interactive_without_ipython(): import iminuit cost = LeastSquares([1.1, 2.2], [3.3, 4.4], 1, lambda x, a: a * x) - with hide_modules("PyQt6", "IPython", reload="iminuit.qtwidget"): - with pytest.raises(ModuleNotFoundError, match="Please install PyQt6"): + with hide_modules("PySide6", "IPython", reload="iminuit.qtwidget"): + with pytest.raises(ModuleNotFoundError, match="Please install PySide6"): iminuit.Minuit(cost, 1).interactive()