source: sasview/src/sas/qtgui/Perspectives/Fitting/FittingOptions.py @ f86343bd

ESS_GUI_bumps_abstraction
Last change on this file since f86343bd was f86343bd, checked in by ibressler, 5 years ago

FittingOptions?: minor consistency fix

  • Property mode set to 100644
File size: 15.8 KB
RevLine 
[2d0e0c1]1# global
2import sys
3import os
[b0c5e8c]4import types
[e90988c]5import webbrowser
[b0c5e8c]6
[4992ff2]7from PyQt5 import QtCore
8from PyQt5 import QtGui
9from PyQt5 import QtWidgets
[2d0e0c1]10
11from sas.qtgui.UI import images_rc
12from sas.qtgui.UI import main_resources_rc
[b0c5e8c]13import sas.qtgui.Utilities.GuiUtils as GuiUtils
14
[2d0e0c1]15from bumps import fitters
16import bumps.options
17
18from sas.qtgui.Perspectives.Fitting.UI.FittingOptionsUI import Ui_FittingOptions
19
20# Set the default optimizer
21
[aa47ea5]22class FittingMethodParameter:
[04a269f]23    """
24    Descriptive meta data of a single parameter of an optimizer.
25    """
[aa47ea5]26    _shortName = None
27    _longName = None
28    _type = None
[ccfe03b]29    _description = None # an optional description for the user, will be shown as UI tooltip
[aa47ea5]30    _defaultValue = None
31    value = None
32
[ccfe03b]33    def __init__(self, shortName, longName, dtype, defaultValue, description = None):
[aa47ea5]34        self._shortName = shortName
35        self._longName = longName
36        self._type = dtype
[ccfe03b]37        self._description = description
[aa47ea5]38        self._defaultValue = defaultValue
39        self.value = defaultValue
40
41    @property
42    def shortName(self):
43        return self._shortName
44
45    @property
46    def longName(self):
47        return self._longName
48
49    @property
50    def type(self):
51        return self._type
52
53    @property
[ccfe03b]54    def description(self):
55        return self._description
56
57    @property
[aa47ea5]58    def defaultValue(self):
59        return self._defaultValue
60
61    def __str__(self):
62        return "'{}' ({}): {} ({})".format(
63                self.longName, self.sortName, self.defaultValue, self.type)
64
65class FittingMethod:
66    """
67    Represents a generic fitting method.
68    """
69    _shortName = None
70    _longName = None
71    _params = None    # dict of <param short names>: <FittingMethodParam>
72
73    def __init__(self, shortName, longName, params):
74        self._shortName = shortName
75        self._longName = longName
76        self._params = dict(zip([p.shortName for p in params], params))
77
78    @property
79    def shortName(self):
80        return self._shortName
81
82    @property
83    def longName(self):
84        return self._longName
85
86    @property
87    def params(self):
88        return self._params
89
[740a738]90    def storeConfig(self):
91        """
92        To be overridden by subclasses specific to optimizers.
93        """
94        pass
95
[aa47ea5]96    def __str__(self):
97        return "\n".join(["{} ({})".format(self.longName, self.shortName)]
98                + [str(p) for p in self.params])
99
100class FittingMethods:
101    """
102    A container for the available fitting methods.
103    Allows SasView to employ other methods than those provided by the bumps package.
104    """
105    _methods = None # a dict containing FitMethod objects
106    _default = None
107
108    def __init__(self):
109        """Receives a list of FittingMethod instances to be initialized with."""
110        self._methods = dict()
111
112    def add(self, fittingMethod):
113        if not isinstance(fittingMethod, FittingMethod):
114            return
115        self._methods[fittingMethod.longName] = fittingMethod
116
117    @property
118    def longNames(self):
119        return list(self._methods.keys())
120
121    @property
122    def ids(self):
123        return [fm.shortName for fm in self._methods.values()]
124
[04a269f]125    def __getitem__(self, longName):
126        return self._methods[longName]
[aa47ea5]127
[740a738]128    def __len__(self):
129        return len(self._methods)
130
131    def __iter__(self):
132        return self._methods.__iter__
133
[aa47ea5]134    @property
135    def default(self):
136        return self._default
137
138    def setDefault(self, methodName):
139        assert methodName in self._methods # silently fail instead?
140        self._default = self._methods[methodName]
141
142    def __str__(self):
143        return "\n".join(["{}: {}".format(key, fm) for key, fm in self._methods.items()])
[72f4834]144
[ccfe03b]145from sas.sascalc.fit.BumpsFitting import toolTips as bumpsToolTips
146
[740a738]147class FittingMethodBumps(FittingMethod):
148    def storeConfig(self):
149        """
150        Writes the user settings of given fitting method back to the optimizer backend
151        where it is used once the 'fit' button is hit in the GUI.
152        """
153        fitConfig = bumps.options.FIT_CONFIG
154        fitConfig.selected_id = self.shortName
155        for param in self.params.values():
156            fitConfig.values[self.shortName][param.shortName] = param.value
157
158class FittingMethodsBumps(FittingMethods):
159    def __init__(self):
160        """
161        Import fitting methods indicated by the provided list of ids from the bumps package.
162        """
163        super(FittingMethodsBumps, self).__init__()
164        ids = fitters.FIT_ACTIVE_IDS
165        for f in fitters.FITTERS:
166            if f.id not in ids:
167                continue
168            params = []
169            for shortName, defValue in f.settings:
170                longName, dtype = bumps.options.FIT_FIELDS[shortName]
[22b4962]171                dtype = self._convertParamType(dtype)
[ccfe03b]172                key = shortName+"_"+f.id
173                descr = bumpsToolTips.get(key, None)
174                param = FittingMethodParameter(shortName, longName, dtype, defValue,
175                                               description=descr)
[740a738]176                params.append(param)
177            self.add(FittingMethodBumps(f.id, f.name, params))
178
[22b4962]179    @staticmethod
180    def _convertParamType(dtype):
181        if dtype is bumps.options.parse_int:
182            dtype = int
183        elif isinstance(dtype, bumps.options.ChoiceList):
184            dtype = tuple(dtype.choices)
185        return dtype
186
[4992ff2]187class FittingOptions(QtWidgets.QDialog, Ui_FittingOptions):
[2d0e0c1]188    """
189    Hard-coded version of the fit options dialog available from BUMPS.
190    This should be make more "dynamic".
191    bumps.options.FIT_FIELDS gives mapping between parameter names, parameter strings and field type
192     (double line edit, integer line edit, combo box etc.), e.g.
193        FIT_FIELDS = dict(
194            samples = ("Samples", parse_int),
195            xtol = ("x tolerance", float))
196
197    bumps.fitters.<algorithm>.settings gives mapping between algorithm, parameter name and default value:
198        e.g.
199        settings = [('steps', 1000), ('starts', 1), ('radius', 0.15), ('xtol', 1e-6), ('ftol', 1e-8)]
200    """
[b0c5e8c]201    fit_option_changed = QtCore.pyqtSignal(str)
[aa47ea5]202    # storing of fitting methods here for now, dependencies might indicate a better place later
[04a269f]203    _fittingMethods = None
204
205    @property
206    def fittingMethods(self):
207        return self._fittingMethods
[2d0e0c1]208
[c9d6b9f]209    def __init__(self, parent=None):
[2d0e0c1]210        super(FittingOptions, self).__init__(parent)
211        self.setupUi(self)
[33c0561]212        # disable the context help icon
213        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
[85487ebd]214        self.setWindowTitle("Fit Algorithms")
[fec5842]215        # no reason to have this widget resizable
216        self.layout().setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
[2d0e0c1]217
218        # Fill up the algorithm combo, based on what BUMPS says is available
[740a738]219        self._fittingMethods = FittingMethodsBumps()
[aa47ea5]220        # option 1: hardcode the list of bumps fitting methods according to forms
221        # option 2: create forms dynamically based on selected fitting methods
[740a738]222        self.fittingMethods.add(FittingMethod('mcsas', 'McSAS', [])) # FIXME just testing for now
[aa47ea5]223        self.fittingMethods.setDefault('Levenberg-Marquardt')
[22b4962]224
[aa47ea5]225        # build up the comboBox finally
226        self.cbAlgorithm.addItems(self.fittingMethods.longNames)
[2d0e0c1]227
228        # Handle the Apply button click
[4992ff2]229        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(self.onApply)
[b0c5e8c]230        # handle the Help button click
[4992ff2]231        self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
[2d0e0c1]232
[b0c5e8c]233        # Assign appropriate validators
234        self.assignValidators()
235
[72f4834]236        # OK has to be initialized to True, after initial validator setup
[4992ff2]237        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
[72f4834]238
[fec5842]239        # Handle the combo box changes
240        self.cbAlgorithm.currentIndexChanged.connect(self.onAlgorithmChange)
241
242        # Set the default index and trigger filling the layout
243        default_index = self.cbAlgorithm.findText(self.fittingMethods.default.longName)
244        self.cbAlgorithm.setCurrentIndex(default_index)
245
[b0c5e8c]246    def assignValidators(self):
247        """
[04a269f]248        Sets the appropriate validators to the line edits as defined by FittingMethodParameter
[b0c5e8c]249        """
[af893db]250        fm = self.fittingMethods[self.currentOptimizer]
[04a269f]251        for param in fm.params.values():
[b0c5e8c]252            validator = None
[22b4962]253            if param.type == int:
[b0c5e8c]254                validator = QtGui.QIntValidator()
[72f4834]255                validator.setBottom(0)
[04a269f]256            elif param.type == float:
[d6b8a1d]257                validator = GuiUtils.DoubleValidator()
[72f4834]258                validator.setBottom(0)
[b0c5e8c]259            else:
260                continue
[04a269f]261            line_edit = self.paramWidget(fm, param.shortName)
262            if hasattr(line_edit, 'setValidator') and validator is not None:
263                line_edit.setValidator(validator)
264                line_edit.textChanged.connect(self.check_state)
265                line_edit.textChanged.emit(line_edit.text())
[72f4834]266
267    def check_state(self, *args, **kwargs):
268        sender = self.sender()
269        validator = sender.validator()
270        state = validator.validate(sender.text(), 0)[0]
271        if state == QtGui.QValidator.Acceptable:
272            color = '' # default
[4992ff2]273            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
[72f4834]274        else:
275            color = '#fff79a' # yellow
[4992ff2]276            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False)
[72f4834]277
278        sender.setStyleSheet('QLineEdit { background-color: %s }' % color)
[2d0e0c1]279
[22b4962]280    def _clearLayout(self):
281        layout = self.groupBox.layout()
282        for i in reversed(list(range(layout.count()))):
283            # reversed removal avoids renumbering possibly
284            item = layout.takeAt(i)
285            try: # spaceritem does not have a widget
[fec5842]286                if item.widget().objectName() == "cbAlgorithm":
287                    continue # do not delete the checkbox, will be added later again
[22b4962]288                item.widget().setParent(None)
289                item.widget().deleteLater()
290            except AttributeError:
291                pass
292
293    @staticmethod
294    def _makeLabel(name):
295        lbl = QtWidgets.QLabel(name + ":")
296        lbl.setAlignment(QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
[fec5842]297        lbl.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding,
298                                                QtWidgets.QSizePolicy.Fixed))
[22b4962]299        lbl.setWordWrap(True)
300        return lbl
301
302    @staticmethod
303    def _inputWidgetFromType(ptype, parent):
304        """
305        Creates a widget for user input based on the given data type to be entered.
306        """
307        widget = None
308        if ptype in (float, int):
309            widget = QtWidgets.QLineEdit(parent)
310        elif isinstance(ptype, (tuple, list)):
311            widget = QtWidgets.QComboBox(parent)
312            widget.addItems(ptype)
313        return widget
314
315    def _fillLayout(self):
316        fm = self.fittingMethods[self.currentOptimizer]
317        layout = self.groupBox.layout()
[fec5842]318        layout.addWidget(self.cbAlgorithm, 0, 0, 1, -1)
[22b4962]319        for param in fm.params.values():
[fec5842]320            row = layout.rowCount()+1
[ccfe03b]321            label = self._makeLabel(param.longName)
322            layout.addWidget(label, row, 0)
[22b4962]323            widget = self._inputWidgetFromType(param.type, self)
324            if widget is None:
325                continue
[ccfe03b]326            if param.description is not None:
327                widget.setToolTip(param.description)
328                label.setToolTip(param.description)
[22b4962]329            layout.addWidget(widget, row, 1)
[ccfe03b]330            widgetName = param.shortName+'_'+fm.shortName
[22b4962]331            setattr(self, widgetName, widget)
332        layout.addItem(QtWidgets.QSpacerItem(0, 0, vPolicy=QtWidgets.QSizePolicy.Expanding))
333
[2d0e0c1]334    def onAlgorithmChange(self, index):
335        """
336        Change the page in response to combo box index
337        """
338        # Find the algorithm ID from name
[af893db]339        selectedName = self.currentOptimizer
[22b4962]340
341        self._clearLayout()
342        self._fillLayout()
343
[2d0e0c1]344        # Select the requested widget
[04a269f]345        self.updateWidgetFromConfig()
[b0c5e8c]346        self.assignValidators()
347
[b67bfa7]348        # OK has to be reinitialized to True
[4992ff2]349        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
[b67bfa7]350
[04a269f]351    def paramWidget(self, fittingMethod, paramShortName):
352        """
353        Returns the widget associated to a FittingMethodParameter.
354        """
355        if paramShortName not in fittingMethod.params:
356            return None
357        widget_name = 'self.'+paramShortName+'_'+fittingMethod.shortName
358        widget = None
359        try:
360            widget = eval(widget_name)
361        except AttributeError:
362            pass
363        return widget
364
[2d0e0c1]365    def onApply(self):
366        """
[b0c5e8c]367        Update the fitter object
[2d0e0c1]368        """
[af893db]369        fm = self.fittingMethods[self.currentOptimizer]
[04a269f]370        for param in fm.params.values():
371            line_edit = self.paramWidget(fm, param.shortName)
[ff3b293]372            if line_edit is None or not isinstance(line_edit, QtWidgets.QLineEdit):
373                continue
374            color = line_edit.palette().color(QtGui.QPalette.Background).name()
375            if color == '#fff79a':
376                # Show a custom tooltip and return
377                tooltip = "<html><b>Please enter valid values in all fields.</html>"
378                QtWidgets.QToolTip.showText(line_edit.mapToGlobal(
[04a269f]379                    QtCore.QPoint(line_edit.rect().right(), line_edit.rect().bottom() + 2)),
380                    tooltip)
[ff3b293]381                return
382
[04a269f]383        # update config values from widgets before any notification is sent
[ea1753f]384        try:
385            self.updateConfigFromWidget(fm)
386        except ValueError:
387            # Don't update bumps if widget has bad data
388            self.reject
[2d0e0c1]389
[740a738]390        fm.storeConfig() # write the current settings to bumps module
[04a269f]391        # Notify the perspective, so the window title is updated
392        self.fit_option_changed.emit(self.cbAlgorithm.currentText())
[ff3b293]393        self.close()
[b0c5e8c]394
395    def onHelp(self):
396        """
397        Show the "Fitting options" section of help
398        """
[e90988c]399        tree_location = GuiUtils.HELP_DIRECTORY_LOCATION
[aed0532]400        tree_location += "/user/qtgui/Perspectives/Fitting/"
[b0c5e8c]401
402        # Actual file anchor will depend on the combo box index
403        # Note that we can be clusmy here, since bad current_fitter_id
404        # will just make the page displayed from the top
[fec5842]405        current_fitter_id = self.fittingMethods[self.currentOptimizer].shortName
406        helpfile = "optimizer.html#fit-" + current_fitter_id
[b0c5e8c]407        help_location = tree_location + helpfile
[e90988c]408        webbrowser.open('file://' + os.path.realpath(help_location))
[b0c5e8c]409
[04a269f]410    @property
411    def currentOptimizer(self):
[2d0e0c1]412        """
413        Sends back the current choice of parameters
414        """
[af893db]415        value = self.cbAlgorithm.currentText()
416        return str(value) # is str() really needed?
[2d0e0c1]417
[04a269f]418    def updateWidgetFromConfig(self):
[2d0e0c1]419        """
420        Given the ID of the current optimizer, fetch the values
421        and update the widget
422        """
[46477950]423        fm = self.fittingMethods[self.currentOptimizer]
[04a269f]424        for param in fm.params.values():
[f86343bd]425            # get the widget name of the option
426            widget = self.paramWidget(fm, param.shortName)
[22b4962]427            if isinstance(param.type, (tuple, list)):
[f86343bd]428                widget.setCurrentIndex(widget.findText(str(param.value)))
[2d0e0c1]429            else:
[f86343bd]430                widget.setText(str(param.value))
[2d0e0c1]431
[ea1753f]432    def updateConfigFromWidget(self, fittingMethod):
433        # update config values from widgets before any notification is sent
434        for param in fittingMethod.params.values():
435            widget = self.paramWidget(fittingMethod, param.shortName)
436            if widget is None:
437                continue
438            new_value = None
439            if isinstance(widget, QtWidgets.QComboBox):
440                new_value = widget.currentText()
441            else:
442                try:
443                    new_value = int(widget.text())
444                except ValueError:
445                    new_value = float(widget.text())
446            if new_value is not None:
447                fittingMethod.params[param.shortName].value = new_value
Note: See TracBrowser for help on using the repository browser.