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

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

FittingOptions?: using new FittingMethods? structure, updated FittingPerspective?

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