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
RevLine 
[2d0e0c1]1# global
2import sys
3import os
[b0c5e8c]4import types
[e90988c]5import webbrowser
[b0c5e8c]6
[4992ff2]7from PyQt5 import QtCore
8from PyQt5 import QtGui
9from PyQt5 import QtWidgets
[2d0e0c1]10
11from sas.qtgui.UI import images_rc
12from sas.qtgui.UI import main_resources_rc
[b0c5e8c]13import sas.qtgui.Utilities.GuiUtils as GuiUtils
14
[2d0e0c1]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
[04a269f]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
[aa47ea5]44class FittingMethodParameter:
[04a269f]45    """
46    Descriptive meta data of a single parameter of an optimizer.
47    """
[aa47ea5]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
[740a738]106    def storeConfig(self):
107        """
108        To be overridden by subclasses specific to optimizers.
109        """
110        pass
111
[aa47ea5]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
[04a269f]141    def __getitem__(self, longName):
142        return self._methods[longName]
[aa47ea5]143
[740a738]144    def __len__(self):
145        return len(self._methods)
146
147    def __iter__(self):
148        return self._methods.__iter__
149
[aa47ea5]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()])
[72f4834]160
[740a738]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
[4992ff2]189class FittingOptions(QtWidgets.QDialog, Ui_FittingOptions):
[2d0e0c1]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    """
[b0c5e8c]203    fit_option_changed = QtCore.pyqtSignal(str)
[aa47ea5]204    # storing of fitting methods here for now, dependencies might indicate a better place later
[04a269f]205    _fittingMethods = None
206
207    @property
208    def fittingMethods(self):
209        return self._fittingMethods
[2d0e0c1]210
[c9d6b9f]211    def __init__(self, parent=None):
[2d0e0c1]212        super(FittingOptions, self).__init__(parent)
213        self.setupUi(self)
[33c0561]214        # disable the context help icon
215        self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
[2d0e0c1]216
217        # no reason to have this widget resizable
218        self.setFixedSize(self.minimumSizeHint())
[85487ebd]219        self.setWindowTitle("Fit Algorithms")
[2d0e0c1]220
221        # Fill up the algorithm combo, based on what BUMPS says is available
[740a738]222        self._fittingMethods = FittingMethodsBumps()
[aa47ea5]223        # option 1: hardcode the list of bumps fitting methods according to forms
224        # option 2: create forms dynamically based on selected fitting methods
[740a738]225        self.fittingMethods.add(FittingMethod('mcsas', 'McSAS', [])) # FIXME just testing for now
[aa47ea5]226        self.fittingMethods.setDefault('Levenberg-Marquardt')
227        # build up the comboBox finally
228        self.cbAlgorithm.addItems(self.fittingMethods.longNames)
[2d0e0c1]229
230        # Handle the Apply button click
[4992ff2]231        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).clicked.connect(self.onApply)
[b0c5e8c]232        # handle the Help button click
[4992ff2]233        self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
[2d0e0c1]234
235        # Handle the combo box changes
236        self.cbAlgorithm.currentIndexChanged.connect(self.onAlgorithmChange)
237
238        # Set the default index
[aa47ea5]239        default_index = self.cbAlgorithm.findText(self.fittingMethods.default.longName)
[2d0e0c1]240        self.cbAlgorithm.setCurrentIndex(default_index)
[8873ab7]241        # previous algorithm choice
242        self.previous_index = default_index
[b0c5e8c]243
244        # Assign appropriate validators
245        self.assignValidators()
246
[72f4834]247        # Set defaults
[aa47ea5]248        self.current_fitter_id = self.fittingMethods.default.shortName
[2d0e0c1]249
[72f4834]250        # OK has to be initialized to True, after initial validator setup
[4992ff2]251        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
[72f4834]252
[b0c5e8c]253    def assignValidators(self):
254        """
[04a269f]255        Sets the appropriate validators to the line edits as defined by FittingMethodParameter
[b0c5e8c]256        """
[af893db]257        fm = self.fittingMethods[self.currentOptimizer]
[04a269f]258        for param in fm.params.values():
[b0c5e8c]259            validator = None
[04a269f]260            if type(param.type) == types.FunctionType:
[b0c5e8c]261                validator = QtGui.QIntValidator()
[72f4834]262                validator.setBottom(0)
[04a269f]263            elif param.type == float:
[d6b8a1d]264                validator = GuiUtils.DoubleValidator()
[72f4834]265                validator.setBottom(0)
[b0c5e8c]266            else:
267                continue
[04a269f]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())
[72f4834]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
[4992ff2]280            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
[72f4834]281        else:
282            color = '#fff79a' # yellow
[4992ff2]283            self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False)
[72f4834]284
285        sender.setStyleSheet('QLineEdit { background-color: %s }' % color)
[2d0e0c1]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
[af893db]292        selectedName = self.currentOptimizer
[aa47ea5]293        if selectedName in self.fittingMethods.longNames:
294            self.current_fitter_id = self.fittingMethods[selectedName].shortName
[2d0e0c1]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
[8873ab7]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
[b0c5e8c]315        index_for_this_id = self.stackedWidget.indexOf(widget_to_activate)
[2d0e0c1]316
317        # Select the requested widget
[b0c5e8c]318        self.stackedWidget.setCurrentIndex(index_for_this_id)
[04a269f]319        self.updateWidgetFromConfig()
[b0c5e8c]320        self.assignValidators()
321
[b67bfa7]322        # OK has to be reinitialized to True
[4992ff2]323        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(True)
[b67bfa7]324
[8873ab7]325        # keep reference
326        self.previous_index = index
327
[04a269f]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
[2d0e0c1]342    def onApply(self):
343        """
[b0c5e8c]344        Update the fitter object
[2d0e0c1]345        """
[af893db]346        fm = self.fittingMethods[self.currentOptimizer]
[04a269f]347        for param in fm.params.values():
348            line_edit = self.paramWidget(fm, param.shortName)
[ff3b293]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(
[04a269f]356                    QtCore.QPoint(line_edit.rect().right(), line_edit.rect().bottom() + 2)),
357                    tooltip)
[ff3b293]358                return
359
[04a269f]360        # update config values from widgets before any notification is sent
[ea1753f]361        try:
362            self.updateConfigFromWidget(fm)
363        except ValueError:
364            # Don't update bumps if widget has bad data
365            self.reject
[2d0e0c1]366
[740a738]367        fm.storeConfig() # write the current settings to bumps module
[04a269f]368        # Notify the perspective, so the window title is updated
369        self.fit_option_changed.emit(self.cbAlgorithm.currentText())
[ff3b293]370        self.close()
[b0c5e8c]371
372    def onHelp(self):
373        """
374        Show the "Fitting options" section of help
375        """
[e90988c]376        tree_location = GuiUtils.HELP_DIRECTORY_LOCATION
[aed0532]377        tree_location += "/user/qtgui/Perspectives/Fitting/"
[b0c5e8c]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
[e90988c]384        webbrowser.open('file://' + os.path.realpath(help_location))
[b0c5e8c]385
[04a269f]386    @property
387    def currentOptimizer(self):
[2d0e0c1]388        """
389        Sends back the current choice of parameters
390        """
[af893db]391        value = self.cbAlgorithm.currentText()
392        return str(value) # is str() really needed?
[2d0e0c1]393
[04a269f]394    def updateWidgetFromConfig(self):
[2d0e0c1]395        """
396        Given the ID of the current optimizer, fetch the values
397        and update the widget
398        """
[46477950]399        fm = self.fittingMethods[self.currentOptimizer]
[04a269f]400        for param in fm.params.values():
[2d0e0c1]401            # Find the widget name of the option
402            # e.g. 'samples' for 'dream' is 'self.samples_dream'
[04a269f]403            widget_name = 'self.'+param.shortName+'_'+(fm.shortName)
404            if isinstance(param.type, ChoiceList):
[2d0e0c1]405                control = eval(widget_name)
[04a269f]406                control.setCurrentIndex(control.findText(str(param.value)))
[2d0e0c1]407            else:
[04a269f]408                eval(widget_name).setText(str(param.value))
[2d0e0c1]409
[ea1753f]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.