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

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

FittingOptions?: removed stackWidget from .UI, fixed layout issues, removed obsolete code

  • fitting method parameter tooltips will be back soon
  • Property mode set to 100644
File size: 15.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        self.setWindowTitle("Fit Algorithms")
204        # no reason to have this widget resizable
205        self.layout().setSizeConstraint(QtWidgets.QLayout.SetFixedSize)
206
207        # Fill up the algorithm combo, based on what BUMPS says is available
208        self._fittingMethods = FittingMethodsBumps()
209        # option 1: hardcode the list of bumps fitting methods according to forms
210        # option 2: create forms dynamically based on selected fitting methods
211        self.fittingMethods.add(FittingMethod('mcsas', 'McSAS', [])) # FIXME just testing for now
212        self.fittingMethods.setDefault('Levenberg-Marquardt')
213
214        # build up the comboBox finally
215        self.cbAlgorithm.addItems(self.fittingMethods.longNames)
216
217        # Handle the Apply button click
218        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(self.onApply)
219        # handle the Help button click
220        self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
221
222        # Assign appropriate validators
223        self.assignValidators()
224
225        # OK has to be initialized to True, after initial validator setup
226        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
227
228        # Handle the combo box changes
229        self.cbAlgorithm.currentIndexChanged.connect(self.onAlgorithmChange)
230
231        # Set the default index and trigger filling the layout
232        default_index = self.cbAlgorithm.findText(self.fittingMethods.default.longName)
233        self.cbAlgorithm.setCurrentIndex(default_index)
234
235    def assignValidators(self):
236        """
237        Sets the appropriate validators to the line edits as defined by FittingMethodParameter
238        """
239        fm = self.fittingMethods[self.currentOptimizer]
240        for param in fm.params.values():
241            validator = None
242            if param.type == int:
243                validator = QtGui.QIntValidator()
244                validator.setBottom(0)
245            elif param.type == float:
246                validator = GuiUtils.DoubleValidator()
247                validator.setBottom(0)
248            else:
249                continue
250            line_edit = self.paramWidget(fm, param.shortName)
251            if hasattr(line_edit, 'setValidator') and validator is not None:
252                line_edit.setValidator(validator)
253                line_edit.textChanged.connect(self.check_state)
254                line_edit.textChanged.emit(line_edit.text())
255
256    def check_state(self, *args, **kwargs):
257        sender = self.sender()
258        validator = sender.validator()
259        state = validator.validate(sender.text(), 0)[0]
260        if state == QtGui.QValidator.Acceptable:
261            color = '' # default
262            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
263        else:
264            color = '#fff79a' # yellow
265            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False)
266
267        sender.setStyleSheet('QLineEdit { background-color: %s }' % color)
268
269    def _clearLayout(self):
270        layout = self.groupBox.layout()
271        for i in reversed(list(range(layout.count()))):
272            # reversed removal avoids renumbering possibly
273            item = layout.takeAt(i)
274            try: # spaceritem does not have a widget
275                if item.widget().objectName() == "cbAlgorithm":
276                    continue # do not delete the checkbox, will be added later again
277                item.widget().setParent(None)
278                item.widget().deleteLater()
279            except AttributeError:
280                pass
281
282    @staticmethod
283    def _makeLabel(name):
284        lbl = QtWidgets.QLabel(name + ":")
285        lbl.setAlignment(QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
286        lbl.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding,
287                                                QtWidgets.QSizePolicy.Fixed))
288        lbl.setWordWrap(True)
289        return lbl
290
291    @staticmethod
292    def _inputWidgetFromType(ptype, parent):
293        """
294        Creates a widget for user input based on the given data type to be entered.
295        """
296        widget = None
297        if ptype in (float, int):
298            widget = QtWidgets.QLineEdit(parent)
299        elif isinstance(ptype, (tuple, list)):
300            widget = QtWidgets.QComboBox(parent)
301            widget.addItems(ptype)
302        return widget
303
304    def _fillLayout(self):
305        fm = self.fittingMethods[self.currentOptimizer]
306        layout = self.groupBox.layout()
307        layout.addWidget(self.cbAlgorithm, 0, 0, 1, -1)
308        for param in fm.params.values():
309            row = layout.rowCount()+1
310            layout.addWidget(self._makeLabel(param.longName), row, 0)
311            widget = self._inputWidgetFromType(param.type, self)
312            if widget is None:
313                continue
314            widgetName = param.shortName+'_'+fm.shortName
315            layout.addWidget(widget, row, 1)
316            setattr(self, widgetName, widget)
317        layout.addItem(QtWidgets.QSpacerItem(0, 0, vPolicy=QtWidgets.QSizePolicy.Expanding))
318
319    def onAlgorithmChange(self, index):
320        """
321        Change the page in response to combo box index
322        """
323        # Find the algorithm ID from name
324        selectedName = self.currentOptimizer
325
326        self._clearLayout()
327        self._fillLayout()
328
329        # Select the requested widget
330        self.updateWidgetFromConfig()
331        self.assignValidators()
332
333        # OK has to be reinitialized to True
334        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
335
336    def paramWidget(self, fittingMethod, paramShortName):
337        """
338        Returns the widget associated to a FittingMethodParameter.
339        """
340        if paramShortName not in fittingMethod.params:
341            return None
342        widget_name = 'self.'+paramShortName+'_'+fittingMethod.shortName
343        widget = None
344        try:
345            widget = eval(widget_name)
346        except AttributeError:
347            pass
348        return widget
349
350    def onApply(self):
351        """
352        Update the fitter object
353        """
354        fm = self.fittingMethods[self.currentOptimizer]
355        for param in fm.params.values():
356            line_edit = self.paramWidget(fm, param.shortName)
357            if line_edit is None or not isinstance(line_edit, QtWidgets.QLineEdit):
358                continue
359            color = line_edit.palette().color(QtGui.QPalette.Background).name()
360            if color == '#fff79a':
361                # Show a custom tooltip and return
362                tooltip = "<html><b>Please enter valid values in all fields.</html>"
363                QtWidgets.QToolTip.showText(line_edit.mapToGlobal(
364                    QtCore.QPoint(line_edit.rect().right(), line_edit.rect().bottom() + 2)),
365                    tooltip)
366                return
367
368        # update config values from widgets before any notification is sent
369        try:
370            self.updateConfigFromWidget(fm)
371        except ValueError:
372            # Don't update bumps if widget has bad data
373            self.reject
374
375        fm.storeConfig() # write the current settings to bumps module
376        # Notify the perspective, so the window title is updated
377        self.fit_option_changed.emit(self.cbAlgorithm.currentText())
378        self.close()
379
380    def onHelp(self):
381        """
382        Show the "Fitting options" section of help
383        """
384        tree_location = GuiUtils.HELP_DIRECTORY_LOCATION
385        tree_location += "/user/qtgui/Perspectives/Fitting/"
386
387        # Actual file anchor will depend on the combo box index
388        # Note that we can be clusmy here, since bad current_fitter_id
389        # will just make the page displayed from the top
390        current_fitter_id = self.fittingMethods[self.currentOptimizer].shortName
391        helpfile = "optimizer.html#fit-" + current_fitter_id
392        help_location = tree_location + helpfile
393        webbrowser.open('file://' + os.path.realpath(help_location))
394
395    @property
396    def currentOptimizer(self):
397        """
398        Sends back the current choice of parameters
399        """
400        value = self.cbAlgorithm.currentText()
401        return str(value) # is str() really needed?
402
403    def updateWidgetFromConfig(self):
404        """
405        Given the ID of the current optimizer, fetch the values
406        and update the widget
407        """
408        fm = self.fittingMethods[self.currentOptimizer]
409        for param in fm.params.values():
410            # Find the widget name of the option
411            # e.g. 'samples' for 'dream' is 'self.samples_dream'
412            widget_name = 'self.'+param.shortName+'_'+fm.shortName
413            if isinstance(param.type, (tuple, list)):
414                control = eval(widget_name)
415                control.setCurrentIndex(control.findText(str(param.value)))
416            else:
417                eval(widget_name).setText(str(param.value))
418
419    def updateConfigFromWidget(self, fittingMethod):
420        # update config values from widgets before any notification is sent
421        for param in fittingMethod.params.values():
422            widget = self.paramWidget(fittingMethod, param.shortName)
423            if widget is None:
424                continue
425            new_value = None
426            if isinstance(widget, QtWidgets.QComboBox):
427                new_value = widget.currentText()
428            else:
429                try:
430                    new_value = int(widget.text())
431                except ValueError:
432                    new_value = float(widget.text())
433            if new_value is not None:
434                fittingMethod.params[param.shortName].value = new_value
Note: See TracBrowser for help on using the repository browser.