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

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

FittingOptions?: fixed typo

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