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

ESS_GUI_bumps_abstraction
Last change on this file since 22b4962 was 22b4962, checked in by ibressler, 8 months ago

FittingOptions?: create form dynamically

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