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

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

FittingOptions?: now with tooltips again

  • extracted from generated FittingOptions? UI file
  • moved to bumps related interface code
    • could be provided by bumps directly instead
    • alternatively, extract the tooltips from the documentation programatically, perhaps?
  • Property mode set to 100644
File size: 15.9 KB
Line 
1# global
2import sys
3import os
4import types
5import webbrowser
6
7from PyQt5 import QtCore
8from PyQt5 import QtGui
9from PyQt5 import QtWidgets
10
11from sas.qtgui.UI import images_rc
12from sas.qtgui.UI import main_resources_rc
13import sas.qtgui.Utilities.GuiUtils as GuiUtils
14
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
22class FittingMethodParameter:
23    """
24    Descriptive meta data of a single parameter of an optimizer.
25    """
26    _shortName = None
27    _longName = None
28    _type = None
29    _description = None # an optional description for the user, will be shown as UI tooltip
30    _defaultValue = None
31    value = None
32
33    def __init__(self, shortName, longName, dtype, defaultValue, description = None):
34        self._shortName = shortName
35        self._longName = longName
36        self._type = dtype
37        self._description = description
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
54    def description(self):
55        return self._description
56
57    @property
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
90    def storeConfig(self):
91        """
92        To be overridden by subclasses specific to optimizers.
93        """
94        pass
95
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
125    def __getitem__(self, longName):
126        return self._methods[longName]
127
128    def __len__(self):
129        return len(self._methods)
130
131    def __iter__(self):
132        return self._methods.__iter__
133
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()])
144
145from sas.sascalc.fit.BumpsFitting import toolTips as bumpsToolTips
146
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]
171                dtype = self._convertParamType(dtype)
172                key = shortName+"_"+f.id
173                descr = bumpsToolTips.get(key, None)
174                param = FittingMethodParameter(shortName, longName, dtype, defValue,
175                                               description=descr)
176                params.append(param)
177            self.add(FittingMethodBumps(f.id, f.name, params))
178
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
187class FittingOptions(QtWidgets.QDialog, Ui_FittingOptions):
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    """
201    fit_option_changed = QtCore.pyqtSignal(str)
202    # storing of fitting methods here for now, dependencies might indicate a better place later
203    _fittingMethods = None
204
205    @property
206    def fittingMethods(self):
207        return self._fittingMethods
208
209    def __init__(self, parent=None):
210        super(FittingOptions, self).__init__(parent)
211        self.setupUi(self)
212        # disable the context help icon
213        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
214        self.setWindowTitle("Fit Algorithms")
215        # no reason to have this widget resizable
216        self.layout().setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
217
218        # Fill up the algorithm combo, based on what BUMPS says is available
219        self._fittingMethods = FittingMethodsBumps()
220        # option 1: hardcode the list of bumps fitting methods according to forms
221        # option 2: create forms dynamically based on selected fitting methods
222        self.fittingMethods.add(FittingMethod('mcsas', 'McSAS', [])) # FIXME just testing for now
223        self.fittingMethods.setDefault('Levenberg-Marquardt')
224
225        # build up the comboBox finally
226        self.cbAlgorithm.addItems(self.fittingMethods.longNames)
227
228        # Handle the Apply button click
229        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(self.onApply)
230        # handle the Help button click
231        self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
232
233        # Assign appropriate validators
234        self.assignValidators()
235
236        # OK has to be initialized to True, after initial validator setup
237        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
238
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
246    def assignValidators(self):
247        """
248        Sets the appropriate validators to the line edits as defined by FittingMethodParameter
249        """
250        fm = self.fittingMethods[self.currentOptimizer]
251        for param in fm.params.values():
252            validator = None
253            if param.type == int:
254                validator = QtGui.QIntValidator()
255                validator.setBottom(0)
256            elif param.type == float:
257                validator = GuiUtils.DoubleValidator()
258                validator.setBottom(0)
259            else:
260                continue
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())
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
273            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
274        else:
275            color = '#fff79a' # yellow
276            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False)
277
278        sender.setStyleSheet('QLineEdit { background-color: %s }' % color)
279
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
286                if item.widget().objectName() == "cbAlgorithm":
287                    continue # do not delete the checkbox, will be added later again
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)
297        lbl.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding,
298                                                QtWidgets.QSizePolicy.Fixed))
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()
318        layout.addWidget(self.cbAlgorithm, 0, 0, 1, -1)
319        for param in fm.params.values():
320            row = layout.rowCount()+1
321            label = self._makeLabel(param.longName)
322            layout.addWidget(label, row, 0)
323            widget = self._inputWidgetFromType(param.type, self)
324            if widget is None:
325                continue
326            if param.description is not None:
327                widget.setToolTip(param.description)
328                label.setToolTip(param.description)
329            layout.addWidget(widget, row, 1)
330            widgetName = param.shortName+'_'+fm.shortName
331            setattr(self, widgetName, widget)
332        layout.addItem(QtWidgets.QSpacerItem(0, 0, vPolicy=QtWidgets.QSizePolicy.Expanding))
333
334    def onAlgorithmChange(self, index):
335        """
336        Change the page in response to combo box index
337        """
338        # Find the algorithm ID from name
339        selectedName = self.currentOptimizer
340
341        self._clearLayout()
342        self._fillLayout()
343
344        # Select the requested widget
345        self.updateWidgetFromConfig()
346        self.assignValidators()
347
348        # OK has to be reinitialized to True
349        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
350
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
365    def onApply(self):
366        """
367        Update the fitter object
368        """
369        fm = self.fittingMethods[self.currentOptimizer]
370        for param in fm.params.values():
371            line_edit = self.paramWidget(fm, param.shortName)
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(
379                    QtCore.QPoint(line_edit.rect().right(), line_edit.rect().bottom() + 2)),
380                    tooltip)
381                return
382
383        # update config values from widgets before any notification is sent
384        try:
385            self.updateConfigFromWidget(fm)
386        except ValueError:
387            # Don't update bumps if widget has bad data
388            self.reject
389
390        fm.storeConfig() # write the current settings to bumps module
391        # Notify the perspective, so the window title is updated
392        self.fit_option_changed.emit(self.cbAlgorithm.currentText())
393        self.close()
394
395    def onHelp(self):
396        """
397        Show the "Fitting options" section of help
398        """
399        tree_location = GuiUtils.HELP_DIRECTORY_LOCATION
400        tree_location += "/user/qtgui/Perspectives/Fitting/"
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
405        current_fitter_id = self.fittingMethods[self.currentOptimizer].shortName
406        helpfile = "optimizer.html#fit-" + current_fitter_id
407        help_location = tree_location + helpfile
408        webbrowser.open('file://' + os.path.realpath(help_location))
409
410    @property
411    def currentOptimizer(self):
412        """
413        Sends back the current choice of parameters
414        """
415        value = self.cbAlgorithm.currentText()
416        return str(value) # is str() really needed?
417
418    def updateWidgetFromConfig(self):
419        """
420        Given the ID of the current optimizer, fetch the values
421        and update the widget
422        """
423        fm = self.fittingMethods[self.currentOptimizer]
424        for param in fm.params.values():
425            # Find the widget name of the option
426            # e.g. 'samples' for 'dream' is 'self.samples_dream'
427            widget_name = 'self.'+param.shortName+'_'+fm.shortName
428            if isinstance(param.type, (tuple, list)):
429                control = eval(widget_name)
430                control.setCurrentIndex(control.findText(str(param.value)))
431            else:
432                eval(widget_name).setText(str(param.value))
433
434    def updateConfigFromWidget(self, fittingMethod):
435        # update config values from widgets before any notification is sent
436        for param in fittingMethod.params.values():
437            widget = self.paramWidget(fittingMethod, param.shortName)
438            if widget is None:
439                continue
440            new_value = None
441            if isinstance(widget, QtWidgets.QComboBox):
442                new_value = widget.currentText()
443            else:
444                try:
445                    new_value = int(widget.text())
446                except ValueError:
447                    new_value = float(widget.text())
448            if new_value is not None:
449                fittingMethod.params[param.shortName].value = new_value
Note: See TracBrowser for help on using the repository browser.