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

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

some new classes to handle fitting methods in a generic way

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