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

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

Code review changes

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