diff options
author | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
---|---|---|
committer | Picca Frédéric-Emmanuel <picca@synchrotron-soleil.fr> | 2017-08-18 14:48:52 +0200 |
commit | f7bdc2acff3c13a6d632c28c4569690ab106eed7 (patch) | |
tree | 9d67cdb7152ee4e711379e03fe0546c7c3b97303 /silx/gui/fit |
Import Upstream version 0.5.0+dfsg
Diffstat (limited to 'silx/gui/fit')
-rw-r--r-- | silx/gui/fit/BackgroundWidget.py | 530 | ||||
-rw-r--r-- | silx/gui/fit/FitConfig.py | 540 | ||||
-rw-r--r-- | silx/gui/fit/FitWidget.py | 727 | ||||
-rw-r--r-- | silx/gui/fit/FitWidgets.py | 559 | ||||
-rw-r--r-- | silx/gui/fit/Parameters.py | 882 | ||||
-rw-r--r-- | silx/gui/fit/__init__.py | 28 | ||||
-rw-r--r-- | silx/gui/fit/setup.py | 43 | ||||
-rw-r--r-- | silx/gui/fit/test/__init__.py | 43 | ||||
-rw-r--r-- | silx/gui/fit/test/testBackgroundWidget.py | 83 | ||||
-rw-r--r-- | silx/gui/fit/test/testFitConfig.py | 95 | ||||
-rw-r--r-- | silx/gui/fit/test/testFitWidget.py | 135 |
11 files changed, 3665 insertions, 0 deletions
diff --git a/silx/gui/fit/BackgroundWidget.py b/silx/gui/fit/BackgroundWidget.py new file mode 100644 index 0000000..577a8c7 --- /dev/null +++ b/silx/gui/fit/BackgroundWidget.py @@ -0,0 +1,530 @@ +# coding: utf-8 +#/*########################################################################## +# Copyright (C) 2004-2017 V.A. Sole, European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# #########################################################################*/ +"""This module provides a background configuration widget +:class:`BackgroundWidget` and a corresponding dialog window +:class:`BackgroundDialog`.""" +import sys +import numpy +from silx.gui import qt +from silx.gui.plot import PlotWidget +from silx.math.fit import filters + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "24/01/2017" + + +class HorizontalSpacer(qt.QWidget): + def __init__(self, *args): + qt.QWidget.__init__(self, *args) + self.setSizePolicy(qt.QSizePolicy(qt.QSizePolicy.Expanding, + qt.QSizePolicy.Fixed)) + + +class BackgroundParamWidget(qt.QWidget): + """Background configuration composite widget. + + Strip and snip filters parameters can be adjusted using input widgets. + + Updating the widgets causes :attr:`sigBackgroundParamWidgetSignal` to + be emitted. + """ + sigBackgroundParamWidgetSignal = qt.pyqtSignal(object) + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.mainLayout = qt.QGridLayout(self) + self.mainLayout.setColumnStretch(1, 1) + + # Algorithm choice --------------------------------------------------- + self.algorithmComboLabel = qt.QLabel(self) + self.algorithmComboLabel.setText("Background algorithm") + self.algorithmCombo = qt.QComboBox(self) + self.algorithmCombo.addItem("Strip") + self.algorithmCombo.addItem("Snip") + self.algorithmCombo.activated[int].connect( + self._algorithmComboActivated) + + # Strip parameters --------------------------------------------------- + self.stripWidthLabel = qt.QLabel(self) + self.stripWidthLabel.setText("Strip Width") + + self.stripWidthSpin = qt.QSpinBox(self) + self.stripWidthSpin.setMaximum(100) + self.stripWidthSpin.setMinimum(1) + self.stripWidthSpin.valueChanged[int].connect(self._emitSignal) + + self.stripIterLabel = qt.QLabel(self) + self.stripIterLabel.setText("Strip Iterations") + self.stripIterValue = qt.QLineEdit(self) + validator = qt.QIntValidator(self.stripIterValue) + self.stripIterValue._v = validator + self.stripIterValue.setText("0") + self.stripIterValue.editingFinished[()].connect(self._emitSignal) + self.stripIterValue.setToolTip( + "Number of iterations for strip algorithm.\n" + + "If greater than 999, an 2nd pass of strip filter is " + + "applied to remove artifacts created by first pass.") + + # Snip parameters ---------------------------------------------------- + self.snipWidthLabel = qt.QLabel(self) + self.snipWidthLabel.setText("Snip Width") + + self.snipWidthSpin = qt.QSpinBox(self) + self.snipWidthSpin.setMaximum(300) + self.snipWidthSpin.setMinimum(0) + self.snipWidthSpin.valueChanged[int].connect(self._emitSignal) + + + # Smoothing parameters ----------------------------------------------- + self.smoothingFlagCheck = qt.QCheckBox(self) + self.smoothingFlagCheck.setText("Smoothing Width (Savitsky-Golay)") + self.smoothingFlagCheck.toggled.connect(self._smoothingToggled) + + self.smoothingSpin = qt.QSpinBox(self) + self.smoothingSpin.setMinimum(3) + #self.smoothingSpin.setMaximum(40) + self.smoothingSpin.setSingleStep(2) + self.smoothingSpin.valueChanged[int].connect(self._emitSignal) + + # Anchors ------------------------------------------------------------ + + self.anchorsGroup = qt.QWidget(self) + anchorsLayout = qt.QHBoxLayout(self.anchorsGroup) + anchorsLayout.setSpacing(2) + anchorsLayout.setContentsMargins(0, 0, 0, 0) + + self.anchorsFlagCheck = qt.QCheckBox(self.anchorsGroup) + self.anchorsFlagCheck.setText("Use anchors") + self.anchorsFlagCheck.setToolTip( + "Define X coordinates of points that must remain fixed") + self.anchorsFlagCheck.stateChanged[int].connect( + self._anchorsToggled) + anchorsLayout.addWidget(self.anchorsFlagCheck) + + maxnchannel = 16384 * 4 # Fixme ? + self.anchorsList = [] + num_anchors = 4 + for i in range(num_anchors): + anchorSpin = qt.QSpinBox(self.anchorsGroup) + anchorSpin.setMinimum(0) + anchorSpin.setMaximum(maxnchannel) + anchorSpin.valueChanged[int].connect(self._emitSignal) + anchorsLayout.addWidget(anchorSpin) + self.anchorsList.append(anchorSpin) + + # Layout ------------------------------------------------------------ + self.mainLayout.addWidget(self.algorithmComboLabel, 0, 0) + self.mainLayout.addWidget(self.algorithmCombo, 0, 2) + self.mainLayout.addWidget(self.stripWidthLabel, 1, 0) + self.mainLayout.addWidget(self.stripWidthSpin, 1, 2) + self.mainLayout.addWidget(self.stripIterLabel, 2, 0) + self.mainLayout.addWidget(self.stripIterValue, 2, 2) + self.mainLayout.addWidget(self.snipWidthLabel, 3, 0) + self.mainLayout.addWidget(self.snipWidthSpin, 3, 2) + self.mainLayout.addWidget(self.smoothingFlagCheck, 4, 0) + self.mainLayout.addWidget(self.smoothingSpin, 4, 2) + self.mainLayout.addWidget(self.anchorsGroup, 5, 0, 1, 4) + + # Initialize interface ----------------------------------------------- + self._setAlgorithm("strip") + self.smoothingFlagCheck.setChecked(False) + self._smoothingToggled(is_checked=False) + self.anchorsFlagCheck.setChecked(False) + self._anchorsToggled(is_checked=False) + + def _algorithmComboActivated(self, algorithm_index): + self._setAlgorithm("strip" if algorithm_index == 0 else "snip") + + def _setAlgorithm(self, algorithm): + """Enable/disable snip and snip input widgets, depending on the + chosen algorithm. + :param algorithm: "snip" or "strip" + """ + if algorithm not in ["strip", "snip"]: + raise ValueError( + "Unknown background filter algorithm %s" % algorithm) + + self.algorithm = algorithm + self.stripWidthSpin.setEnabled(algorithm == "strip") + self.stripIterValue.setEnabled(algorithm == "strip") + self.snipWidthSpin.setEnabled(algorithm == "snip") + + def _smoothingToggled(self, is_checked): + """Enable/disable smoothing input widgets, emit dictionary""" + self.smoothingSpin.setEnabled(is_checked) + self._emitSignal() + + def _anchorsToggled(self, is_checked): + """Enable/disable all spin widgets defining anchor X coordinates, + emit signal. + """ + for anchor_spin in self.anchorsList: + anchor_spin.setEnabled(is_checked) + self._emitSignal() + + def setParameters(self, ddict): + """Set values for all input widgets. + + :param dict ddict: Input dictionary, must have the same + keys as the dictionary output by :meth:`getParameters` + """ + if "algorithm" in ddict: + self._setAlgorithm(ddict["algorithm"]) + + if "SnipWidth" in ddict: + self.snipWidthSpin.setValue(int(ddict["SnipWidth"])) + + if "StripWidth" in ddict: + self.stripWidthSpin.setValue(int(ddict["StripWidth"])) + + if "StripIterations" in ddict: + self.stripIterValue.setText("%d" % int(ddict["StripIterations"])) + + if "SmoothingFlag" in ddict: + self.smoothingFlagCheck.setChecked(bool(ddict["SmoothingFlag"])) + + if "SmoothingWidth" in ddict: + self.smoothingSpin.setValue(int(ddict["SmoothingWidth"])) + + if "AnchorsFlag" in ddict: + self.anchorsFlagCheck.setChecked(bool(ddict["AnchorsFlag"])) + + if "AnchorsList" in ddict: + anchorslist = ddict["AnchorsList"] + if anchorslist in [None, 'None']: + anchorslist = [] + for spin in self.anchorsList: + spin.setValue(0) + + i = 0 + for value in anchorslist: + self.anchorsList[i].setValue(int(value)) + i += 1 + + def getParameters(self): + """Return dictionary of parameters defined in the GUI + + The returned dictionary contains following values: + + - *algorithm*: *"strip"* or *"snip"* + - *StripWidth*: width of strip iterator + - *StripIterations*: number of iterations + - *StripThreshold*: curvature parameter (currently fixed to 1.0) + - *SnipWidth*: width of snip algorithm + - *SmoothingFlag*: flag to enable/disable smoothing + - *SmoothingWidth*: width of Savitsky-Golay smoothing filter + - *AnchorsFlag*: flag to enable/disable anchors + - *AnchorsList*: list of anchors (X coordinates of fixed values) + """ + stripitertext = self.stripIterValue.text() + stripiter = int(stripitertext) if len(stripitertext) else 0 + + return {"algorithm": self.algorithm, + "StripThreshold": 1.0, + "SnipWidth": self.snipWidthSpin.value(), + "StripIterations": stripiter, + "StripWidth": self.stripWidthSpin.value(), + "SmoothingFlag": self.smoothingFlagCheck.isChecked(), + "SmoothingWidth": self.smoothingSpin.value(), + "AnchorsFlag": self.anchorsFlagCheck.isChecked(), + "AnchorsList": [spin.value() for spin in self.anchorsList]} + + def _emitSignal(self, dummy=None): + self.sigBackgroundParamWidgetSignal.emit( + {'event': 'ParametersChanged', + 'parameters': self.getParameters()}) + + +class BackgroundWidget(qt.QWidget): + """Background configuration widget, with a :class:`PlotWindow`. + + Strip and snip filters parameters can be adjusted using input widgets, + and the computed backgrounds are plotted next to the original data to + show the result.""" + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + self.setWindowTitle("Strip and SNIP Configuration Window") + self.mainLayout = qt.QVBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(2) + self.parametersWidget = BackgroundParamWidget(self) + self.graphWidget = PlotWidget(parent=self) + self.mainLayout.addWidget(self.parametersWidget) + self.mainLayout.addWidget(self.graphWidget) + self._x = None + self._y = None + self.parametersWidget.sigBackgroundParamWidgetSignal.connect(self._slot) + + def getParameters(self): + """Return dictionary of parameters defined in the GUI + + The returned dictionary contains following values: + + - *algorithm*: *"strip"* or *"snip"* + - *StripWidth*: width of strip iterator + - *StripIterations*: number of iterations + - *StripThreshold*: strip curvature (currently fixed to 1.0) + - *SnipWidth*: width of snip algorithm + - *SmoothingFlag*: flag to enable/disable smoothing + - *SmoothingWidth*: width of Savitsky-Golay smoothing filter + - *AnchorsFlag*: flag to enable/disable anchors + - *AnchorsList*: list of anchors (X coordinates of fixed values) + """ + return self.parametersWidget.getParameters() + + def setParameters(self, ddict): + """Set values for all input widgets. + + :param dict ddict: Input dictionary, must have the same + keys as the dictionary output by :meth:`getParameters` + """ + return self.parametersWidget.setParameters(ddict) + + def setData(self, x, y, xmin=None, xmax=None): + """Set data for the original curve, and _update strip and snip + curves accordingly. + + :param x: Array or sequence of curve abscissa values + :param y: Array or sequence of curve ordinate values + :param xmin: Min value to be displayed on the X axis + :param xmax: Max value to be displayed on the X axis + """ + self._x = x + self._y = y + self._xmin = xmin + self._xmax = xmax + self._update(resetzoom=True) + + def _slot(self, ddict): + self._update() + + def _update(self, resetzoom=False): + """Compute strip and snip backgrounds, update the curves + """ + if self._y is None: + return + + pars = self.getParameters() + + # smoothed data + y = numpy.ravel(numpy.array(self._y)).astype(numpy.float) + if pars["SmoothingFlag"]: + ysmooth = filters.savitsky_golay(y, pars['SmoothingWidth']) + f = [0.25, 0.5, 0.25] + ysmooth[1:-1] = numpy.convolve(ysmooth, f, mode=0) + ysmooth[0] = 0.5 * (ysmooth[0] + ysmooth[1]) + ysmooth[-1] = 0.5 * (ysmooth[-1] + ysmooth[-2]) + else: + ysmooth = y + + + # loop for anchors + x = self._x + niter = pars['StripIterations'] + anchors_indices = [] + if pars['AnchorsFlag'] and pars['AnchorsList'] is not None: + ravelled = x + for channel in pars['AnchorsList']: + if channel <= ravelled[0]: + continue + index = numpy.nonzero(ravelled >= channel)[0] + if len(index): + index = min(index) + if index > 0: + anchors_indices.append(index) + + stripBackground = filters.strip(ysmooth, + w=pars['StripWidth'], + niterations=niter, + factor=pars['StripThreshold'], + anchors=anchors_indices) + + if niter >= 1000: + # final smoothing + stripBackground = filters.strip(stripBackground, + w=1, + niterations=50*pars['StripWidth'], + factor=pars['StripThreshold'], + anchors=anchors_indices) + + if len(anchors_indices) == 0: + anchors_indices = [0, len(ysmooth)-1] + anchors_indices.sort() + snipBackground = 0.0 * ysmooth + lastAnchor = 0 + for anchor in anchors_indices: + if (anchor > lastAnchor) and (anchor < len(ysmooth)): + snipBackground[lastAnchor:anchor] =\ + filters.snip1d(ysmooth[lastAnchor:anchor], + pars['SnipWidth']) + lastAnchor = anchor + if lastAnchor < len(ysmooth): + snipBackground[lastAnchor:] =\ + filters.snip1d(ysmooth[lastAnchor:], + pars['SnipWidth']) + + self.graphWidget.addCurve(x, y, + legend='Input Data', + replace=True, + resetzoom=resetzoom) + self.graphWidget.addCurve(x, stripBackground, + legend='Strip Background', + resetzoom=False) + self.graphWidget.addCurve(x, snipBackground, + legend='SNIP Background', + resetzoom=False) + if self._xmin is not None and self._xmax is not None: + self.graphWidget.setGraphXLimits(xmin=self._xmin, xmax=self._xmax) + + +class BackgroundDialog(qt.QDialog): + """QDialog window featuring a :class:`BackgroundWidget`""" + def __init__(self, parent=None): + qt.QDialog.__init__(self, parent) + self.setWindowTitle("Strip and Snip Configuration Window") + self.mainLayout = qt.QVBoxLayout(self) + self.mainLayout.setContentsMargins(0, 0, 0, 0) + self.mainLayout.setSpacing(2) + self.parametersWidget = BackgroundWidget(self) + self.mainLayout.addWidget(self.parametersWidget) + hbox = qt.QWidget(self) + hboxLayout = qt.QHBoxLayout(hbox) + hboxLayout.setContentsMargins(0, 0, 0, 0) + hboxLayout.setSpacing(2) + self.okButton = qt.QPushButton(hbox) + self.okButton.setText("OK") + self.okButton.setAutoDefault(False) + self.dismissButton = qt.QPushButton(hbox) + self.dismissButton.setText("Cancel") + self.dismissButton.setAutoDefault(False) + hboxLayout.addWidget(HorizontalSpacer(hbox)) + hboxLayout.addWidget(self.okButton) + hboxLayout.addWidget(self.dismissButton) + self.mainLayout.addWidget(hbox) + self.dismissButton.clicked.connect(self.reject) + self.okButton.clicked.connect(self.accept) + + self.output = {} + """Configuration dictionary containing following fields: + + - *SmoothingFlag* + - *SmoothingWidth* + - *StripWidth* + - *StripIterations* + - *StripThreshold* + - *SnipWidth* + - *AnchorsFlag* + - *AnchorsList* + """ + + # self.parametersWidget.parametersWidget.sigBackgroundParamWidgetSignal.connect(self.updateOutput) + + # def updateOutput(self, ddict): + # self.output = ddict + + def accept(self): + """Update :attr:`output`, then call :meth:`QDialog.accept` + """ + self.output = self.getParameters() + super(BackgroundDialog, self).accept() + + def sizeHint(self): + return qt.QSize(int(1.5*qt.QDialog.sizeHint(self).width()), + qt.QDialog.sizeHint(self).height()) + + def setData(self, x, y, xmin=None, xmax=None): + """See :meth:`BackgroundWidget.setData`""" + return self.parametersWidget.setData(x, y, xmin, xmax) + + def getParameters(self): + """See :meth:`BackgroundWidget.getParameters`""" + return self.parametersWidget.getParameters() + + def setParameters(self, ddict): + """See :meth:`BackgroundWidget.setParameters`""" + return self.parametersWidget.setParameters(ddict) + + def setDefault(self, ddict): + """Alias for :meth:`setParameters`""" + return self.setParameters(ddict) + + +def getBgDialog(parent=None, default=None, modal=True): + """Instantiate and return a bg configuration dialog, adapted + for configuring standard background theories from + :mod:`silx.math.fit.bgtheories`. + + :return: Instance of :class:`BackgroundDialog` + """ + bgd = BackgroundDialog(parent=parent) + # apply default to newly added pages + bgd.setParameters(default) + + return bgd + + +def main(): + # synthetic data + from silx.math.fit.functions import sum_gauss + + x = numpy.arange(5000) + # (height1, center1, fwhm1, ...) 5 peaks + params1 = (50, 500, 100, + 20, 2000, 200, + 50, 2250, 100, + 40, 3000, 75, + 23, 4000, 150) + y0 = sum_gauss(x, *params1) + + # random values between [-1;1] + noise = 2 * numpy.random.random(5000) - 1 + # make it +- 5% + noise *= 0.05 + + # 2 gaussians with very large fwhm, as background signal + actual_bg = sum_gauss(x, 15, 3500, 3000, 5, 1000, 1500) + + # Add 5% random noise to gaussians and add background + y = y0 + numpy.average(y0) * noise + actual_bg + + # Open widget + a = qt.QApplication(sys.argv) + a.lastWindowClosed.connect(a.quit) + + def mySlot(ddict): + print(ddict) + + w = BackgroundDialog() + w.parametersWidget.parametersWidget.sigBackgroundParamWidgetSignal.connect(mySlot) + w.setData(x, y) + w.exec_() + #a.exec_() + +if __name__ == "__main__": + main() diff --git a/silx/gui/fit/FitConfig.py b/silx/gui/fit/FitConfig.py new file mode 100644 index 0000000..70b6fbe --- /dev/null +++ b/silx/gui/fit/FitConfig.py @@ -0,0 +1,540 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2004-2016 V.A. Sole, European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ######################################################################### */ +"""This module defines widgets used to build a fit configuration dialog. +The resulting dialog widget outputs a dictionary of configuration parameters. +""" +from silx.gui import qt + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "30/11/2016" + + +class TabsDialog(qt.QDialog): + """Dialog widget containing a QTabWidget :attr:`tabWidget` + and a buttons: + + # - buttonHelp + - buttonDefaults + - buttonOk + - buttonCancel + + This dialog defines a __len__ returning the number of tabs, + and an __iter__ method yielding the tab widgets. + """ + def __init__(self, parent=None): + qt.QDialog.__init__(self, parent) + self.tabWidget = qt.QTabWidget(self) + + layout = qt.QVBoxLayout(self) + layout.addWidget(self.tabWidget) + + layout2 = qt.QHBoxLayout(None) + + # self.buttonHelp = qt.QPushButton(self) + # self.buttonHelp.setText("Help") + # layout2.addWidget(self.buttonHelp) + + self.buttonDefault = qt.QPushButton(self) + self.buttonDefault.setText("Default") + layout2.addWidget(self.buttonDefault) + + spacer = qt.QSpacerItem(20, 20, + qt.QSizePolicy.Expanding, + qt.QSizePolicy.Minimum) + layout2.addItem(spacer) + + self.buttonOk = qt.QPushButton(self) + self.buttonOk.setText("OK") + layout2.addWidget(self.buttonOk) + + self.buttonCancel = qt.QPushButton(self) + self.buttonCancel.setText("Cancel") + layout2.addWidget(self.buttonCancel) + + layout.addLayout(layout2) + + self.buttonOk.clicked.connect(self.accept) + self.buttonCancel.clicked.connect(self.reject) + + def __len__(self): + """Return number of tabs""" + return self.tabWidget.count() + + def __iter__(self): + """Return the next tab widget in :attr:`tabWidget` every + time this method is called. + + :return: Tab widget + :rtype: QWidget + """ + for widget_index in range(len(self)): + yield self.tabWidget.widget(widget_index) + + def addTab(self, page, label): + """Add a new tab + + :param page: Content of new page. Must be a widget with + a get() method returning a dictionary. + :param str label: Tab label + """ + self.tabWidget.addTab(page, label) + + def getTabLabels(self): + """ + Return a list of all tab labels in :attr:`tabWidget` + """ + return [self.tabWidget.tabText(i) for i in range(len(self))] + + +class TabsDialogData(TabsDialog): + """This dialog adds a data attribute to :class:`TabsDialog`. + + Data input in widgets, such as text entries or checkboxes, is stored in an + attribute :attr:`output` when the user clicks the OK button. + + A default dictionary can be supplied when this dialog is initialized, to + be used as default data for :attr:`output`. + """ + def __init__(self, parent=None, modal=True, default=None): + """ + + :param parent: Parent :class:`QWidget` + :param modal: If `True`, dialog is modal, meaning this dialog remains + in front of it's parent window and disables it until the user is + done interacting with the dialog + :param default: Default dictionary, used to initialize and reset + :attr:`output`. + """ + TabsDialog.__init__(self, parent) + self.setModal(modal) + self.setWindowTitle("Fit configuration") + + self.output = {} + + self.default = {} if default is None else default + + self.buttonDefault.clicked.connect(self.setDefault) + # self.keyPressEvent(qt.Qt.Key_Enter). + + def keyPressEvent(self, event): + """Redefining this method to ignore Enter key + (for some reason it activates buttonDefault callback which + resets all widgets) + """ + if event.key() in [qt.Qt.Key_Enter, qt.Qt.Key_Return]: + return + TabsDialog.keyPressEvent(self, event) + + def accept(self): + """When *OK* is clicked, update :attr:`output` with data from + various widgets + """ + self.output.update(self.default) + + # loop over all tab widgets (uses TabsDialog.__iter__) + for tabWidget in self: + self.output.update(tabWidget.get()) + + # avoid pathological None cases + for key in self.output.keys(): + if self.output[key] is None: + if key in self.default: + self.output[key] = self.default[key] + super(TabsDialogData, self).accept() + + def reject(self): + """When the *Cancel* button is clicked, reinitialize :attr:`output` + and quit + """ + self.setDefault() + super(TabsDialogData, self).reject() + + def setDefault(self, newdefault=None): + """Reinitialize :attr:`output` with :attr:`default` or with + new dictionary ``newdefault`` if provided. + Call :meth:`setDefault` for each tab widget, if available. + """ + self.output = {} + if newdefault is None: + newdefault = self.default + else: + self.default = newdefault + self.output.update(newdefault) + + for tabWidget in self: + if hasattr(tabWidget, "setDefault"): + tabWidget.setDefault(self.output) + + +class ConstraintsPage(qt.QGroupBox): + """Checkable QGroupBox widget filled with QCheckBox widgets, + to configure the fit estimation for standard fit theories. + """ + def __init__(self, parent=None, title="Set constraints"): + super(ConstraintsPage, self).__init__(parent) + self.setTitle(title) + self.setToolTip("Disable 'Set constraints' to remove all " + + "constraints on all fit parameters") + self.setCheckable(True) + + layout = qt.QVBoxLayout(self) + self.setLayout(layout) + + self.positiveHeightCB = qt.QCheckBox("Force positive height/area", self) + self.positiveHeightCB.setToolTip("Fit must find positive peaks") + layout.addWidget(self.positiveHeightCB) + + self.positionInIntervalCB = qt.QCheckBox("Force position in interval", self) + self.positionInIntervalCB.setToolTip( + "Fit must position peak within X limits") + layout.addWidget(self.positionInIntervalCB) + + self.positiveFwhmCB = qt.QCheckBox("Force positive FWHM", self) + self.positiveFwhmCB.setToolTip("Fit must find a positive FWHM") + layout.addWidget(self.positiveFwhmCB) + + self.sameFwhmCB = qt.QCheckBox("Force same FWHM for all peaks", self) + self.sameFwhmCB.setToolTip("Fit must find same FWHM for all peaks") + layout.addWidget(self.sameFwhmCB) + + self.quotedEtaCB = qt.QCheckBox("Force Eta between 0 and 1", self) + self.quotedEtaCB.setToolTip( + "Fit must find Eta between 0 and 1 for pseudo-Voigt function") + layout.addWidget(self.quotedEtaCB) + + layout.addStretch() + + self.setDefault() + + def setDefault(self, default_dict=None): + """Set default state for all widgets. + + :param default_dict: If a default config dictionary is provided as + a parameter, its values are used as default state.""" + if default_dict is None: + default_dict = {} + # this one uses reverse logic: if checked, NoConstraintsFlag must be False + self.setChecked( + not default_dict.get('NoConstraintsFlag', False)) + self.positiveHeightCB.setChecked( + default_dict.get('PositiveHeightAreaFlag', True)) + self.positionInIntervalCB.setChecked( + default_dict.get('QuotedPositionFlag', False)) + self.positiveFwhmCB.setChecked( + default_dict.get('PositiveFwhmFlag', True)) + self.sameFwhmCB.setChecked( + default_dict.get('SameFwhmFlag', False)) + self.quotedEtaCB.setChecked( + default_dict.get('QuotedEtaFlag', False)) + + def get(self): + """Return a dictionary of constraint flags, to be processed by the + :meth:`configure` method of the selected fit theory.""" + ddict = { + 'NoConstraintsFlag': not self.isChecked(), + 'PositiveHeightAreaFlag': self.positiveHeightCB.isChecked(), + 'QuotedPositionFlag': self.positionInIntervalCB.isChecked(), + 'PositiveFwhmFlag': self.positiveFwhmCB.isChecked(), + 'SameFwhmFlag': self.sameFwhmCB.isChecked(), + 'QuotedEtaFlag': self.quotedEtaCB.isChecked(), + } + return ddict + + +class SearchPage(qt.QWidget): + def __init__(self, parent=None): + super(SearchPage, self).__init__(parent) + layout = qt.QVBoxLayout(self) + + self.manualFwhmGB = qt.QGroupBox("Define FWHM manually", self) + self.manualFwhmGB.setCheckable(True) + self.manualFwhmGB.setToolTip( + "If disabled, the FWHM parameter used for peak search is " + + "estimated based on the highest peak in the data") + layout.addWidget(self.manualFwhmGB) + # ------------ GroupBox fwhm-------------------------- + layout2 = qt.QHBoxLayout(self.manualFwhmGB) + self.manualFwhmGB.setLayout(layout2) + + label = qt.QLabel("Fwhm Points", self.manualFwhmGB) + layout2.addWidget(label) + + self.fwhmPointsSpin = qt.QSpinBox(self.manualFwhmGB) + self.fwhmPointsSpin.setRange(0, 999999) + self.fwhmPointsSpin.setToolTip("Typical peak fwhm (number of data points)") + layout2.addWidget(self.fwhmPointsSpin) + # ---------------------------------------------------- + + self.manualScalingGB = qt.QGroupBox("Define scaling manually", self) + self.manualScalingGB.setCheckable(True) + self.manualScalingGB.setToolTip( + "If disabled, the Y scaling used for peak search is " + + "estimated automatically") + layout.addWidget(self.manualScalingGB) + # ------------ GroupBox scaling----------------------- + layout3 = qt.QHBoxLayout(self.manualScalingGB) + self.manualScalingGB.setLayout(layout3) + + label = qt.QLabel("Y Scaling", self.manualScalingGB) + layout3.addWidget(label) + + self.yScalingEntry = qt.QLineEdit(self.manualScalingGB) + self.yScalingEntry.setToolTip( + "Data values will be multiplied by this value prior to peak" + + " search") + self.yScalingEntry.setValidator(qt.QDoubleValidator()) + layout3.addWidget(self.yScalingEntry) + # ---------------------------------------------------- + + # ------------------- grid layout -------------------- + containerWidget = qt.QWidget(self) + layout4 = qt.QHBoxLayout(containerWidget) + containerWidget.setLayout(layout4) + + label = qt.QLabel("Sensitivity", containerWidget) + layout4.addWidget(label) + + self.sensitivityEntry = qt.QLineEdit(containerWidget) + self.sensitivityEntry.setToolTip( + "Peak search sensitivity threshold, expressed as a multiple " + + "of the standard deviation of the noise.\nMinimum value is 1 " + + "(to be detected, peak must be higher than the estimated noise)") + sensivalidator = qt.QDoubleValidator() + sensivalidator.setBottom(1.0) + self.sensitivityEntry.setValidator(sensivalidator) + layout4.addWidget(self.sensitivityEntry) + # ---------------------------------------------------- + layout.addWidget(containerWidget) + + self.forcePeakPresenceCB = qt.QCheckBox("Force peak presence", self) + self.forcePeakPresenceCB.setToolTip( + "If peak search algorithm is unsuccessful, place one peak " + + "at the maximum of the curve") + layout.addWidget(self.forcePeakPresenceCB) + + layout.addStretch() + + self.setDefault() + + def setDefault(self, default_dict=None): + """Set default values for all widgets. + + :param default_dict: If a default config dictionary is provided as + a parameter, its values are used as default values.""" + if default_dict is None: + default_dict = {} + self.manualFwhmGB.setChecked( + not default_dict.get('AutoFwhm', True)) + self.fwhmPointsSpin.setValue( + default_dict.get('FwhmPoints', 8)) + self.sensitivityEntry.setText( + str(default_dict.get('Sensitivity', 1.0))) + self.manualScalingGB.setChecked( + not default_dict.get('AutoScaling', False)) + self.yScalingEntry.setText( + str(default_dict.get('Yscaling', 1.0))) + self.forcePeakPresenceCB.setChecked( + default_dict.get('ForcePeakPresence', False)) + + def get(self): + """Return a dictionary of peak search parameters, to be processed by + the :meth:`configure` method of the selected fit theory.""" + ddict = { + 'AutoFwhm': not self.manualFwhmGB.isChecked(), + 'FwhmPoints': self.fwhmPointsSpin.value(), + 'Sensitivity': safe_float(self.sensitivityEntry.text()), + 'AutoScaling': not self.manualScalingGB.isChecked(), + 'Yscaling': safe_float(self.yScalingEntry.text()), + 'ForcePeakPresence': self.forcePeakPresenceCB.isChecked() + } + return ddict + + +class BackgroundPage(qt.QGroupBox): + """Background subtraction configuration, specific to fittheories + estimation functions.""" + def __init__(self, parent=None, + title="Subtract strip background prior to estimation"): + super(BackgroundPage, self).__init__(parent) + self.setTitle(title) + self.setCheckable(True) + self.setToolTip( + "The strip algorithm strips away peaks to compute the " + + "background signal.\nAt each iteration, a sample is compared " + + "to the average of the two samples at a given distance in both" + + " directions,\n and if its value is higher than the average," + "it is replaced by the average.") + + layout = qt.QGridLayout(self) + self.setLayout(layout) + + for i, label_text in enumerate( + ["Strip width (in samples)", + "Number of iterations", + "Strip threshold factor"]): + label = qt.QLabel(label_text) + layout.addWidget(label, i, 0) + + self.stripWidthSpin = qt.QSpinBox(self) + self.stripWidthSpin.setToolTip( + "Width, in number of samples, of the strip operator") + self.stripWidthSpin.setRange(1, 999999) + + layout.addWidget(self.stripWidthSpin, 0, 1) + + self.numIterationsSpin = qt.QSpinBox(self) + self.numIterationsSpin.setToolTip( + "Number of iterations of the strip algorithm") + self.numIterationsSpin.setRange(1, 999999) + layout.addWidget(self.numIterationsSpin, 1, 1) + + self.thresholdFactorEntry = qt.QLineEdit(self) + self.thresholdFactorEntry.setToolTip( + "Factor used by the strip algorithm to decide whether a sample" + + "value should be stripped.\nThe value must be higher than the " + + "average of the 2 samples at +- w times this factor.\n") + self.thresholdFactorEntry.setValidator(qt.QDoubleValidator()) + layout.addWidget(self.thresholdFactorEntry, 2, 1) + + self.smoothStripGB = qt.QGroupBox("Apply smoothing prior to strip", self) + self.smoothStripGB.setCheckable(True) + self.smoothStripGB.setToolTip( + "Apply a smoothing before subtracting strip background" + + " in fit and estimate processes") + smoothlayout = qt.QHBoxLayout(self.smoothStripGB) + label = qt.QLabel("Smoothing width (Savitsky-Golay)") + smoothlayout.addWidget(label) + self.smoothingWidthSpin = qt.QSpinBox(self) + self.smoothingWidthSpin.setToolTip( + "Width parameter for Savitsky-Golay smoothing (number of samples, must be odd)") + self.smoothingWidthSpin.setRange(3, 101) + self.smoothingWidthSpin.setSingleStep(2) + smoothlayout.addWidget(self.smoothingWidthSpin) + + layout.addWidget(self.smoothStripGB, 3, 0, 1, 2) + + layout.setRowStretch(4, 1) + + self.setDefault() + + def setDefault(self, default_dict=None): + """Set default values for all widgets. + + :param default_dict: If a default config dictionary is provided as + a parameter, its values are used as default values.""" + if default_dict is None: + default_dict = {} + + self.setChecked( + default_dict.get('StripBackgroundFlag', True)) + + self.stripWidthSpin.setValue( + default_dict.get('StripWidth', 2)) + self.numIterationsSpin.setValue( + default_dict.get('StripIterations', 5000)) + self.thresholdFactorEntry.setText( + str(default_dict.get('StripThreshold', 1.0))) + self.smoothStripGB.setChecked( + default_dict.get('SmoothingFlag', False)) + self.smoothingWidthSpin.setValue( + default_dict.get('SmoothingWidth', 3)) + + def get(self): + """Return a dictionary of background subtraction parameters, to be + processed by the :meth:`configure` method of the selected fit theory. + """ + ddict = { + 'StripBackgroundFlag': self.isChecked(), + 'StripWidth': self.stripWidthSpin.value(), + 'StripIterations': self.numIterationsSpin.value(), + 'StripThreshold': safe_float(self.thresholdFactorEntry.text()), + 'SmoothingFlag': self.smoothStripGB.isChecked(), + 'SmoothingWidth': self.smoothingWidthSpin.value() + } + return ddict + + +def safe_float(string_, default=1.0): + """Convert a string into a float. + If the conversion fails, return the default value. + """ + try: + ret = float(string_) + except ValueError: + return default + else: + return ret + + +def safe_int(string_, default=1): + """Convert a string into a integer. + If the conversion fails, return the default value. + """ + try: + ret = int(float(string_)) + except ValueError: + return default + else: + return ret + + +def getFitConfigDialog(parent=None, default=None, modal=True): + """Instantiate and return a fit configuration dialog, adapted + for configuring standard fit theories from + :mod:`silx.math.fit.fittheories`. + + :return: Instance of :class:`TabsDialogData` with 3 tabs: + :class:`ConstraintsPage`, :class:`SearchPage` and + :class:`BackgroundPage` + """ + tdd = TabsDialogData(parent=parent, default=default) + tdd.addTab(ConstraintsPage(), label="Constraints") + tdd.addTab(SearchPage(), label="Peak search") + tdd.addTab(BackgroundPage(), label="Background") + # apply default to newly added pages + tdd.setDefault() + + return tdd + + +def main(): + a = qt.QApplication([]) + + mw = qt.QMainWindow() + mw.show() + + tdd = getFitConfigDialog(mw, default={"a": 1}) + tdd.show() + tdd.exec_() + print("TabsDialogData result: ", tdd.result()) + print("TabsDialogData output: ", tdd.output) + + a.exec_() + +if __name__ == "__main__": + main() diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py new file mode 100644 index 0000000..a5c3cfd --- /dev/null +++ b/silx/gui/fit/FitWidget.py @@ -0,0 +1,727 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2004-2017 European Synchrotron Radiation Facility +# +# This file is part of the PyMca X-ray Fluorescence Toolkit developed at +# the ESRF by the Software group. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ######################################################################### */ +"""This module provides a widget designed to configure and run a fitting +process with constraints on parameters. + +The main class is :class:`FitWidget`. It relies on +:mod:`silx.math.fit.fitmanager`, which relies on :func:`silx.math.fit.leastsq`. + +The user can choose between functions before running the fit. These function can +be user defined, or by default are loaded from +:mod:`silx.math.fit.fittheories`. +""" + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "15/02/2017" + +import logging +import sys +import traceback +import warnings + +from silx.math.fit import fittheories +from silx.math.fit import fitmanager, functions +from silx.gui import qt +from .FitWidgets import (FitActionsButtons, FitStatusLines, + FitConfigWidget, ParametersTab) +from .FitConfig import getFitConfigDialog +from .BackgroundWidget import getBgDialog, BackgroundDialog + +QTVERSION = qt.qVersion() +DEBUG = 0 +_logger = logging.getLogger(__name__) + + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "30/11/2016" + + +class FitWidget(qt.QWidget): + """This widget can be used to configure, run and display results of a + fitting process. + + The standard steps for using this widget is to initialize it, then load + the data to be fitted. + + Optionally, you can also load user defined fit theories. If you skip this + step, a series of default fit functions will be presented (gaussian-like + functions), and you can later load your custom fit theories from an + external file using the GUI. + + A fit theory is a fit function and its associated features: + + - estimation function, + - list of parameter names + - numerical derivative algorithm + - configuration widget + + Once the widget is up and running, the user may select a fit theory and a + background theory, change configuration parameters specific to the theory + run the estimation, set constraints on parameters and run the actual fit. + + The results are displayed in a table. + """ + sigFitWidgetSignal = qt.Signal(object) + """This signal is emitted by the estimation and fit methods. + It carries a dictionary with two items: + + - *event*: one of the following strings + + - *EstimateStarted*, + - *FitStarted* + - *EstimateFinished*, + - *FitFinished* + - *EstimateFailed* + - *FitFailed* + + - *data*: None, or fit/estimate results (see documentation for + :attr:`silx.math.fit.fitmanager.FitManager.fit_results`) + """ + + def __init__(self, parent=None, title=None, fitmngr=None, + enableconfig=True, enablestatus=True, enablebuttons=True): + """ + + :param parent: Parent widget + :param title: Window title + :param fitmngr: User defined instance of + :class:`silx.math.fit.fitmanager.FitManager`, or ``None`` + :param enableconfig: If ``True``, activate widgets to modify the fit + configuration (select between several fit functions or background + functions, apply global constraints, peak search parameters…) + :param enablestatus: If ``True``, add a fit status widget, to display + a message when fit estimation is available and when fit results + are available, as well as a measure of the fit error. + :param enablebuttons: If ``True``, add buttons to run estimation and + fitting. + """ + if title is None: + title = "FitWidget" + qt.QWidget.__init__(self, parent) + + self.setWindowTitle(title) + layout = qt.QVBoxLayout(self) + + self.fitmanager = self._setFitManager(fitmngr) + """Instance of :class:`FitManager`. + This is the underlying data model of this FitWidget. + + If no custom theories are defined, the default ones from + :mod:`silx.math.fit.fittheories` are imported. + """ + + # reference fitmanager.configure method for direct access + self.configure = self.fitmanager.configure + self.fitconfig = self.fitmanager.fitconfig + + self.configdialogs = {} + """This dictionary defines the fit configuration widgets + associated with the fit theories in :attr:`fitmanager.theories` + + Keys must correspond to existing theory names, i.e. existing keys + in :attr:`fitmanager.theories`. + + Values must be instances of QDialog widgets with an additional + *output* attribute, a dictionary storing configuration parameters + interpreted by the corresponding fit theory. + + The dialog can also define a *setDefault* method to initialize the + widget values with values in a dictionary passed as a parameter. + This will be executed first. + + In case the widget does not actually inherit :class:`QDialog`, it + must at least implement the following methods (executed in this + particular order): + + - :meth:`show`: should cause the widget to become visible to the + user) + - :meth:`exec_`: should run while the user is interacting with the + widget, interrupting the rest of the program. It should + typically end (*return*) when the user clicks an *OK* + or a *Cancel* button. + - :meth:`result`: must return ``True`` if the new configuration in + attribute :attr:`output` is to be accepted (user clicked *OK*), + or return ``False`` if :attr:`output` is to be rejected (user + clicked *Cancel*) + + To associate a custom configuration widget with a fit theory, use + :meth:`associateConfigDialog`. E.g.:: + + fw = FitWidget() + my_config_widget = MyGaussianConfigWidget(parent=fw) + fw.associateConfigDialog(theory_name="Gaussians", + config_widget=my_config_widget) + """ + + self.bgconfigdialogs = {} + """Same as :attr:`configdialogs`, except that the widget is associated + with a background theory in :attr:`fitmanager.bgtheories`""" + + self._associateDefaultConfigDialogs() + + self.guiConfig = None + """Configuration widget at the top of FitWidget, to select + fit function, background function, and open an advanced + configuration dialog.""" + + self.guiParameters = ParametersTab(self) + """Table widget for display of fit parameters and constraints""" + + if enableconfig: + self.guiConfig = FitConfigWidget(self) + """Function selector and configuration widget""" + + self.guiConfig.FunConfigureButton.clicked.connect( + self.__funConfigureGuiSlot) + self.guiConfig.BgConfigureButton.clicked.connect( + self.__bgConfigureGuiSlot) + + self.guiConfig.WeightCheckBox.setChecked( + self.fitconfig.get("WeightFlag", False)) + self.guiConfig.WeightCheckBox.stateChanged[int].connect(self.weightEvent) + + self.guiConfig.BkgComBox.activated[str].connect(self.bkgEvent) + self.guiConfig.FunComBox.activated[str].connect(self.funEvent) + self._populateFunctions() + + layout.addWidget(self.guiConfig) + + layout.addWidget(self.guiParameters) + + if enablestatus: + self.guistatus = FitStatusLines(self) + """Status bar""" + layout.addWidget(self.guistatus) + + if enablebuttons: + self.guibuttons = FitActionsButtons(self) + """Widget with estimate, start fit and dismiss buttons""" + self.guibuttons.EstimateButton.clicked.connect(self.estimate) + self.guibuttons.StartFitButton.clicked.connect(self.startFit) + self.guibuttons.DismissButton.clicked.connect(self.dismiss) + layout.addWidget(self.guibuttons) + + def _setFitManager(self, fitinstance): + """Initialize a :class:`FitManager` instance, to be assigned to + :attr:`fitmanager`, or use a custom FitManager instance. + + :param fitinstance: Existing instance of FitManager, possibly + customized by the user, or None to load a default instance.""" + if isinstance(fitinstance, fitmanager.FitManager): + # customized + fitmngr = fitinstance + else: + # initialize default instance + fitmngr = fitmanager.FitManager() + + # initialize the default fitting functions in case + # none is present + if not len(fitmngr.theories): + fitmngr.loadtheories(fittheories) + + return fitmngr + + def _associateDefaultConfigDialogs(self): + """Fill :attr:`bgconfigdialogs` and :attr:`configdialogs` by calling + :meth:`associateConfigDialog` with default config dialog widgets. + """ + # associate silx.gui.fit.FitConfig with all theories + # Users can later associate their own custom dialogs to + # replace the default. + configdialog = getFitConfigDialog(parent=self, + default=self.fitconfig) + for theory in self.fitmanager.theories: + self.associateConfigDialog(theory, configdialog) + for bgtheory in self.fitmanager.bgtheories: + self.associateConfigDialog(bgtheory, configdialog, + theory_is_background=True) + + # associate silx.gui.fit.BackgroundWidget with Strip and Snip + bgdialog = getBgDialog(parent=self, + default=self.fitconfig) + for bgtheory in ["Strip", "Snip"]: + if bgtheory in self.fitmanager.bgtheories: + self.associateConfigDialog(bgtheory, bgdialog, + theory_is_background=True) + + def _populateFunctions(self): + """Fill combo-boxes with fit theories and background theories + loaded by :attr:`fitmanager`. + Run :meth:`fitmanager.configure` to ensure the custom configuration + of the selected theory has been loaded into :attr:`fitconfig`""" + for theory_name in self.fitmanager.bgtheories: + self.guiConfig.BkgComBox.addItem(theory_name) + self.guiConfig.BkgComBox.setItemData( + self.guiConfig.BkgComBox.findText(theory_name), + self.fitmanager.bgtheories[theory_name].description, + qt.Qt.ToolTipRole) + + for theory_name in self.fitmanager.theories: + self.guiConfig.FunComBox.addItem(theory_name) + self.guiConfig.FunComBox.setItemData( + self.guiConfig.FunComBox.findText(theory_name), + self.fitmanager.theories[theory_name].description, + qt.Qt.ToolTipRole) + + # - activate selected fit theory (if any) + # - activate selected bg theory (if any) + configuration = self.fitmanager.configure() + if self.fitmanager.selectedtheory is None: + # take the first one by default + self.guiConfig.FunComBox.setCurrentIndex(1) + self.funEvent(list(self.fitmanager.theories.keys())[0]) + else: + idx = list(self.fitmanager.theories).index(self.fitmanager.selectedtheory) + self.guiConfig.FunComBox.setCurrentIndex(idx + 1) + self.funEvent(self.fitmanager.selectedtheory) + + if self.fitmanager.selectedbg is None: + self.guiConfig.BkgComBox.setCurrentIndex(1) + self.bkgEvent(list(self.fitmanager.bgtheories.keys())[0]) + else: + idx = list(self.fitmanager.bgtheories).index(self.fitmanager.selectedbg) + self.guiConfig.BkgComBox.setCurrentIndex(idx + 1) + self.bkgEvent(self.fitmanager.selectedbg) + + configuration.update(self.configure()) + + def setdata(self, x, y, sigmay=None, xmin=None, xmax=None): + warnings.warn("Method renamed to setData", + DeprecationWarning) + self.setData(x, y, sigmay, xmin, xmax) + + def setData(self, x, y, sigmay=None, xmin=None, xmax=None): + """Set data to be fitted. + + :param x: Abscissa data. If ``None``, :attr:`xdata`` is set to + ``numpy.array([0.0, 1.0, 2.0, ..., len(y)-1])`` + :type x: Sequence or numpy array or None + :param y: The dependant data ``y = f(x)``. ``y`` must have the same + shape as ``x`` if ``x`` is not ``None``. + :type y: Sequence or numpy array or None + :param sigmay: The uncertainties in the ``ydata`` array. These are + used as weights in the least-squares problem. + If ``None``, the uncertainties are assumed to be 1. + :type sigmay: Sequence or numpy array or None + :param xmin: Lower value of x values to use for fitting + :param xmax: Upper value of x values to use for fitting + """ + self.fitmanager.setdata(x=x, y=y, sigmay=sigmay, + xmin=xmin, xmax=xmax) + for config_dialog in self.bgconfigdialogs.values(): + if isinstance(config_dialog, BackgroundDialog): + config_dialog.setData(x, y, xmin=xmin, xmax=xmax) + + def associateConfigDialog(self, theory_name, config_widget, + theory_is_background=False): + """Associate an instance of custom configuration dialog widget to + a fit theory or to a background theory. + + This adds or modifies an item in the correspondence table + :attr:`configdialogs` or :attr:`bgconfigdialogs`. + + :param str theory_name: Name of fit theory. This must be a key of dict + :attr:`fitmanager.theories` + :param config_widget: Custom configuration widget. See documentation + for :attr:`configdialogs` + :param bool theory_is_background: If flag is *True*, add dialog to + :attr:`bgconfigdialogs` rather than :attr:`configdialogs` + (default). + :raise: KeyError if parameter ``theory_name`` does not match an + existing fit theory or background theory in :attr:`fitmanager`. + :raise: AttributeError if the widget does not implement the mandatory + methods (*show*, *exec_*, *result*, *setDefault*) or the mandatory + attribute (*output*). + """ + theories = self.fitmanager.bgtheories if theory_is_background else\ + self.fitmanager.theories + + if theory_name not in theories: + raise KeyError("%s does not match an existing fitmanager theory") + + if config_widget is not None: + for mandatory_attr in ["show", "exec_", "result", "output"]: + if not hasattr(config_widget, mandatory_attr): + raise AttributeError( + "Custom configuration widget must define " + + "attribute or method " + mandatory_attr) + + if theory_is_background: + self.bgconfigdialogs[theory_name] = config_widget + else: + self.configdialogs[theory_name] = config_widget + + def _emitSignal(self, ddict): + """Emit pyqtSignal after estimation completed + (``ddict = {'event': 'EstimateFinished', 'data': fit_results}``) + and after fit completed + (``ddict = {'event': 'FitFinished', 'data': fit_results}``)""" + self.sigFitWidgetSignal.emit(ddict) + + def __funConfigureGuiSlot(self): + """Open an advanced configuration dialog widget""" + self.__configureGui(dialog_type="function") + + def __bgConfigureGuiSlot(self): + """Open an advanced configuration dialog widget""" + self.__configureGui(dialog_type="background") + + def __configureGui(self, newconfiguration=None, dialog_type="function"): + """Open an advanced configuration dialog widget to get a configuration + dictionary, or use a supplied configuration dictionary. Call + :meth:`configure` with this dictionary as a parameter. Update the gui + accordingly. Reinitialize the fit results in the table and in + :attr:`fitmanager`. + + :param newconfiguration: User supplied configuration dictionary. If ``None``, + open a dialog widget that returns a dictionary.""" + configuration = self.configure() + # get new dictionary + if newconfiguration is None: + newconfiguration = self.configureDialog(configuration, dialog_type) + # update configuration + configuration.update(self.configure(**newconfiguration)) + # set fit function theory + try: + i = 1 + \ + list(self.fitmanager.theories.keys()).index( + self.fitmanager.selectedtheory) + self.guiConfig.FunComBox.setCurrentIndex(i) + self.funEvent(self.fitmanager.selectedtheory) + except ValueError: + _logger.error("Function not in list %s", + self.fitmanager.selectedtheory) + self.funEvent(list(self.fitmanager.theories.keys())[0]) + # current background + try: + i = 1 + \ + list(self.fitmanager.bgtheories.keys()).index( + self.fitmanager.selectedbg) + self.guiConfig.BkgComBox.setCurrentIndex(i) + self.bkgEvent(self.fitmanager.selectedbg) + except ValueError: + _logger.error("Background not in list %s", + self.fitmanager.selectedbg) + self.bkgEvent(list(self.fitmanager.bgtheories.keys())[0]) + + # update the Gui + self.__initialParameters() + + def configureDialog(self, oldconfiguration, dialog_type="function"): + """Display a dialog, allowing the user to define fit configuration + parameters. + + By default, a common dialog is used for all fit theories. But if the + defined a custom dialog using :meth:`associateConfigDialog`, it is + used instead. + + :param dict oldconfiguration: Dictionary containing previous configuration + :param str dialog_type: "function" or "background" + :return: User defined parameters in a dictionary + """ + newconfiguration = {} + newconfiguration.update(oldconfiguration) + + if dialog_type == "function": + theory = self.fitmanager.selectedtheory + configdialog = self.configdialogs[theory] + elif dialog_type == "background": + theory = self.fitmanager.selectedbg + configdialog = self.bgconfigdialogs[theory] + + # this should only happen if a user specifically associates None + # with a theory, to have no configuration option + if configdialog is None: + return {} + + # update state of configdialog before showing it + if hasattr(configdialog, "setDefault"): + configdialog.setDefault(newconfiguration) + configdialog.show() + configdialog.exec_() + if configdialog.result(): + newconfiguration.update(configdialog.output) + + return newconfiguration + + def estimate(self): + """Run parameter estimation function then emit + :attr:`sigFitWidgetSignal` with a dictionary containing a status + message and a list of fit parameters estimations + in the format defined in + :attr:`silx.math.fit.fitmanager.FitManager.fit_results` + + The emitted dictionary has an *"event"* key that can have + following values: + + - *'EstimateStarted'* + - *'EstimateFailed'* + - *'EstimateFinished'* + """ + try: + theory_name = self.fitmanager.selectedtheory + estimation_function = self.fitmanager.theories[theory_name].estimate + if estimation_function is not None: + ddict = {'event': 'EstimateStarted', + 'data': None} + self._emitSignal(ddict) + self.fitmanager.estimate(callback=self.fitStatus) + else: + msg = qt.QMessageBox(self) + msg.setIcon(qt.QMessageBox.Information) + text = "Function does not define a way to estimate\n" + text += "the initial parameters. Please, fill them\n" + text += "yourself in the table and press Start Fit\n" + msg.setText(text) + msg.setWindowTitle('FitWidget Message') + msg.exec_() + return + except: # noqa (we want to catch and report all errors) + msg = qt.QMessageBox(self) + msg.setIcon(qt.QMessageBox.Critical) + msg.setText("Error on estimate: %s" % traceback.format_exc()) + msg.exec_() + ddict = { + 'event': 'EstimateFailed', + 'data': None} + self._emitSignal(ddict) + return + + self.guiParameters.fillFromFit( + self.fitmanager.fit_results, view='Fit') + self.guiParameters.removeAllViews(keep='Fit') + ddict = { + 'event': 'EstimateFinished', + 'data': self.fitmanager.fit_results} + self._emitSignal(ddict) + + def startfit(self): + warnings.warn("Method renamed to startFit", + DeprecationWarning) + self.startFit() + + def startFit(self): + """Run fit, then emit :attr:`sigFitWidgetSignal` with a dictionary + containing a status message and a list of fit + parameters results in the format defined in + :attr:`silx.math.fit.fitmanager.FitManager.fit_results` + + The emitted dictionary has an *"event"* key that can have + following values: + + - *'FitStarted'* + - *'FitFailed'* + - *'FitFinished'* + """ + self.fitmanager.fit_results = self.guiParameters.getFitResults() + try: + ddict = {'event': 'FitStarted', + 'data': None} + self._emitSignal(ddict) + self.fitmanager.runfit(callback=self.fitStatus) + except: # noqa (we want to catch and report all errors) + msg = qt.QMessageBox(self) + msg.setIcon(qt.QMessageBox.Critical) + msg.setText("Error on Fit: %s" % traceback.format_exc()) + msg.exec_() + ddict = { + 'event': 'FitFailed', + 'data': None + } + self._emitSignal(ddict) + return + + self.guiParameters.fillFromFit( + self.fitmanager.fit_results, view='Fit') + self.guiParameters.removeAllViews(keep='Fit') + ddict = { + 'event': 'FitFinished', + 'data': self.fitmanager.fit_results + } + self._emitSignal(ddict) + return + + def bkgEvent(self, bgtheory): + """Select background theory, then reinitialize parameters""" + bgtheory = str(bgtheory) + if bgtheory in self.fitmanager.bgtheories: + self.fitmanager.setbackground(bgtheory) + else: + functionsfile = qt.QFileDialog.getOpenFileName( + self, "Select python module with your function(s)", "", + "Python Files (*.py);;All Files (*)") + + if len(functionsfile): + try: + self.fitmanager.loadbgtheories(functionsfile) + except ImportError: + qt.QMessageBox.critical(self, "ERROR", + "Function not imported") + return + else: + # empty the ComboBox + while self.guiConfig.BkgComBox.count() > 1: + self.guiConfig.BkgComBox.removeItem(1) + # and fill it again + for key in self.fitmanager.bgtheories: + self.guiConfig.BkgComBox.addItem(str(key)) + + i = 1 + \ + list(self.fitmanager.bgtheories.keys()).index( + self.fitmanager.selectedbg) + self.guiConfig.BkgComBox.setCurrentIndex(i) + self.__initialParameters() + + def funEvent(self, theoryname): + """Select a fit theory to be used for fitting. If this theory exists + in :attr:`fitmanager`, use it. Then, reinitialize table. + + :param theoryname: Name of the fit theory to use for fitting. If this theory + exists in :attr:`fitmanager`, use it. Else, open a file dialog to open + a custom fit function definition file with + :meth:`fitmanager.loadtheories`. + """ + theoryname = str(theoryname) + if theoryname in self.fitmanager.theories: + self.fitmanager.settheory(theoryname) + else: + # open a load file dialog + functionsfile = qt.QFileDialog.getOpenFileName( + self, "Select python module with your function(s)", "", + "Python Files (*.py);;All Files (*)") + + if len(functionsfile): + try: + self.fitmanager.loadtheories(functionsfile) + except ImportError: + qt.QMessageBox.critical(self, "ERROR", + "Function not imported") + return + else: + # empty the ComboBox + while self.guiConfig.FunComBox.count() > 1: + self.guiConfig.FunComBox.removeItem(1) + # and fill it again + for key in self.fitmanager.theories: + self.guiConfig.FunComBox.addItem(str(key)) + + i = 1 + \ + list(self.fitmanager.theories.keys()).index( + self.fitmanager.selectedtheory) + self.guiConfig.FunComBox.setCurrentIndex(i) + self.__initialParameters() + + def weightEvent(self, flag): + """This is called when WeightCheckBox is clicked, to configure the + *WeightFlag* field in :attr:`fitmanager.fitconfig` and set weights + in the least-square problem.""" + self.configure(WeightFlag=flag) + if flag: + self.fitmanager.enableweight() + else: + # set weights back to 1 + self.fitmanager.disableweight() + + def __initialParameters(self): + """Fill the fit parameters names with names of the parameters of + the selected background theory and the selected fit theory. + Initialize :attr:`fitmanager.fit_results` with these names, and + initialize the table with them. This creates a view called "Fit" + in :attr:`guiParameters`""" + self.fitmanager.parameter_names = [] + self.fitmanager.fit_results = [] + for pname in self.fitmanager.bgtheories[self.fitmanager.selectedbg].parameters: + self.fitmanager.parameter_names.append(pname) + self.fitmanager.fit_results.append({'name': pname, + 'estimation': 0, + 'group': 0, + 'code': 'FREE', + 'cons1': 0, + 'cons2': 0, + 'fitresult': 0.0, + 'sigma': 0.0, + 'xmin': None, + 'xmax': None}) + if self.fitmanager.selectedtheory is not None: + theory = self.fitmanager.selectedtheory + for pname in self.fitmanager.theories[theory].parameters: + self.fitmanager.parameter_names.append(pname + "1") + self.fitmanager.fit_results.append({'name': pname + "1", + 'estimation': 0, + 'group': 1, + 'code': 'FREE', + 'cons1': 0, + 'cons2': 0, + 'fitresult': 0.0, + 'sigma': 0.0, + 'xmin': None, + 'xmax': None}) + + self.guiParameters.fillFromFit( + self.fitmanager.fit_results, view='Fit') + + def fitStatus(self, data): + """Set *status* and *chisq* in status bar""" + if 'chisq' in data: + if data['chisq'] is None: + self.guistatus.ChisqLine.setText(" ") + else: + chisq = data['chisq'] + self.guistatus.ChisqLine.setText("%6.2f" % chisq) + + if 'status' in data: + status = data['status'] + self.guistatus.StatusLine.setText(str(status)) + + def dismiss(self): + """Close FitWidget""" + self.close() + + +if __name__ == "__main__": + import numpy + + x = numpy.arange(1500).astype(numpy.float) + constant_bg = 3.14 + + p = [1000, 100., 30.0, + 500, 300., 25., + 1700, 500., 35., + 750, 700., 30.0, + 1234, 900., 29.5, + 302, 1100., 30.5, + 75, 1300., 21.] + y = functions.sum_gauss(x, *p) + constant_bg + + a = qt.QApplication(sys.argv) + w = FitWidget() + w.setData(x=x, y=y) + w.show() + a.exec_() diff --git a/silx/gui/fit/FitWidgets.py b/silx/gui/fit/FitWidgets.py new file mode 100644 index 0000000..408666b --- /dev/null +++ b/silx/gui/fit/FitWidgets.py @@ -0,0 +1,559 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2004-2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ######################################################################### */ +"""Collection of widgets used to build +:class:`silx.gui.fit.FitWidget.FitWidget`""" + +from collections import OrderedDict + +from silx.gui import qt +from silx.gui.fit.Parameters import Parameters + +QTVERSION = qt.qVersion() + +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "13/10/2016" + + +class FitActionsButtons(qt.QWidget): + """Widget with 3 ``QPushButton``: + + The buttons can be accessed as public attributes:: + + - ``EstimateButton`` + - ``StartFitButton`` + - ``DismissButton`` + + You will typically need to access these attributes to connect the buttons + to actions. For instance, if you have 3 functions ``estimate``, + ``runfit`` and ``dismiss``, you can connect them like this:: + + >>> fit_actions_buttons = FitActionsButtons() + >>> fit_actions_buttons.EstimateButton.clicked.connect(estimate) + >>> fit_actions_buttons.StartFitButton.clicked.connect(runfit) + >>> fit_actions_buttons.DismissButton.clicked.connect(dismiss) + + """ + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.resize(234, 53) + + grid_layout = qt.QGridLayout(self) + grid_layout.setContentsMargins(11, 11, 11, 11) + grid_layout.setSpacing(6) + layout = qt.QHBoxLayout(None) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.EstimateButton = qt.QPushButton(self) + self.EstimateButton.setText("Estimate") + layout.addWidget(self.EstimateButton) + spacer = qt.QSpacerItem(20, 20, + qt.QSizePolicy.Expanding, + qt.QSizePolicy.Minimum) + layout.addItem(spacer) + + self.StartFitButton = qt.QPushButton(self) + self.StartFitButton.setText("Start Fit") + layout.addWidget(self.StartFitButton) + spacer_2 = qt.QSpacerItem(20, 20, + qt.QSizePolicy.Expanding, + qt.QSizePolicy.Minimum) + layout.addItem(spacer_2) + + self.DismissButton = qt.QPushButton(self) + self.DismissButton.setText("Dismiss") + layout.addWidget(self.DismissButton) + + grid_layout.addLayout(layout, 0, 0) + + +class FitStatusLines(qt.QWidget): + """Widget with 2 greyed out write-only ``QLineEdit``. + + These text widgets can be accessed as public attributes:: + + - ``StatusLine`` + - ``ChisqLine`` + + You will typically need to access these widgets to update the displayed + text:: + + >>> fit_status_lines = FitStatusLines() + >>> fit_status_lines.StatusLine.setText("Ready") + >>> fit_status_lines.ChisqLine.setText("%6.2f" % 0.01) + + """ + + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.resize(535, 47) + + layout = qt.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.StatusLabel = qt.QLabel(self) + self.StatusLabel.setText("Status:") + layout.addWidget(self.StatusLabel) + + self.StatusLine = qt.QLineEdit(self) + self.StatusLine.setText("Ready") + self.StatusLine.setReadOnly(1) + layout.addWidget(self.StatusLine) + + self.ChisqLabel = qt.QLabel(self) + self.ChisqLabel.setText("Reduced chisq:") + layout.addWidget(self.ChisqLabel) + + self.ChisqLine = qt.QLineEdit(self) + self.ChisqLine.setMaximumSize(qt.QSize(16000, 32767)) + self.ChisqLine.setText("") + self.ChisqLine.setReadOnly(1) + layout.addWidget(self.ChisqLine) + + +class FitConfigWidget(qt.QWidget): + """Widget whose purpose is to select a fit theory and a background + theory, load a new fit theory definition file and provide + a "Configure" button to open an advanced configuration dialog. + + This is used in :class:`silx.gui.fit.FitWidget.FitWidget`, to offer + an interface to quickly modify the main parameters prior to running a fit: + + - select a fitting function through :attr:`FunComBox` + - select a background function through :attr:`BkgComBox` + - open a dialog for modifying advanced parameters through + :attr:`FunConfigureButton` + """ + def __init__(self, parent=None): + qt.QWidget.__init__(self, parent) + + self.setWindowTitle("FitConfigGUI") + + layout = qt.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self.FunLabel = qt.QLabel(self) + self.FunLabel.setText("Function") + layout.addWidget(self.FunLabel, 0, 0) + + self.FunComBox = qt.QComboBox(self) + self.FunComBox.addItem("Add Function(s)") + self.FunComBox.setItemData(self.FunComBox.findText("Add Function(s)"), + "Load fit theories from a file", + qt.Qt.ToolTipRole) + layout.addWidget(self.FunComBox, 0, 1) + + self.BkgLabel = qt.QLabel(self) + self.BkgLabel.setText("Background") + layout.addWidget(self.BkgLabel, 1, 0) + + self.BkgComBox = qt.QComboBox(self) + self.BkgComBox.addItem("Add Background(s)") + self.BkgComBox.setItemData(self.BkgComBox.findText("Add Background(s)"), + "Load background theories from a file", + qt.Qt.ToolTipRole) + layout.addWidget(self.BkgComBox, 1, 1) + + self.FunConfigureButton = qt.QPushButton(self) + self.FunConfigureButton.setText("Configure") + self.FunConfigureButton.setToolTip( + "Open a configuration dialog for the selected function") + layout.addWidget(self.FunConfigureButton, 0, 2) + + self.BgConfigureButton = qt.QPushButton(self) + self.BgConfigureButton.setText("Configure") + self.BgConfigureButton.setToolTip( + "Open a configuration dialog for the selected background") + layout.addWidget(self.BgConfigureButton, 1, 2) + + self.WeightCheckBox = qt.QCheckBox(self) + self.WeightCheckBox.setText("Weighted fit") + self.WeightCheckBox.setToolTip( + "Enable usage of weights in the least-square problem.\n Use" + + " the uncertainties (sigma) if provided, else use sqrt(y).") + + layout.addWidget(self.WeightCheckBox, 0, 3, 2, 1) + + layout.setColumnStretch(4, 1) + + +class ParametersTab(qt.QTabWidget): + """This widget provides tabs to display and modify fit parameters. Each + tab contains a table with fit data such as parameter names, estimated + values, fit constraints, and final fit results. + + The usual way to initialize the table is to fill it with the fit + parameters from a :class:`silx.math.fit.fitmanager.FitManager` object, after + the estimation process or after the final fit. + + In the following example we use a :class:`ParametersTab` to display the + results of two separate fits:: + + from silx.math.fit import fittheories + from silx.math.fit import fitmanager + from silx.math.fit import functions + from silx.gui import qt + import numpy + + a = qt.QApplication([]) + + # Create synthetic data + x = numpy.arange(1000) + y1 = functions.sum_gauss(x, 100, 400, 100) + + fit = fitmanager.FitManager(x=x, y=y1) + + fitfuns = fittheories.FitTheories() + fit.addtheory(theory="Gaussian", + function=functions.sum_gauss, + parameters=("height", "peak center", "fwhm"), + estimate=fitfuns.estimate_height_position_fwhm) + fit.settheory('Gaussian') + fit.configure(PositiveFwhmFlag=True, + PositiveHeightAreaFlag=True, + AutoFwhm=True,) + + # Fit + fit.estimate() + fit.runfit() + + # Show first fit result in a tab in our widget + w = ParametersTab() + w.show() + w.fillFromFit(fit.fit_results, view='Gaussians') + + # new synthetic data + y2 = functions.sum_splitgauss(x, + 100, 400, 100, 40, + 10, 600, 50, 500, + 80, 850, 10, 50) + fit.setData(x=x, y=y2) + + # Define new theory + fit.addtheory(theory="Asymetric gaussian", + function=functions.sum_splitgauss, + parameters=("height", "peak center", "left fwhm", "right fwhm"), + estimate=fitfuns.estimate_splitgauss) + fit.settheory('Asymetric gaussian') + + # Fit + fit.estimate() + fit.runfit() + + # Show first fit result in another tab in our widget + w.fillFromFit(fit.fit_results, view='Asymetric gaussians') + a.exec_() + + """ + + def __init__(self, parent=None, name="FitParameters"): + """ + + :param parent: Parent widget + :param name: Widget title + """ + qt.QTabWidget.__init__(self, parent) + self.setWindowTitle(name) + self.setContentsMargins(0, 0, 0, 0) + + self.views = OrderedDict() + """Dictionary of views. Keys are view names, + items are :class:`Parameters` widgets""" + + self.latest_view = None + """Name of latest view""" + + # the widgets/tables themselves + self.tables = {} + """Dictionary of :class:`silx.gui.fit.parameters.Parameters` objects. + These objects store fit results + """ + + self.setContentsMargins(10, 10, 10, 10) + + def setView(self, view=None, fitresults=None): + """Add or update a table. Fill it with data from a fit + + :param view: Tab name to be added or updated. If ``None``, use the + latest view. + :param fitresults: Fit data to be added to the table + :raise: KeyError if no view name specified and no latest view + available. + """ + if view is None: + if self.latest_view is not None: + view = self.latest_view + else: + raise KeyError( + "No view available. You must specify a view" + + " name the first time you call this method." + ) + + if view in self.tables.keys(): + table = self.tables[view] + else: + # create the parameters instance + self.tables[view] = Parameters(self) + table = self.tables[view] + self.views[view] = table + self.addTab(table, str(view)) + + if fitresults is not None: + table.fillFromFit(fitresults) + + self.setCurrentWidget(self.views[view]) + self.latest_view = view + + def renameView(self, oldname=None, newname=None): + """Rename a view (tab) + + :param oldname: Name of the view to be renamed + :param newname: New name of the view""" + error = 1 + if newname is not None: + if newname not in self.views.keys(): + if oldname in self.views.keys(): + parameterlist = self.tables[oldname].getFitResults() + self.setView(view=newname, fitresults=parameterlist) + self.removeView(oldname) + error = 0 + return error + + def fillFromFit(self, fitparameterslist, view=None): + """Update a view with data from a fit (alias for :meth:`setView`) + + :param view: Tab name to be added or updated (default: latest view) + :param fitparameterslist: Fit data to be added to the table + """ + self.setView(view=view, fitresults=fitparameterslist) + + def getFitResults(self, name=None): + """Call :meth:`getFitResults` for the + :class:`silx.gui.fit.parameters.Parameters` corresponding to the + latest table or to the named table (if ``name`` is not + ``None``). This return a list of dictionaries in the format used by + :class:`silx.math.fit.fitmanager.FitManager` to store fit parameter + results. + + :param name: View name. + """ + if name is None: + name = self.latest_view + return self.tables[name].getFitResults() + + def removeView(self, name): + """Remove a view by name. + + :param name: View name. + """ + if name in self.views: + index = self.indexOf(self.tables[name]) + self.removeTab(index) + index = self.indexOf(self.views[name]) + self.removeTab(index) + del self.tables[name] + del self.views[name] + + def removeAllViews(self, keep=None): + """Remove all views, except the one specified (argument + ``keep``) + + :param keep: Name of the view to be kept.""" + for view in self.tables: + if view != keep: + self.removeView(view) + + def getHtmlText(self, name=None): + """Return the table data as HTML + + :param name: View name.""" + if name is None: + name = self.latest_view + table = self.tables[name] + lemon = ("#%x%x%x" % (255, 250, 205)).upper() + hcolor = ("#%x%x%x" % (230, 240, 249)).upper() + text = "" + text += "<nobr>" + text += "<table>" + text += "<tr>" + ncols = table.columnCount() + for l in range(ncols): + text += ('<td align="left" bgcolor="%s"><b>' % hcolor) + if QTVERSION < '4.0.0': + text += (str(table.horizontalHeader().label(l))) + else: + text += (str(table.horizontalHeaderItem(l).text())) + text += "</b></td>" + text += "</tr>" + nrows = table.rowCount() + for r in range(nrows): + text += "<tr>" + item = table.item(r, 0) + newtext = "" + if item is not None: + newtext = str(item.text()) + if len(newtext): + color = "white" + b = "<b>" + else: + b = "" + color = lemon + try: + # MyQTable item has color defined + cc = table.item(r, 0).color + cc = ("#%x%x%x" % (cc.red(), cc.green(), cc.blue())).upper() + color = cc + except: + pass + for c in range(ncols): + item = table.item(r, c) + newtext = "" + if item is not None: + newtext = str(item.text()) + if len(newtext): + finalcolor = color + else: + finalcolor = "white" + if c < 2: + text += ('<td align="left" bgcolor="%s">%s' % + (finalcolor, b)) + else: + text += ('<td align="right" bgcolor="%s">%s' % + (finalcolor, b)) + text += newtext + if len(b): + text += "</td>" + else: + text += "</b></td>" + item = table.item(r, 0) + newtext = "" + if item is not None: + newtext = str(item.text()) + if len(newtext): + text += "</b>" + text += "</tr>" + text += "\n" + text += "</table>" + text += "</nobr>" + return text + + def getText(self, name=None): + """Return the table data as CSV formatted text, using tabulation + characters as separators. + + :param name: View name.""" + if name is None: + name = self.latest_view + table = self.tables[name] + text = "" + ncols = table.columnCount() + for l in range(ncols): + text += (str(table.horizontalHeaderItem(l).text())) + "\t" + text += "\n" + nrows = table.rowCount() + for r in range(nrows): + for c in range(ncols): + newtext = "" + if c != 4: + item = table.item(r, c) + if item is not None: + newtext = str(item.text()) + else: + item = table.cellWidget(r, c) + if item is not None: + newtext = str(item.currentText()) + text += newtext + "\t" + text += "\n" + text += "\n" + return text + + +def test(): + from silx.math.fit import fittheories + from silx.math.fit import fitmanager + from silx.math.fit import functions + from silx.gui.plot.PlotWindow import PlotWindow + import numpy + + a = qt.QApplication([]) + + x = numpy.arange(1000) + y1 = functions.sum_gauss(x, 100, 400, 100) + + fit = fitmanager.FitManager(x=x, y=y1) + + fitfuns = fittheories.FitTheories() + fit.addtheory(name="Gaussian", + function=functions.sum_gauss, + parameters=("height", "peak center", "fwhm"), + estimate=fitfuns.estimate_height_position_fwhm) + fit.settheory('Gaussian') + fit.configure(PositiveFwhmFlag=True, + PositiveHeightAreaFlag=True, + AutoFwhm=True,) + + # Fit + fit.estimate() + fit.runfit() + + w = ParametersTab() + w.show() + w.fillFromFit(fit.fit_results, view='Gaussians') + + y2 = functions.sum_splitgauss(x, + 100, 400, 100, 40, + 10, 600, 50, 500, + 80, 850, 10, 50) + fit.setdata(x=x, y=y2) + + # Define new theory + fit.addtheory(name="Asymetric gaussian", + function=functions.sum_splitgauss, + parameters=("height", "peak center", "left fwhm", "right fwhm"), + estimate=fitfuns.estimate_splitgauss) + fit.settheory('Asymetric gaussian') + + # Fit + fit.estimate() + fit.runfit() + + w.fillFromFit(fit.fit_results, view='Asymetric gaussians') + + # Plot + pw = PlotWindow(control=True) + pw.addCurve(x, y1, "Gaussians") + pw.addCurve(x, y2, "Asymetric gaussians") + pw.show() + + a.exec_() + + +if __name__ == "__main__": + test() diff --git a/silx/gui/fit/Parameters.py b/silx/gui/fit/Parameters.py new file mode 100644 index 0000000..62e3278 --- /dev/null +++ b/silx/gui/fit/Parameters.py @@ -0,0 +1,882 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2004-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ######################################################################### */ +"""This module defines a table widget that is specialized in displaying fit +parameter results and associated constraints.""" +__authors__ = ["V.A. Sole", "P. Knobel"] +__license__ = "MIT" +__date__ = "25/11/2016" + +import sys +from collections import OrderedDict + +from silx.gui import qt +from silx.gui.widgets.TableWidget import TableWidget + + +def float_else_zero(sstring): + """Return converted string to float. If conversion fail, return zero. + + :param sstring: String to be converted + :return: ``float(sstrinq)`` if ``sstring`` can be converted to float + (e.g. ``"3.14"``), else ``0`` + """ + try: + return float(sstring) + except ValueError: + return 0 + + +class QComboTableItem(qt.QComboBox): + """:class:`qt.QComboBox` augmented with a ``sigCellChanged`` signal + to emit a tuple of ``(row, column)`` coordinates when the value is + changed. + + This signal can be used to locate the modified combo box in a table. + + :param row: Row number of the table cell containing this widget + :param col: Column number of the table cell containing this widget""" + sigCellChanged = qt.Signal(int, int) + """Signal emitted when this ``QComboBox`` is activated. + A ``(row, column)`` tuple is passed.""" + + def __init__(self, parent=None, row=None, col=None): + self._row = row + self._col = col + qt.QComboBox.__init__(self, parent) + self.activated[int].connect(self._cellChanged) + + def _cellChanged(self, idx): # noqa + self.sigCellChanged.emit(self._row, self._col) + + +class QCheckBoxItem(qt.QCheckBox): + """:class:`qt.QCheckBox` augmented with a ``sigCellChanged`` signal + to emit a tuple of ``(row, column)`` coordinates when the check box has + been clicked on. + + This signal can be used to locate the modified check box in a table. + + :param row: Row number of the table cell containing this widget + :param col: Column number of the table cell containing this widget""" + sigCellChanged = qt.Signal(int, int) + """Signal emitted when this ``QCheckBox`` is clicked. + A ``(row, column)`` tuple is passed.""" + + def __init__(self, parent=None, row=None, col=None): + self._row = row + self._col = col + qt.QCheckBox.__init__(self, parent) + self.clicked.connect(self._cellChanged) + + def _cellChanged(self): + self.sigCellChanged.emit(self._row, self._col) + + +class Parameters(TableWidget): + """:class:`TableWidget` customized to display fit results + and to interact with :class:`FitManager` objects. + + Data and references to cell widgets are kept in a dictionary + attribute :attr:`parameters`. + + :param parent: Parent widget + :param labels: Column headers. If ``None``, default headers will be used. + :type labels: List of strings or None + :param paramlist: List of fit parameters to be displayed for each fitted + peak. + :type paramlist: list[str] or None + """ + def __init__(self, parent=None, paramlist=None): + TableWidget.__init__(self, parent) + self.setContentsMargins(0, 0, 0, 0) + + labels = ['Parameter', 'Estimation', 'Fit Value', 'Sigma', + 'Constraints', 'Min/Parame', 'Max/Factor/Delta'] + tooltips = ["Fit parameter name", + "Estimated value for fit parameter. You can edit this column.", + "Actual value for parameter, after fit", + "Uncertainty (same unit as the parameter)", + "Constraint to be applied to the parameter for fit", + "First parameter for constraint (name of another param or min value)", + "Second parameter for constraint (max value, or factor/delta)"] + + self.columnKeys = ['name', 'estimation', 'fitresult', + 'sigma', 'code', 'val1', 'val2'] + """This list assigns shorter keys to refer to columns than the + displayed labels.""" + + self.__configuring = False + + # column headers and associated tooltips + self.setColumnCount(len(labels)) + + for i, label in enumerate(labels): + item = self.horizontalHeaderItem(i) + if item is None: + item = qt.QTableWidgetItem(label, + qt.QTableWidgetItem.Type) + self.setHorizontalHeaderItem(i, item) + + item.setText(label) + if tooltips is not None: + item.setToolTip(tooltips[i]) + + # resize columns + for col_key in ["name", "estimation", "sigma", "val1", "val2"]: + col_idx = self.columnIndexByField(col_key) + self.resizeColumnToContents(col_idx) + + # Initialize the table with one line per supplied parameter + paramlist = paramlist if paramlist is not None else [] + self.parameters = OrderedDict() + """This attribute stores all the data in an ordered dictionary. + New data can be added using :meth:`newParameterLine`. + Existing data can be modified using :meth:`configureLine` + + Keys of the dictionary are: + + - 'name': parameter name + - 'line': line index for the parameter in the table + - 'estimation' + - 'fitresult' + - 'sigma' + - 'code': constraint code (one of the elements of + :attr:`code_options`) + - 'val1': first parameter related to constraint, formatted + as a string, as typed in the table + - 'val2': second parameter related to constraint, formatted + as a string, as typed in the table + - 'cons1': scalar representation of 'val1' + (e.g. when val1 is the name of a fit parameter, cons1 + will be the line index of this parameter) + - 'cons2': scalar representation of 'val2' + - 'vmin': equal to 'val1' when 'code' is "QUOTED" + - 'vmax': equal to 'val2' when 'code' is "QUOTED" + - 'relatedto': name of related parameter when this parameter + is constrained to another parameter (same as 'val1') + - 'factor': same as 'val2' when 'code' is 'FACTOR' + - 'delta': same as 'val2' when 'code' is 'DELTA' + - 'sum': same as 'val2' when 'code' is 'SUM' + - 'group': group index for the parameter + - 'xmin': data range minimum + - 'xmax': data range maximum + """ + for line, param in enumerate(paramlist): + self.newParameterLine(param, line) + + self.code_options = ["FREE", "POSITIVE", "QUOTED", "FIXED", + "FACTOR", "DELTA", "SUM", "IGNORE", "ADD"] + """Possible values in the combo boxes in the 'Constraints' column. + """ + + # connect signal + self.cellChanged[int, int].connect(self.onCellChanged) + + def newParameterLine(self, param, line): + """Add a line to the :class:`QTableWidget`. + + Each line represents one of the fit parameters for one of + the fitted peaks. + + :param param: Name of the fit parameter + :type param: str + :param line: 0-based line index + :type line: int + """ + # get current number of lines + nlines = self.rowCount() + self.__configuring = True + if line >= nlines: + self.setRowCount(line + 1) + + # default configuration for fit parameters + self.parameters[param] = OrderedDict((('line', line), + ('estimation', '0'), + ('fitresult', ''), + ('sigma', ''), + ('code', 'FREE'), + ('val1', ''), + ('val2', ''), + ('cons1', 0), + ('cons2', 0), + ('vmin', '0'), + ('vmax', '1'), + ('relatedto', ''), + ('factor', '1.0'), + ('delta', '0.0'), + ('sum', '0.0'), + ('group', ''), + ('name', param), + ('xmin', None), + ('xmax', None))) + self.setReadWrite(param, 'estimation') + self.setReadOnly(param, ['name', 'fitresult', 'sigma', 'val1', 'val2']) + + # Constraint codes + a = [] + for option in self.code_options: + a.append(option) + + code_column_index = self.columnIndexByField('code') + cellWidget = self.cellWidget(line, code_column_index) + if cellWidget is None: + cellWidget = QComboTableItem(self, row=line, + col=code_column_index) + cellWidget.addItems(a) + self.setCellWidget(line, code_column_index, cellWidget) + cellWidget.sigCellChanged[int, int].connect(self.onCellChanged) + self.parameters[param]['code_item'] = cellWidget + self.parameters[param]['relatedto_item'] = None + self.__configuring = False + + def columnIndexByField(self, field): + """ + + :param field: Field name (column key) + :return: Index of the column with this field name + """ + return self.columnKeys.index(field) + + def fillFromFit(self, fitresults): + """Fill table with values from a list of dictionaries + (see :attr:`silx.math.fit.fitmanager.FitManager.fit_results`) + + :param fitresults: List of parameters as recorded + in the ``paramlist`` attribute of a :class:`FitManager` object + :type fitresults: list[dict] + """ + self.setRowCount(len(fitresults)) + + # Reinitialize and fill self.parameters + self.parameters = OrderedDict() + for (line, param) in enumerate(fitresults): + self.newParameterLine(param['name'], line) + + for param in fitresults: + name = param['name'] + code = str(param['code']) + if code not in self.code_options: + # convert code from int to descriptive string + code = self.code_options[int(code)] + val1 = param['cons1'] + val2 = param['cons2'] + estimation = param['estimation'] + group = param['group'] + sigma = param['sigma'] + fitresult = param['fitresult'] + + xmin = param.get('xmin') + xmax = param.get('xmax') + + self.configureLine(name=name, + code=code, + val1=val1, val2=val2, + estimation=estimation, + fitresult=fitresult, + sigma=sigma, + group=group, + xmin=xmin, xmax=xmax) + + def getConfiguration(self): + """Return ``FitManager.paramlist`` dictionary + encapsulated in another dictionary""" + return {'parameters': self.getFitResults()} + + def setConfiguration(self, ddict): + """Fill table with values from a ``FitManager.paramlist`` dictionary + encapsulated in another dictionary""" + self.fillFromFit(ddict['parameters']) + + def getFitResults(self): + """Return fit parameters as a list of dictionaries in the format used + by :class:`FitManager` (attribute ``paramlist``). + """ + fitparameterslist = [] + for param in self.parameters: + fitparam = {} + name = param + estimation, [code, cons1, cons2] = self.getEstimationConstraints(name) + buf = str(self.parameters[param]['fitresult']) + xmin = self.parameters[param]['xmin'] + xmax = self.parameters[param]['xmax'] + if len(buf): + fitresult = float(buf) + else: + fitresult = 0.0 + buf = str(self.parameters[param]['sigma']) + if len(buf): + sigma = float(buf) + else: + sigma = 0.0 + buf = str(self.parameters[param]['group']) + if len(buf): + group = float(buf) + else: + group = 0 + fitparam['name'] = name + fitparam['estimation'] = estimation + fitparam['fitresult'] = fitresult + fitparam['sigma'] = sigma + fitparam['group'] = group + fitparam['code'] = code + fitparam['cons1'] = cons1 + fitparam['cons2'] = cons2 + fitparam['xmin'] = xmin + fitparam['xmax'] = xmax + fitparameterslist.append(fitparam) + return fitparameterslist + + def onCellChanged(self, row, col): + """Slot called when ``cellChanged`` signal is emitted. + Checks the validity of the new text in the cell, then calls + :meth:`configureLine` to update the internal ``self.parameters`` + dictionary. + + :param row: Row number of the changed cell (0-based index) + :param col: Column number of the changed cell (0-based index) + """ + if (col != self.columnIndexByField("code")) and (col != -1): + if row != self.currentRow(): + return + if col != self.currentColumn(): + return + if self.__configuring: + return + param = list(self.parameters)[row] + field = self.columnKeys[col] + oldvalue = self.parameters[param][field] + if col != 4: + item = self.item(row, col) + if item is not None: + newvalue = item.text() + else: + newvalue = '' + else: + # this is the combobox + widget = self.cellWidget(row, col) + newvalue = widget.currentText() + if self.validate(param, field, oldvalue, newvalue): + paramdict = {"name": param, field: newvalue} + self.configureLine(**paramdict) + else: + if field == 'code': + # New code not valid, try restoring the old one + index = self.code_options.index(oldvalue) + self.__configuring = True + try: + self.parameters[param]['code_item'].setCurrentIndex(index) + finally: + self.__configuring = False + else: + paramdict = {"name": param, field: oldvalue} + self.configureLine(**paramdict) + + def validate(self, param, field, oldvalue, newvalue): + """Check validity of ``newvalue`` when a cell's value is modified. + + :param param: Fit parameter name + :param field: Column name + :param oldvalue: Cell value before change attempt + :param newvalue: New value to be validated + :return: True if new cell value is valid, else False + """ + if field == 'code': + return self.setCodeValue(param, oldvalue, newvalue) + # FIXME: validate() shouldn't have side effects. Move this bit to configureLine()? + if field == 'val1' and str(self.parameters[param]['code']) in ['DELTA', 'FACTOR', 'SUM']: + _, candidates = self.getRelatedCandidates(param) + # We expect val1 to be a fit parameter name + if str(newvalue) in candidates: + return True + else: + return False + # except for code, val1 and name (which is read-only and does not need + # validation), all fields must always be convertible to float + else: + try: + float(str(newvalue)) + except ValueError: + return False + return True + + def setCodeValue(self, param, oldvalue, newvalue): + """Update 'code' and 'relatedto' fields when code cell is + changed. + + :param param: Fit parameter name + :param oldvalue: Cell value before change attempt + :param newvalue: New value to be validated + :return: ``True`` if code was successfully updated + """ + + if str(newvalue) in ['FREE', 'POSITIVE', 'QUOTED', 'FIXED']: + self.configureLine(name=param, + code=newvalue) + if str(oldvalue) == 'IGNORE': + self.freeRestOfGroup(param) + return True + elif str(newvalue) in ['FACTOR', 'DELTA', 'SUM']: + # I should check here that some parameter is set + best, candidates = self.getRelatedCandidates(param) + if len(candidates) == 0: + return False + self.configureLine(name=param, + code=newvalue, + relatedto=best) + if str(oldvalue) == 'IGNORE': + self.freeRestOfGroup(param) + return True + + elif str(newvalue) == 'IGNORE': + # I should check if the group can be ignored + # for the time being I just fix all of them to ignore + group = int(float(str(self.parameters[param]['group']))) + candidates = [] + for param in self.parameters.keys(): + if group == int(float(str(self.parameters[param]['group']))): + candidates.append(param) + # print candidates + # I should check here if there is any relation to them + for param in candidates: + self.configureLine(name=param, + code=newvalue) + return True + elif str(newvalue) == 'ADD': + group = int(float(str(self.parameters[param]['group']))) + if group == 0: + # One cannot add a background group + return False + i = 0 + for param in self.parameters: + if i <= int(float(str(self.parameters[param]['group']))): + i += 1 + if (group == 0) and (i == 1): # FIXME: why +1? + i += 1 + self.addGroup(i, group) + return False + elif str(newvalue) == 'SHOW': + print(self.getEstimationConstraints(param)) + return False + + def addGroup(self, newg, gtype): + """Add a fit parameter group with the same fit parameters as an + existing group. + + This function is called when the user selects "ADD" in the + "constraints" combobox. + + :param int newg: New group number + :param int gtype: Group number whose parameters we want to copy + + """ + newparam = [] + # loop through parameters until we encounter group number `gtype` + for param in list(self.parameters): + paramgroup = int(float(str(self.parameters[param]['group']))) + # copy parameter names in group number `gtype` + if paramgroup == gtype: + # but replace `gtype` with `newg` + newparam.append(param.rstrip("0123456789") + "%d" % newg) + + xmin = self.parameters[param]['xmin'] + xmax = self.parameters[param]['xmax'] + + # Add new parameters (one table line per parameter) and configureLine each + # one by updating xmin and xmax to the same values as group `gtype` + line = len(list(self.parameters)) + for param in newparam: + self.newParameterLine(param, line) + line += 1 + for param in newparam: + self.configureLine(name=param, group=newg, xmin=xmin, xmax=xmax) + + def freeRestOfGroup(self, workparam): + """Set ``code`` to ``"FREE"`` for all fit parameters belonging to + the same group as ``workparam``. This is done when the entire group + of parameters was previously ignored and one of them has his code + set to something different than ``"IGNORE"``. + + :param workparam: Fit parameter name + """ + if workparam in self.parameters.keys(): + group = int(float(str(self.parameters[workparam]['group']))) + for param in self.parameters: + if param != workparam and\ + group == int(float(str(self.parameters[param]['group']))): + self.configureLine(name=param, + code='FREE', + cons1=0, + cons2=0, + val1='', + val2='') + + def getRelatedCandidates(self, workparam): + """If fit parameter ``workparam`` has a constraint that involves other + fit parameters, find possible candidates and try to guess which one + is the most likely. + + :param workparam: Fit parameter name + :return: (best_candidate, possible_candidates) tuple + :rtype: (str, list[str]) + """ + candidates = [] + for param_name in self.parameters: + if param_name != workparam: + # ignore parameters that are fixed by a constraint + if str(self.parameters[param_name]['code']) not in\ + ['IGNORE', 'FACTOR', 'DELTA', 'SUM']: + candidates.append(param_name) + # take the previous one (before code cell changed) if possible + if str(self.parameters[workparam]['relatedto']) in candidates: + best = str(self.parameters[workparam]['relatedto']) + return best, candidates + # take the first with same base name (after removing numbers) + for param_name in candidates: + basename = param_name.rstrip("0123456789") + try: + pos = workparam.index(basename) + if pos == 0: + best = param_name + return best, candidates + except ValueError: + pass + # take the first + return candidates[0], candidates + + def setReadOnly(self, parameter, fields): + """Make table cells read-only by setting it's flags and omitting + flag ``qt.Qt.ItemIsEditable`` + + :param parameter: Fit parameter names identifying the rows + :type parameter: str or list[str] + :param fields: Field names identifying the columns + :type fields: str or list[str] + """ + editflags = qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled + self.setField(parameter, fields, editflags) + + def setReadWrite(self, parameter, fields): + """Make table cells read-write by setting it's flags including + flag ``qt.Qt.ItemIsEditable`` + + :param parameter: Fit parameter names identifying the rows + :type parameter: str or list[str] + :param fields: Field names identifying the columns + :type fields: str or list[str] + """ + editflags = qt.Qt.ItemIsSelectable |\ + qt.Qt.ItemIsEnabled |\ + qt.Qt.ItemIsEditable + self.setField(parameter, fields, editflags) + + def setField(self, parameter, fields, edit_flags): + """Set text and flags in a table cell. + + :param parameter: Fit parameter names identifying the rows + :type parameter: str or list[str] + :param fields: Field names identifying the columns + :type fields: str or list[str] + :param edit_flags: Flag combination, e.g:: + + qt.Qt.ItemIsSelectable | qt.Qt.ItemIsEnabled | + qt.Qt.ItemIsEditable + """ + if isinstance(parameter, list) or \ + isinstance(parameter, tuple): + paramlist = parameter + else: + paramlist = [parameter] + if isinstance(fields, list) or \ + isinstance(fields, tuple): + fieldlist = fields + else: + fieldlist = [fields] + + # Set _configuring flag to ignore cellChanged signals in + # self.onCellChanged + _oldvalue = self.__configuring + self.__configuring = True + + # 2D loop through parameter list and field list + # to update their cells + for param in paramlist: + row = list(self.parameters.keys()).index(param) + for field in fieldlist: + col = self.columnIndexByField(field) + if field != 'code': + key = field + "_item" + item = self.item(row, col) + if item is None: + item = qt.QTableWidgetItem() + item.setText(self.parameters[param][field]) + self.setItem(row, col, item) + else: + item.setText(self.parameters[param][field]) + self.parameters[param][key] = item + item.setFlags(edit_flags) + + # Restore previous _configuring flag + self.__configuring = _oldvalue + + def configureLine(self, name, code=None, val1=None, val2=None, + sigma=None, estimation=None, fitresult=None, + group=None, xmin=None, xmax=None, relatedto=None, + cons1=None, cons2=None): + """This function updates values in a line of the table + + :param name: Name of the parameter (serves as unique identifier for + a line). + :param code: Constraint code *FREE, FIXED, POSITIVE, DELTA, FACTOR, + SUM, QUOTED, IGNORE* + :param val1: Constraint 1 (can be the index or name of another + parameter for code *DELTA, FACTOR, SUM*, or a min value + for code *QUOTED*) + :param val2: Constraint 2 + :param sigma: Standard deviation for a fit parameter + :param estimation: Estimated initial value for a fit parameter (used + as input to iterative fit) + :param fitresult: Final result of fit + :param group: Group number of a fit parameter (peak number when doing + multi-peak fitting, as each peak corresponds to a group + of several consecutive parameters) + :param xmin: + :param xmax: + :param relatedto: Index or name of another fit parameter + to which this parameter is related to (constraints) + :param cons1: similar meaning to ``val1``, but is always a number + :param cons2: similar meaning to ``val2``, but is always a number + :return: + """ + paramlist = list(self.parameters.keys()) + + if name not in self.parameters: + raise KeyError("'%s' is not in the parameter list" % name) + + # update code first, if specified + if code is not None: + code = str(code) + self.parameters[name]['code'] = code + # update combobox + index = self.parameters[name]['code_item'].findText(code) + self.parameters[name]['code_item'].setCurrentIndex(index) + else: + # set code to previous value, used later for setting val1 val2 + code = self.parameters[name]['code'] + + # val1 and sigma have special formats + if val1 is not None: + fmt = None if self.parameters[name]['code'] in\ + ['DELTA', 'FACTOR', 'SUM'] else "%8g" + self._updateField(name, "val1", val1, fmat=fmt) + + if sigma is not None: + self._updateField(name, "sigma", sigma, fmat="%6.3g") + + # other fields are formatted as "%8g" + keys_params = (("val2", val2), ("estimation", estimation), + ("fitresult", fitresult)) + for key, value in keys_params: + if value is not None: + self._updateField(name, key, value, fmat="%8g") + + # the rest of the parameters are treated as strings and don't need + # validation + keys_params = (("group", group), ("xmin", xmin), + ("xmax", xmax), ("relatedto", relatedto), + ("cons1", cons1), ("cons2", cons2)) + for key, value in keys_params: + if value is not None: + self.parameters[name][key] = str(value) + + # val1 and val2 have different meanings depending on the code + if code == 'QUOTED': + if val1 is not None: + self.parameters[name]['vmin'] = self.parameters[name]['val1'] + else: + self.parameters[name]['val1'] = self.parameters[name]['vmin'] + if val2 is not None: + self.parameters[name]['vmax'] = self.parameters[name]['val2'] + else: + self.parameters[name]['val2'] = self.parameters[name]['vmax'] + + # cons1 and cons2 are scalar representations of val1 and val2 + self.parameters[name]['cons1'] =\ + float_else_zero(self.parameters[name]['val1']) + self.parameters[name]['cons2'] =\ + float_else_zero(self.parameters[name]['val2']) + + # cons1, cons2 = min(val1, val2), max(val1, val2) + if self.parameters[name]['cons1'] > self.parameters[name]['cons2']: + self.parameters[name]['cons1'], self.parameters[name]['cons2'] =\ + self.parameters[name]['cons2'], self.parameters[name]['cons1'] + + elif code in ['DELTA', 'SUM', 'FACTOR']: + # For these codes, val1 is the fit parameter name on which the + # constraint depends + if val1 is not None and val1 in paramlist: + self.parameters[name]['relatedto'] = self.parameters[name]["val1"] + + elif val1 is not None: + # val1 could be the index of the fit parameter + try: + self.parameters[name]['relatedto'] = paramlist[int(val1)] + except ValueError: + self.parameters[name]['relatedto'] = self.parameters[name]["val1"] + + elif relatedto is not None: + # code changed, val1 not specified but relatedto specified: + # set val1 to relatedto (pre-fill best guess) + self.parameters[name]["val1"] = relatedto + + # update fields "delta", "sum" or "factor" + key = code.lower() + self.parameters[name][key] = self.parameters[name]["val2"] + + # FIXME: val1 is sometimes specified as an index rather than a param name + self.parameters[name]['val1'] = self.parameters[name]['relatedto'] + + # cons1 is the index of the fit parameter in the ordered dictionary + if self.parameters[name]['val1'] in paramlist: + self.parameters[name]['cons1'] =\ + paramlist.index(self.parameters[name]['val1']) + + # cons2 is the constraint value (factor, delta or sum) + try: + self.parameters[name]['cons2'] =\ + float(str(self.parameters[name]['val2'])) + except ValueError: + self.parameters[name]['cons2'] = 1.0 if code == "FACTOR" else 0.0 + + elif code in ['FREE', 'POSITIVE', 'IGNORE', 'FIXED']: + self.parameters[name]['val1'] = "" + self.parameters[name]['val2'] = "" + self.parameters[name]['cons1'] = 0 + self.parameters[name]['cons2'] = 0 + + self._updateCellRWFlags(name, code) + + def _updateField(self, name, field, value, fmat=None): + """Update field in ``self.parameters`` dictionary, if the new value + is valid. + + :param name: Fit parameter name + :param field: Field name + :param value: New value to assign + :type value: String + :param fmat: Format string (e.g. "%8g") to be applied if value represents + a scalar. If ``None``, format is not modified. If ``value`` is an + empty string, ``fmat`` is ignored. + """ + if value is not None: + oldvalue = self.parameters[name][field] + if fmat is not None: + newvalue = fmat % float(value) if value != "" else "" + else: + newvalue = value + self.parameters[name][field] = newvalue if\ + self.validate(name, field, oldvalue, newvalue) else\ + oldvalue + + def _updateCellRWFlags(self, name, code=None): + """Set read-only or read-write flags in a row, + depending on the constraint code + + :param name: Fit parameter name identifying the row + :param code: Constraint code, in `'FREE', 'POSITIVE', 'IGNORE',` + `'FIXED', 'FACTOR', 'DELTA', 'SUM', 'ADD'` + :return: + """ + if code in ['FREE', 'POSITIVE', 'IGNORE', 'FIXED']: + self.setReadWrite(name, 'estimation') + self.setReadOnly(name, ['fitresult', 'sigma', 'val1', 'val2']) + else: + self.setReadWrite(name, ['estimation', 'val1', 'val2']) + self.setReadOnly(name, ['fitresult', 'sigma']) + + def getEstimationConstraints(self, param): + """ + Return tuple ``(estimation, constraints)`` where ``estimation`` is the + value in the ``estimate`` field and ``constraints`` are the relevant + constraints according to the active code + """ + estimation = None + constraints = None + if param in self.parameters.keys(): + buf = str(self.parameters[param]['estimation']) + if len(buf): + estimation = float(buf) + else: + estimation = 0 + if str(self.parameters[param]['code']) in self.code_options: + code = self.code_options.index( + str(self.parameters[param]['code'])) + else: + code = str(self.parameters[param]['code']) + cons1 = self.parameters[param]['cons1'] + cons2 = self.parameters[param]['cons2'] + constraints = [code, cons1, cons2] + return estimation, constraints + + +def main(args): + from silx.math.fit import fittheories + from silx.math.fit import fitmanager + try: + from PyMca5 import PyMcaDataDir + except ImportError: + raise ImportError("This demo requires PyMca data. Install PyMca5.") + import numpy + import os + app = qt.QApplication(args) + tab = Parameters(paramlist=['Height', 'Position', 'FWHM']) + tab.showGrid() + tab.configureLine(name='Height', estimation='1234', group=0) + tab.configureLine(name='Position', code='FIXED', group=1) + tab.configureLine(name='FWHM', group=1) + + y = numpy.loadtxt(os.path.join(PyMcaDataDir.PYMCA_DATA_DIR, + "XRFSpectrum.mca")) # FIXME + + x = numpy.arange(len(y)) * 0.0502883 - 0.492773 + fit = fitmanager.FitManager() + fit.setdata(x=x, y=y, xmin=20, xmax=150) + + fit.loadtheories(fittheories) + + fit.settheory('ahypermet') + fit.configure(Yscaling=1., + PositiveFwhmFlag=True, + PositiveHeightAreaFlag=True, + FwhmPoints=16, + QuotedPositionFlag=1, + HypermetTails=1) + fit.setbackground('Linear') + fit.estimate() + fit.runfit() + tab.fillFromFit(fit.fit_results) + tab.show() + app.exec_() + +if __name__ == "__main__": + main(sys.argv) diff --git a/silx/gui/fit/__init__.py b/silx/gui/fit/__init__.py new file mode 100644 index 0000000..e4fd3ab --- /dev/null +++ b/silx/gui/fit/__init__.py @@ -0,0 +1,28 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "07/07/2016" + +from .FitWidget import FitWidget diff --git a/silx/gui/fit/setup.py b/silx/gui/fit/setup.py new file mode 100644 index 0000000..6672363 --- /dev/null +++ b/silx/gui/fit/setup.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "21/07/2016" + + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('fit', parent_package, top_path) + config.add_subpackage('test') + + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + + setup(configuration=configuration) diff --git a/silx/gui/fit/test/__init__.py b/silx/gui/fit/test/__init__.py new file mode 100644 index 0000000..2236d64 --- /dev/null +++ b/silx/gui/fit/test/__init__.py @@ -0,0 +1,43 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +import unittest + +from .testFitWidget import suite as testFitWidgetSuite +from .testFitConfig import suite as testFitConfigSuite +from .testBackgroundWidget import suite as testBackgroundWidgetSuite + + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "21/07/2016" + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTests( + [testFitWidgetSuite(), + testFitConfigSuite(), + testBackgroundWidgetSuite()]) + return test_suite diff --git a/silx/gui/fit/test/testBackgroundWidget.py b/silx/gui/fit/test/testBackgroundWidget.py new file mode 100644 index 0000000..2e366e4 --- /dev/null +++ b/silx/gui/fit/test/testBackgroundWidget.py @@ -0,0 +1,83 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +import unittest + +from ...test.utils import TestCaseQt + +from .. import BackgroundWidget + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + + +class TestBackgroundWidget(TestCaseQt): + def setUp(self): + super(TestBackgroundWidget, self).setUp() + self.bgdialog = BackgroundWidget.BackgroundDialog() + self.bgdialog.setData(list([0, 1, 2, 3]), + list([0, 1, 4, 8])) + self.qWaitForWindowExposed(self.bgdialog) + + def tearDown(self): + del self.bgdialog + super(TestBackgroundWidget, self).tearDown() + + def testShow(self): + self.bgdialog.show() + self.bgdialog.hide() + + def testAccept(self): + self.bgdialog.accept() + self.assertTrue(self.bgdialog.result()) + + def testReject(self): + self.bgdialog.reject() + self.assertFalse(self.bgdialog.result()) + + def testDefaultOutput(self): + self.bgdialog.accept() + output = self.bgdialog.output + + for key in ["algorithm", "StripThreshold", "SnipWidth", + "StripIterations", "StripWidth", "SmoothingFlag", + "SmoothingWidth", "AnchorsFlag", "AnchorsList"]: + self.assertIn(key, output) + + self.assertFalse(output["AnchorsFlag"]) + self.assertEqual(output["StripWidth"], 1) + self.assertEqual(output["SmoothingFlag"], False) + self.assertEqual(output["SmoothingWidth"], 3) + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestBackgroundWidget)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/fit/test/testFitConfig.py b/silx/gui/fit/test/testFitConfig.py new file mode 100644 index 0000000..eea35cc --- /dev/null +++ b/silx/gui/fit/test/testFitConfig.py @@ -0,0 +1,95 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Basic tests for :class:`FitConfig`""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + +import unittest + +from ...test.utils import TestCaseQt +from .. import FitConfig + + +class TestFitConfig(TestCaseQt): + """Basic test for FitWidget""" + + def setUp(self): + super(TestFitConfig, self).setUp() + self.fit_config = FitConfig.getFitConfigDialog(modal=False) + self.qWaitForWindowExposed(self.fit_config) + + def tearDown(self): + del self.fit_config + super(TestFitConfig, self).tearDown() + + def testShow(self): + self.fit_config.show() + self.fit_config.hide() + + def testAccept(self): + self.fit_config.accept() + self.assertTrue(self.fit_config.result()) + + def testReject(self): + self.fit_config.reject() + self.assertFalse(self.fit_config.result()) + + def testDefaultOutput(self): + self.fit_config.accept() + output = self.fit_config.output + + for key in ["AutoFwhm", + "PositiveHeightAreaFlag", + "QuotedPositionFlag", + "PositiveFwhmFlag", + "SameFwhmFlag", + "QuotedEtaFlag", + "NoConstraintsFlag", + "FwhmPoints", + "Sensitivity", + "Yscaling", + "ForcePeakPresence", + "StripBackgroundFlag", + "StripWidth", + "StripIterations", + "StripThreshold", + "SmoothingFlag"]: + self.assertIn(key, output) + + self.assertTrue(output["AutoFwhm"]) + self.assertEqual(output["StripWidth"], 2) + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestFitConfig)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/silx/gui/fit/test/testFitWidget.py b/silx/gui/fit/test/testFitWidget.py new file mode 100644 index 0000000..d542fd0 --- /dev/null +++ b/silx/gui/fit/test/testFitWidget.py @@ -0,0 +1,135 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Basic tests for :class:`FitWidget`""" + +import unittest + +from ...test.utils import TestCaseQt + +from ... import qt +from .. import FitWidget + +from ....math.fit.fittheory import FitTheory +from ....math.fit.fitmanager import FitManager + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/12/2016" + + +class TestFitWidget(TestCaseQt): + """Basic test for FitWidget""" + + def setUp(self): + super(TestFitWidget, self).setUp() + self.fit_widget = FitWidget() + self.fit_widget.show() + self.qWaitForWindowExposed(self.fit_widget) + + def tearDown(self): + self.fit_widget.setAttribute(qt.Qt.WA_DeleteOnClose) + self.fit_widget.close() + del self.fit_widget + super(TestFitWidget, self).tearDown() + + def testShow(self): + pass + + def testInteract(self): + self.mouseClick(self.fit_widget, qt.Qt.LeftButton) + self.keyClick(self.fit_widget, qt.Qt.Key_Enter) + self.qapp.processEvents() + + def testCustomConfigWidget(self): + class CustomConfigWidget(qt.QDialog): + def __init__(self): + qt.QDialog.__init__(self) + self.setModal(True) + self.ok = qt.QPushButton("ok", self) + self.ok.clicked.connect(self.accept) + cancel = qt.QPushButton("cancel", self) + cancel.clicked.connect(self.reject) + layout = qt.QVBoxLayout(self) + layout.addWidget(self.ok) + layout.addWidget(cancel) + self.output = {"hello": "world"} + + def fitfun(x, a, b): + return a * x + b + + x = list(range(0, 100)) + y = [fitfun(x_, 2, 3) for x_ in x] + + def conf(**kw): + return {"spam": "eggs", + "hello": "world!"} + + theory = FitTheory( + function=fitfun, + parameters=["a", "b"], + configure=conf) + + fitmngr = FitManager() + fitmngr.setdata(x, y) + fitmngr.addtheory("foo", theory) + fitmngr.addtheory("bar", theory) + fitmngr.addbgtheory("spam", theory) + + fw = FitWidget(fitmngr=fitmngr) + fw.associateConfigDialog("spam", CustomConfigWidget(), + theory_is_background=True) + fw.associateConfigDialog("foo", CustomConfigWidget()) + fw.show() + self.qWaitForWindowExposed(fw) + + fw.bgconfigdialogs["spam"].accept() + self.assertTrue(fw.bgconfigdialogs["spam"].result()) + + self.assertEqual(fw.bgconfigdialogs["spam"].output, + {"hello": "world"}) + + fw.bgconfigdialogs["spam"].reject() + self.assertFalse(fw.bgconfigdialogs["spam"].result()) + + fw.configdialogs["foo"].accept() + self.assertTrue(fw.configdialogs["foo"].result()) + + # todo: figure out how to click fw.configdialog.ok to close dialog + # open dialog + # self.mouseClick(fw.guiConfig.FunConfigureButton, qt.Qt.LeftButton) + # clove dialog + # self.mouseClick(fw.configdialogs["foo"].ok, qt.Qt.LeftButton) + # self.qapp.processEvents() + + +def suite(): + test_suite = unittest.TestSuite() + test_suite.addTest( + unittest.defaultTestLoader.loadTestsFromTestCase(TestFitWidget)) + return test_suite + + +if __name__ == '__main__': + unittest.main(defaultTest='suite') |