source: sasview/src/sas/qtgui/Utilities/TabbedModelEditor.py @ aefde77

ESS_GUIESS_GUI_DocsESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_iss959ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since aefde77 was 3790f7f, checked in by Piotr Rozyczko <rozyczko@…>, 7 years ago

More user friendly model editor.
Enabled model check while pending sasmodel change.

  • Property mode set to 100644
File size: 18.1 KB
RevLine 
[3b3b40b]1# global
2import sys
3import os
4import datetime
5import numpy as np
6import logging
[3790f7f]7import traceback
[3b3b40b]8
9from PyQt5 import QtWidgets
10
11from sas.sascalc.fit import models
12
13from sas.qtgui.Utilities.UI.TabbedModelEditor import Ui_TabbedModelEditor
14from sas.qtgui.Utilities.PluginDefinition import PluginDefinition
15from sas.qtgui.Utilities.ModelEditor import ModelEditor
16
17class TabbedModelEditor(QtWidgets.QDialog, Ui_TabbedModelEditor):
18    """
19    Model editor "container" class describing interaction between
20    plugin definition widget and model editor widget.
21    Once the model is defined, it can be saved as a plugin.
22    """
23    # Signals for intertab communication plugin -> editor
24    def __init__(self, parent=None, edit_only=False):
25        super(TabbedModelEditor, self).__init__()
26
27        self.parent = parent
28
29        self.setupUi(self)
30
31        # globals
32        self.filename = ""
33        self.window_title = self.windowTitle()
34        self.edit_only = edit_only
35        self.is_modified = False
36
37        self.addWidgets()
38
39        self.addSignals()
40
41    def addWidgets(self):
42        """
43        Populate tabs with widgets
44        """
45        # Set up widget enablement/visibility
46        self.cmdLoad.setVisible(self.edit_only)
47
48        # Add tabs
49        # Plugin definition widget
50        self.plugin_widget = PluginDefinition(self)
51        self.tabWidget.addTab(self.plugin_widget, "Plugin Definition")
52        self.setPluginActive(True)
53
54        self.editor_widget = ModelEditor(self)
55        # Initially, nothing in the editor
56        self.editor_widget.setEnabled(False)
57        self.tabWidget.addTab(self.editor_widget, "Model editor")
58        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
59
60        if self.edit_only:
61            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setText("Save")
62            # Hide signals from the plugin widget
63            self.plugin_widget.blockSignals(True)
64            # and hide the tab/widget itself
65            self.tabWidget.removeTab(0)
66
67    def addSignals(self):
68        """
69        Define slots for common widget signals
70        """
71        # buttons
72        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).clicked.connect(self.onApply)
73        self.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel).clicked.connect(self.onCancel)
74        self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
75        self.cmdLoad.clicked.connect(self.onLoad)
76        # signals from tabs
77        self.editor_widget.modelModified.connect(self.editorModelModified)
[3790f7f]78        self.plugin_widget.txtName.editingFinished.connect(self.pluginTitleSet)
[3b3b40b]79
80    def setPluginActive(self, is_active=True):
81        """
82        Enablement control for all the controls on the simple plugin editor
83        """
84        self.plugin_widget.setEnabled(is_active)
85
86    def closeEvent(self, event):
87        """
88        Overwrite the close even to assure intent
89        """
90        if self.is_modified:
91            ret = self.onModifiedExit()
92            if ret == QtWidgets.QMessageBox.Cancel:
93                return
94            elif ret == QtWidgets.QMessageBox.Save:
95                self.updateFromEditor()
96        event.accept()
97
98    def onLoad(self):
99        """
100        Loads a model plugin file
101        """
102        plugin_location = models.find_plugins_dir()
103        filename = QtWidgets.QFileDialog.getOpenFileName(
104                                        self,
105                                        'Open Plugin',
106                                        plugin_location,
107                                        'SasView Plugin Model (*.py)',
108                                        None,
109                                        QtWidgets.QFileDialog.DontUseNativeDialog)[0]
110
111        # Load the file
112        if not filename:
113            logging.info("No data file chosen.")
114            return
115
116        self.loadFile(filename)
117
118    def loadFile(self, filename):
119        """
120        Performs the load operation and updates the view
121        """
122        self.editor_widget.blockSignals(True)
123        with open(filename, 'r') as plugin:
124            self.editor_widget.txtEditor.setPlainText(plugin.read())
125        self.editor_widget.setEnabled(True)
126        self.editor_widget.blockSignals(False)
127        self.filename, _ = os.path.splitext(os.path.basename(filename))
128
129        self.setWindowTitle(self.window_title + " - " + self.filename)
130
131    def onModifiedExit(self):
132        msg_box = QtWidgets.QMessageBox(self)
133        msg_box.setWindowTitle("SasView Model Editor")
134        msg_box.setText("The document has been modified.")
135        msg_box.setInformativeText("Do you want to save your changes?")
136        msg_box.setStandardButtons(QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel)
137        msg_box.setDefaultButton(QtWidgets.QMessageBox.Save)
138        return msg_box.exec()
139
140    def onCancel(self):
141        """
142        Accept if document not modified, confirm intent otherwise.
143        """
144        if self.is_modified:
145            ret = self.onModifiedExit()
146            if ret == QtWidgets.QMessageBox.Cancel:
147                return
148            elif ret == QtWidgets.QMessageBox.Save:
149                self.updateFromEditor()
150        self.reject()
151
152    def onApply(self):
153        """
154        Write the plugin and update the model editor if plugin editor open
155        Write/overwrite the plugin if model editor open
156        """
157        if isinstance(self.tabWidget.currentWidget(), PluginDefinition):
158            self.updateFromPlugin()
159        else:
160            self.updateFromEditor()
161        self.is_modified = False
162
163    def editorModelModified(self):
164        """
165        User modified the model in the Model Editor.
166        Disable the plugin editor and show that the model is changed.
167        """
168        self.setTabEdited(True)
169        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
170        self.is_modified = True
171
[3790f7f]172    def pluginTitleSet(self):
[3b3b40b]173        """
[3790f7f]174        User modified the model name.
175        Display the model name in the window title
176        and allow for model save.
[3b3b40b]177        """
178        # Ensure plugin name is non-empty
179        model = self.getModel()
180        if 'filename' in model and model['filename']:
181            self.setWindowTitle(self.window_title + " - " + model['filename'])
182            self.setTabEdited(True)
183            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
184            self.is_modified = True
185        else:
[3790f7f]186            # the model name is empty - disable Apply and clear the editor
[3b3b40b]187            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
[3790f7f]188            self.editor_widget.blockSignals(True)
189            self.editor_widget.txtEditor.setPlainText('')
190            self.editor_widget.blockSignals(False)
191            self.editor_widget.setEnabled(False)
[3b3b40b]192
193    def setTabEdited(self, is_edited):
194        """
195        Change the widget name to indicate unsaved state
196        Unsaved state: add "*" to filename display
197        saved state: remove "*" from filename display
198        """
199        current_text = self.windowTitle()
200
201        if is_edited:
202            if current_text[-1] != "*":
203                current_text += "*"
204        else:
205            if current_text[-1] == "*":
206                current_text = current_text[:-1]
207        self.setWindowTitle(current_text)
208
209    def updateFromPlugin(self):
210        """
211        Write the plugin and update the model editor
212        """
213        # get current model
214        model = self.getModel()
215        if 'filename' not in model: return
216
217        # get required filename
218        filename = model['filename']
219
220        # check if file exists
221        plugin_location = models.find_plugins_dir()
222        full_path = os.path.join(plugin_location, filename)
223        if os.path.splitext(full_path)[1] != ".py":
224            full_path += ".py"
225
226        # Update the global path definition
227        self.filename = full_path
228
229        if not self.canWriteModel(model, full_path):
230            return
231
232        # generate the model representation as string
233        model_str = self.generateModel(model, full_path)
234        self.writeFile(full_path, model_str)
[3790f7f]235
236        # test the model
237
238        # Run the model test in sasmodels
239        try:
240            _ = self.checkModel(full_path)
241        except Exception as ex:
242            msg = "Error building model: "+ str(ex)
243            logging.error(msg)
244            #print three last lines of the stack trace
245            # this will point out the exact line failing
246            last_lines = traceback.format_exc().split('\n')[-4:]
247            traceback_to_show = '\n'.join(last_lines)
248            logging.error(traceback_to_show)
249
250            self.parent.communicate.statusBarUpdateSignal.emit("Model check failed")
251            return
252
253        self.editor_widget.setEnabled(True)
[3b3b40b]254
255        # Update the editor here.
256        # Simple string forced into control.
257        self.editor_widget.blockSignals(True)
258        self.editor_widget.txtEditor.setPlainText(model_str)
259        self.editor_widget.blockSignals(False)
260
261        # Set the widget title
262        self.setTabEdited(False)
263
264        # Notify listeners
265        self.parent.communicate.customModelDirectoryChanged.emit()
266
[3790f7f]267        # Notify the user
268        msg = "Custom model "+filename + " successfully created."
269        self.parent.communicate.statusBarUpdateSignal.emit(msg)
270        logging.info(msg)
271
[3b3b40b]272    def updateFromEditor(self):
273        """
274        Save the current state of the Model Editor
275        """
276        # make sure we have the file handly ready
277        assert(self.filename != "")
278        # Retrieve model string
279        model_str = self.getModel()['text']
280        # Save the file
281        self.writeFile(self.filename, model_str)
282        # Update the tab title
283        self.setTabEdited(False)
[3790f7f]284        # notify the user
285        msg = self.filename + " successfully saved."
286        self.parent.communicate.statusBarUpdateSignal.emit(msg)
287        logging.info(msg)
288
[3b3b40b]289    def canWriteModel(self, model=None, full_path=""):
290        """
291        Determine if the current plugin can be written to file
292        """
293        assert(isinstance(model, dict))
294        assert(full_path!="")
295
296        # Make sure we can overwrite the file if it exists
297        if os.path.isfile(full_path):
298            # can we overwrite it?
299            if not model['overwrite']:
300                # notify the viewer
301                msg = "Plugin with specified name already exists.\n"
302                msg += "Please specify different filename or allow file overwrite."
303                QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
304                # Don't accept but return
305                return False
306        # Update model editor if plugin definition changed
307        func_str = model['text']
308        msg = None
309        if func_str:
310            if 'return' not in func_str:
311                msg = "Error: The func(x) must 'return' a value at least.\n"
312                msg += "For example: \n\nreturn 2*x"
313        else:
314            msg = 'Error: Function is not defined.'
315        if msg is not None:
316            QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
317            return False
318        return True
319
320    def onHelp(self):
321        """
322        Bring up the Model Editor Documentation whenever
323        the HELP button is clicked.
324        Calls Documentation Window with the path of the location within the
325        documentation tree (after /doc/ ....".
326        """
327        location = "/user/sasgui/perspectives/fitting/plugin.html"
328        self.parent.showHelp(location)
329
330    def getModel(self):
331        """
332        Retrieves plugin model from the currently open tab
333        """
334        return self.tabWidget.currentWidget().getModel()
335
[8b480d27]336    @classmethod
337    def writeFile(cls, fname, model_str=""):
[3b3b40b]338        """
339        Write model content to file "fname"
340        """
341        with open(fname, 'w') as out_f:
342            out_f.write(model_str)
343
344    def generateModel(self, model, fname):
345        """
346        generate model from the current plugin state
347        """
348        name = model['filename']
349        desc_str = model['description']
350        param_str = self.strFromParamDict(model['parameters'])
351        pd_param_str = self.strFromParamDict(model['pd_parameters'])
352        func_str = model['text']
353        model_text = CUSTOM_TEMPLATE % {
354            'name': name,
355            'title': 'User model for ' + name,
356            'description': desc_str,
357            'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'),
358        }
359
360        # Write out parameters
361        param_names = []    # to store parameter names
362        pd_params = []
363        model_text += 'parameters = [ \n'
364        model_text += '#   ["name", "units", default, [lower, upper], "type", "description"],\n'
[8b480d27]365        if param_str:
366            for pname, pvalue, desc in self.getParamHelper(param_str):
367                param_names.append(pname)
368                model_text += "    ['%s', '', %s, [-inf, inf], '', '%s'],\n" % (pname, pvalue, desc)
369        if pd_param_str:
370            for pname, pvalue, desc in self.getParamHelper(pd_param_str):
371                param_names.append(pname)
372                pd_params.append(pname)
373                model_text += "    ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc)
[3b3b40b]374        model_text += '    ]\n'
375
376        # Write out function definition
377        model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names)
378        model_text += '    """Absolute scattering"""\n'
379        if "scipy." in func_str:
380            model_text +="    import scipy\n"
381        if "numpy." in func_str:
382            model_text +="    import numpy\n"
383        if "np." in func_str:
384            model_text +="    import numpy as np\n"
385        for func_line in func_str.split('\n'):
386                model_text +='%s%s\n' % ("    ", func_line)
387        model_text +='## uncomment the following if Iq works for vector x\n'
388        model_text +='#Iq.vectorized = True\n'
389
390        # If polydisperse, create place holders for form_volume, ER and VR
391        if pd_params:
392            model_text +="\n"
393            model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)}
394
395        # Create place holder for Iqxy
396        model_text +="\n"
397        model_text +='#def Iqxy(%s):\n' % ', '.join(["x", "y"] + param_names)
398        model_text +='#    """Absolute scattering of oriented particles."""\n'
399        model_text +='#    ...\n'
400        model_text +='#    return oriented_form(x, y, args)\n'
401        model_text +='## uncomment the following if Iqxy works for vector x, y\n'
402        model_text +='#Iqxy.vectorized = True\n'
403
404        return model_text
405
[8b480d27]406    @classmethod
407    def checkModel(cls, path):
[3b3b40b]408        """
409        Check that the model save in file 'path' can run.
410        """
411        # try running the model
412        from sasmodels.sasview_model import load_custom_model
413        Model = load_custom_model(path)
414        model = Model()
415        q =  np.array([0.01, 0.1])
[8b480d27]416        _ = model.evalDistribution(q)
[3b3b40b]417        qx, qy =  np.array([0.01, 0.01]), np.array([0.1, 0.1])
[8b480d27]418        _ = model.evalDistribution([qx, qy])
[3b3b40b]419
420        # check the model's unit tests run
421        from sasmodels.model_test import run_one
[3790f7f]422        # TestSuite module in Qt5 now deletes tests in the suite after running,
423        # so suite[0] in run_one() in sasmodels/model_test.py will contain [None] and
424        # test.info.tests will raise.
425        # Not sure how to change the behaviour here, most likely sasmodels will have to
426        # be modified
[3b3b40b]427        result = run_one(path)
428
429        return result
430
[8b480d27]431    @classmethod
432    def getParamHelper(cls, param_str):
[3b3b40b]433        """
434        yield a sequence of name, value pairs for the parameters in param_str
435
436        Parameters can be defined by one per line by name=value, or multiple
437        on the same line by separating the pairs by semicolon or comma.  The
438        value is optional and defaults to "1.0".
439        """
440        for line in param_str.replace(';', ',').split('\n'):
441            for item in line.split(','):
442                defn, desc = item.split('#', 1) if '#' in item else (item, '')
443                name, value = defn.split('=', 1) if '=' in defn else (defn, '1.0')
444                if name:
445                    yield [v.strip() for v in (name, value, desc)]
[8b480d27]446
447    @classmethod
448    def strFromParamDict(cls, param_dict):
[3b3b40b]449        """
450        Creates string from parameter dictionary
451        {0: ('variable','value'),
452         1: ('variable','value'),
453         ...}
454        """
455        param_str = ""
[8b480d27]456        for _, params in param_dict.items():
[3b3b40b]457            if not params[0]: continue
458            value = 1
459            if params[1]:
460                try:
461                    value = float(params[1])
462                except ValueError:
463                    # convert to default
464                    value = 1
465            param_str += params[0] + " = " + str(value) + "\n"
466        return param_str
467
468
469CUSTOM_TEMPLATE = '''\
470r"""
471Definition
472----------
473
474Calculates %(name)s.
475
476%(description)s
477
478References
479----------
480
481Authorship and Verification
482---------------------------
483
484* **Author:** --- **Date:** %(date)s
485* **Last Modified by:** --- **Date:** %(date)s
486* **Last Reviewed by:** --- **Date:** %(date)s
487"""
488
489from math import *
490from numpy import inf
491
492name = "%(name)s"
493title = "%(title)s"
494description = """%(description)s"""
495
496'''
497
498CUSTOM_TEMPLATE_PD = '''\
499def form_volume(%(args)s):
500    """
501    Volume of the particles used to compute absolute scattering intensity
502    and to weight polydisperse parameter contributions.
503    """
504    return 0.0
505
506def ER(%(args)s):
507    """
508    Effective radius of particles to be used when computing structure factors.
509
510    Input parameters are vectors ranging over the mesh of polydispersity values.
511    """
512    return 0.0
513
514def VR(%(args)s):
515    """
516    Volume ratio of particles to be used when computing structure factors.
517
518    Input parameters are vectors ranging over the mesh of polydispersity values.
519    """
520    return 1.0
521'''
522
523SUM_TEMPLATE = """
524from sasmodels.core import load_model_info
525from sasmodels.sasview_model import make_model_from_info
526
527model_info = load_model_info('{model1}{operator}{model2}')
528model_info.name = '{name}'{desc_line}
529Model = make_model_from_info(model_info)
530"""
531
532if __name__ == '__main__':
533    app = QtWidgets.QApplication(sys.argv)
534    sheet = TabbedModelEditor()
535    sheet.show()
[8b480d27]536    app.exec_()
[3b3b40b]537   
Note: See TracBrowser for help on using the repository browser.