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

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

FittingOptions?: let helpers ignore unexpected argument types

  • Property mode set to 100644
File size: 16.1 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 = [fid for fid in fitters.FIT_ACTIVE_IDS if fid != "mp"] # exclude unusable algos
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        # update the widget elements
339        self._clearLayout()
340        self._fillLayout()
341
342        # Select the requested widget
343        self.updateWidgetFromConfig()
344        self.assignValidators()
345
346        # OK has to be reinitialized to True
347        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
348
349    def paramWidget(self, fittingMethod, paramShortName):
350        """
351        Returns the widget associated to a FittingMethodParameter.
352        """
353        if (not isinstance(fittingMethod, FittingMethod)
354            or paramShortName not in fittingMethod.params):
355            return
356        widget_name = 'self.'+paramShortName+'_'+fittingMethod.shortName
357        widget = None
358        try:
359            widget = eval(widget_name)
360        except AttributeError:
361            pass
362        return widget
363
364    def onApply(self):
365        """
366        Update the fitter object
367        """
368        fm = self.fittingMethods[self.currentOptimizer]
369        for param in fm.params.values():
370            line_edit = self.paramWidget(fm, param.shortName)
371            if line_edit is None or not isinstance(line_edit, QtWidgets.QLineEdit):
372                continue
373            color = line_edit.palette().color(QtGui.QPalette.Background).name()
374            if color == '#fff79a':
375                # Show a custom tooltip and return
376                tooltip = "<html><b>Please enter valid values in all fields.</html>"
377                QtWidgets.QToolTip.showText(line_edit.mapToGlobal(
378                    QtCore.QPoint(line_edit.rect().right(), line_edit.rect().bottom() + 2)),
379                    tooltip)
380                return
381
382        # update config values from widgets before any notification is sent
383        try:
384            self.updateConfigFromWidget(fm)
385        except ValueError:
386            # Don't update bumps if widget has bad data
387            self.reject
388
389        fm.storeConfig() # write the current settings to bumps module
390        # Notify the perspective, so the window title is updated
391        self.fit_option_changed.emit(self.cbAlgorithm.currentText())
392        self.close()
393
394    def onHelp(self):
395        """
396        Show the "Fitting options" section of help
397        """
398        tree_location = GuiUtils.HELP_DIRECTORY_LOCATION
399        tree_location += "/user/qtgui/Perspectives/Fitting/"
400
401        # Actual file anchor will depend on the combo box index
402        # Note that we can be clusmy here, since bad current_fitter_id
403        # will just make the page displayed from the top
404        current_fitter_id = self.fittingMethods[self.currentOptimizer].shortName
405        helpfile = "optimizer.html#fit-" + current_fitter_id
406        help_location = tree_location + helpfile
407        webbrowser.open('file://' + os.path.realpath(help_location))
408
409    @property
410    def currentOptimizer(self):
411        """
412        Sends back the current choice of parameters
413        """
414        value = self.cbAlgorithm.currentText()
415        return str(value) # is str() really needed?
416
417    def updateWidgetFromConfig(self):
418        """
419        Given the ID of the current optimizer, fetch the values
420        and update the widget
421        """
422        fm = self.fittingMethods[self.currentOptimizer]
423        for param in fm.params.values():
424            # get the widget name of the option
425            widget = self.paramWidget(fm, param.shortName)
426            if isinstance(param.type, (tuple, list)):
427                widget.setCurrentIndex(widget.findText(str(param.value)))
428            else:
429                widget.setText(str(param.value))
430
431    def updateConfigFromWidget(self, fittingMethod):
432        """
433        Updates the given FittingMethod with the values from the GUI widgets.
434        Does not respect the given data types in the FittingMethod (yet?).
435        """
436        if not isinstance(fittingMethod, FittingMethod):
437            return
438        # update config values from widgets before any notification is sent
439        for param in fittingMethod.params.values():
440            widget = self.paramWidget(fittingMethod, param.shortName)
441            if widget is None:
442                continue
443            new_value = None
444            if isinstance(widget, QtWidgets.QComboBox):
445                new_value = widget.currentText()
446            else:
447                try:
448                    new_value = int(widget.text())
449                except ValueError:
450                    new_value = float(widget.text())
451            if new_value is not None:
452                fittingMethod.params[param.shortName].value = new_value
Note: See TracBrowser for help on using the repository browser.