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

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

FittingOptions?.updateConfigFromWidget()

  • analog to updateWidgetFromConfig()
  • 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, config=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        self.config = config
218
219        # no reason to have this widget resizable
220        self.setFixedSize(self.minimumSizeHint())
221
222        self.setWindowTitle("Fit Algorithms")
223
224        # Fill up the algorithm combo, based on what BUMPS says is available
225        self._fittingMethods = FittingMethodsBumps()
226        # option 1: hardcode the list of bumps fitting methods according to forms
227        # option 2: create forms dynamically based on selected fitting methods
228        self.fittingMethods.add(FittingMethod('mcsas', 'McSAS', [])) # FIXME just testing for now
229        self.fittingMethods.setDefault('Levenberg-Marquardt')
230        # build up the comboBox finally
231        self.cbAlgorithm.addItems(self.fittingMethods.longNames)
232
233        # Handle the Apply button click
234        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(self.onApply)
235        # handle the Help button click
236        self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
237
238        # Handle the combo box changes
239        self.cbAlgorithm.currentIndexChanged.connect(self.onAlgorithmChange)
240
241        # Set the default index
242        default_index = self.cbAlgorithm.findText(self.fittingMethods.default.longName)
243        self.cbAlgorithm.setCurrentIndex(default_index)
244        # previous algorithm choice
245        self.previous_index = default_index
246
247        # Assign appropriate validators
248        self.assignValidators()
249
250        # Set defaults
251        self.current_fitter_id = self.fittingMethods.default.shortName
252
253        # OK has to be initialized to True, after initial validator setup
254        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
255
256    def assignValidators(self):
257        """
258        Sets the appropriate validators to the line edits as defined by FittingMethodParameter
259        """
260        fm = self.fittingMethods[str(self.currentOptimizer)]
261        for param in fm.params.values():
262            validator = None
263            if type(param.type) == types.FunctionType:
264                validator = QtGui.QIntValidator()
265                validator.setBottom(0)
266            elif param.type == float:
267                validator = GuiUtils.DoubleValidator()
268                validator.setBottom(0)
269            else:
270                continue
271            line_edit = self.paramWidget(fm, param.shortName)
272            if hasattr(line_edit, 'setValidator') and validator is not None:
273                line_edit.setValidator(validator)
274                line_edit.textChanged.connect(self.check_state)
275                line_edit.textChanged.emit(line_edit.text())
276
277    def check_state(self, *args, **kwargs):
278        sender = self.sender()
279        validator = sender.validator()
280        state = validator.validate(sender.text(), 0)[0]
281        if state == QtGui.QValidator.Acceptable:
282            color = '' # default
283            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
284        else:
285            color = '#fff79a' # yellow
286            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False)
287
288        sender.setStyleSheet('QLineEdit { background-color: %s }' % color)
289
290    def onAlgorithmChange(self, index):
291        """
292        Change the page in response to combo box index
293        """
294        # Find the algorithm ID from name
295        selectedName = str(self.currentOptimizer)
296        if selectedName in self.fittingMethods.longNames:
297            self.current_fitter_id = self.fittingMethods[selectedName].shortName
298
299        # find the right stacked widget
300        widget_name = "self.page_"+str(self.current_fitter_id)
301
302        # Convert the name into widget instance
303        try:
304            widget_to_activate = eval(widget_name)
305        except AttributeError:
306            # We don't yet have this optimizer.
307            # Show message
308            msg = "This algorithm has not yet been implemented in SasView.\n"
309            msg += "Please choose a different algorithm"
310            QtWidgets.QMessageBox.warning(self,
311                                        'Warning',
312                                        msg,
313                                        QtWidgets.QMessageBox.Ok)
314            # Move the index to previous position
315            self.cbAlgorithm.setCurrentIndex(self.previous_index)
316            return
317
318        index_for_this_id = self.stackedWidget.indexOf(widget_to_activate)
319
320        # Select the requested widget
321        self.stackedWidget.setCurrentIndex(index_for_this_id)
322
323        self.updateWidgetFromConfig()
324
325        self.assignValidators()
326
327        # OK has to be reinitialized to True
328        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
329
330        # keep reference
331        self.previous_index = index
332
333    def paramWidget(self, fittingMethod, paramShortName):
334        """
335        Returns the widget associated to a FittingMethodParameter.
336        """
337        if paramShortName not in fittingMethod.params:
338            return None
339        widget_name = 'self.'+paramShortName+'_'+fittingMethod.shortName
340        widget = None
341        try:
342            widget = eval(widget_name)
343        except AttributeError:
344            pass
345        return widget
346
347    def onApply(self):
348        """
349        Update the fitter object
350        """
351        fm = self.fittingMethods[str(self.currentOptimizer)]
352        for param in fm.params.values():
353            line_edit = self.paramWidget(fm, param.shortName)
354            if line_edit is None or not isinstance(line_edit, QtWidgets.QLineEdit):
355                continue
356            color = line_edit.palette().color(QtGui.QPalette.Background).name()
357            if color == '#fff79a':
358                # Show a custom tooltip and return
359                tooltip = "<html><b>Please enter valid values in all fields.</html>"
360                QtWidgets.QToolTip.showText(line_edit.mapToGlobal(
361                    QtCore.QPoint(line_edit.rect().right(), line_edit.rect().bottom() + 2)),
362                    tooltip)
363                return
364
365        # update config values from widgets before any notification is sent
366        try:
367            self.updateConfigFromWidget(fm)
368        except ValueError:
369            # Don't update bumps if widget has bad data
370            self.reject
371
372        fm.storeConfig() # write the current settings to bumps module
373        # Notify the perspective, so the window title is updated
374        self.fit_option_changed.emit(self.cbAlgorithm.currentText())
375        self.close()
376
377    def onHelp(self):
378        """
379        Show the "Fitting options" section of help
380        """
381        tree_location = GuiUtils.HELP_DIRECTORY_LOCATION
382        tree_location += "/user/qtgui/Perspectives/Fitting/"
383
384        # Actual file anchor will depend on the combo box index
385        # Note that we can be clusmy here, since bad current_fitter_id
386        # will just make the page displayed from the top
387        helpfile = "optimizer.html#fit-" + self.current_fitter_id
388        help_location = tree_location + helpfile
389        webbrowser.open('file://' + os.path.realpath(help_location))
390
391    @property
392    def currentOptimizer(self):
393        """
394        Sends back the current choice of parameters
395        """
396        return self.cbAlgorithm.currentText()
397
398    def updateWidgetFromConfig(self):
399        """
400        Given the ID of the current optimizer, fetch the values
401        and update the widget
402        """
403        fm = self.fittingMethods[str(self.currentOptimizer)]
404        for param in fm.params.values():
405            # Find the widget name of the option
406            # e.g. 'samples' for 'dream' is 'self.samples_dream'
407            widget_name = 'self.'+param.shortName+'_'+(fm.shortName)
408            if isinstance(param.type, ChoiceList):
409                control = eval(widget_name)
410                control.setCurrentIndex(control.findText(str(param.value)))
411            else:
412                eval(widget_name).setText(str(param.value))
413
414    def updateConfigFromWidget(self, fittingMethod):
415        # update config values from widgets before any notification is sent
416        for param in fittingMethod.params.values():
417            widget = self.paramWidget(fittingMethod, param.shortName)
418            if widget is None:
419                continue
420            new_value = None
421            if isinstance(widget, QtWidgets.QComboBox):
422                new_value = widget.currentText()
423            else:
424                try:
425                    new_value = int(widget.text())
426                except ValueError:
427                    new_value = float(widget.text())
428            if new_value is not None:
429                fittingMethod.params[param.shortName].value = new_value
Note: See TracBrowser for help on using the repository browser.