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

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 3b3b40b was 3b3b40b, checked in by Piotr Rozyczko <rozyczko@…>, 6 years ago

Merging feature branches

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