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

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

Merge branch 'ESS_GUI' into ESS_GUI_sum_editor

  • Property mode set to 100644
File size: 17.3 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 = filename
129        display_name, _ = os.path.splitext(os.path.basename(filename))
130
131        self.setWindowTitle(self.window_title + " - " + display_name)
132
133    def onModifiedExit(self):
134        msg_box = QtWidgets.QMessageBox(self)
135        msg_box.setWindowTitle("SasView Model Editor")
136        msg_box.setText("The document has been modified.")
137        msg_box.setInformativeText("Do you want to save your changes?")
138        msg_box.setStandardButtons(QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel)
139        msg_box.setDefaultButton(QtWidgets.QMessageBox.Save)
140        return msg_box.exec()
141
142    def onCancel(self):
143        """
144        Accept if document not modified, confirm intent otherwise.
145        """
146        if self.is_modified:
147            ret = self.onModifiedExit()
148            if ret == QtWidgets.QMessageBox.Cancel:
149                return
150            elif ret == QtWidgets.QMessageBox.Save:
151                self.updateFromEditor()
152        self.reject()
153
154    def onApply(self):
155        """
156        Write the plugin and update the model editor if plugin editor open
157        Write/overwrite the plugin if model editor open
158        """
159        if isinstance(self.tabWidget.currentWidget(), PluginDefinition):
160            self.updateFromPlugin()
161        else:
162            self.updateFromEditor()
163        self.is_modified = False
164
165    def editorModelModified(self):
166        """
167        User modified the model in the Model Editor.
168        Disable the plugin editor and show that the model is changed.
169        """
170        self.setTabEdited(True)
171        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
172        self.is_modified = True
173
174    def pluginTitleSet(self):
175        """
176        User modified the model name.
177        Display the model name in the window title
178        and allow for model save.
179        """
180        # Ensure plugin name is non-empty
181        model = self.getModel()
182        if 'filename' in model and model['filename']:
183            self.setWindowTitle(self.window_title + " - " + model['filename'])
184            self.setTabEdited(True)
185            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
186            self.is_modified = True
187        else:
188            # the model name is empty - disable Apply and clear the editor
189            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
190            self.editor_widget.blockSignals(True)
191            self.editor_widget.txtEditor.setPlainText('')
192            self.editor_widget.blockSignals(False)
193            self.editor_widget.setEnabled(False)
194
195    def setTabEdited(self, is_edited):
196        """
197        Change the widget name to indicate unsaved state
198        Unsaved state: add "*" to filename display
199        saved state: remove "*" from filename display
200        """
201        current_text = self.windowTitle()
202
203        if is_edited:
204            if current_text[-1] != "*":
205                current_text += "*"
206        else:
207            if current_text[-1] == "*":
208                current_text = current_text[:-1]
209        self.setWindowTitle(current_text)
210
211    def updateFromPlugin(self):
212        """
213        Write the plugin and update the model editor
214        """
215        # get current model
216        model = self.getModel()
217        if 'filename' not in model: return
218
219        # get required filename
220        filename = model['filename']
221
222        # check if file exists
223        plugin_location = models.find_plugins_dir()
224        full_path = os.path.join(plugin_location, filename)
225        if os.path.splitext(full_path)[1] != ".py":
226            full_path += ".py"
227
228        # Update the global path definition
229        self.filename = full_path
230
231        if not self.canWriteModel(model, full_path):
232            return
233
234        # generate the model representation as string
235        model_str = self.generateModel(model, full_path)
236        self.writeFile(full_path, model_str)
237
238        # test the model
239
240        # Run the model test in sasmodels
241        try:
242            _ = GuiUtils.checkModel(full_path)
243        except Exception as ex:
244            msg = "Error building model: "+ str(ex)
245            logging.error(msg)
246            #print three last lines of the stack trace
247            # this will point out the exact line failing
248            last_lines = traceback.format_exc().split('\n')[-4:]
249            traceback_to_show = '\n'.join(last_lines)
250            logging.error(traceback_to_show)
251
252            self.parent.communicate.statusBarUpdateSignal.emit("Model check failed")
253            return
254
255        self.editor_widget.setEnabled(True)
256
257        # Update the editor here.
258        # Simple string forced into control.
259        self.editor_widget.blockSignals(True)
260        self.editor_widget.txtEditor.setPlainText(model_str)
261        self.editor_widget.blockSignals(False)
262
263        # Set the widget title
264        self.setTabEdited(False)
265
266        # Notify listeners
267        self.parent.communicate.customModelDirectoryChanged.emit()
268
269        # Notify the user
270        msg = "Custom model "+filename + " successfully created."
271        self.parent.communicate.statusBarUpdateSignal.emit(msg)
272        logging.info(msg)
273
274    def updateFromEditor(self):
275        """
276        Save the current state of the Model Editor
277        """
278        # make sure we have the file handly ready
279        assert(self.filename != "")
280        # Retrieve model string
281        model_str = self.getModel()['text']
282        # Save the file
283        self.writeFile(self.filename, model_str)
284        # Update the tab title
285        self.setTabEdited(False)
286        # notify the user
287        msg = self.filename + " successfully saved."
288        self.parent.communicate.statusBarUpdateSignal.emit(msg)
289        logging.info(msg)
290
291    def canWriteModel(self, model=None, full_path=""):
292        """
293        Determine if the current plugin can be written to file
294        """
295        assert(isinstance(model, dict))
296        assert(full_path!="")
297
298        # Make sure we can overwrite the file if it exists
299        if os.path.isfile(full_path):
300            # can we overwrite it?
301            if not model['overwrite']:
302                # notify the viewer
303                msg = "Plugin with specified name already exists.\n"
304                msg += "Please specify different filename or allow file overwrite."
305                QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
306                # Don't accept but return
307                return False
308        # Update model editor if plugin definition changed
309        func_str = model['text']
310        msg = None
311        if func_str:
312            if 'return' not in func_str:
313                msg = "Error: The func(x) must 'return' a value at least.\n"
314                msg += "For example: \n\nreturn 2*x"
315        else:
316            msg = 'Error: Function is not defined.'
317        if msg is not None:
318            QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
319            return False
320        return True
321
322    def onHelp(self):
323        """
324        Bring up the Model Editor Documentation whenever
325        the HELP button is clicked.
326        Calls Documentation Window with the path of the location within the
327        documentation tree (after /doc/ ....".
328        """
329        location = "/user/sasgui/perspectives/fitting/plugin.html"
330        self.parent.showHelp(location)
331
332    def getModel(self):
333        """
334        Retrieves plugin model from the currently open tab
335        """
336        return self.tabWidget.currentWidget().getModel()
337
338    @classmethod
339    def writeFile(cls, fname, model_str=""):
340        """
341        Write model content to file "fname"
342        """
343        with open(fname, 'w') as out_f:
344            out_f.write(model_str)
345
346    def generateModel(self, model, fname):
347        """
348        generate model from the current plugin state
349        """
350        name = model['filename']
351        desc_str = model['description']
352        param_str = self.strFromParamDict(model['parameters'])
353        pd_param_str = self.strFromParamDict(model['pd_parameters'])
354        func_str = model['text']
355        model_text = CUSTOM_TEMPLATE % {
356            'name': name,
357            'title': 'User model for ' + name,
358            'description': desc_str,
359            'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'),
360        }
361
362        # Write out parameters
363        param_names = []    # to store parameter names
364        pd_params = []
365        model_text += 'parameters = [ \n'
366        model_text += '#   ["name", "units", default, [lower, upper], "type", "description"],\n'
367        if param_str:
368            for pname, pvalue, desc in self.getParamHelper(param_str):
369                param_names.append(pname)
370                model_text += "    ['%s', '', %s, [-inf, inf], '', '%s'],\n" % (pname, pvalue, desc)
371        if pd_param_str:
372            for pname, pvalue, desc in self.getParamHelper(pd_param_str):
373                param_names.append(pname)
374                pd_params.append(pname)
375                model_text += "    ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc)
376        model_text += '    ]\n'
377
378        # Write out function definition
379        model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names)
380        model_text += '    """Absolute scattering"""\n'
381        if "scipy." in func_str:
382            model_text +="    import scipy\n"
383        if "numpy." in func_str:
384            model_text +="    import numpy\n"
385        if "np." in func_str:
386            model_text +="    import numpy as np\n"
387        for func_line in func_str.split('\n'):
388                model_text +='%s%s\n' % ("    ", func_line)
389        model_text +='## uncomment the following if Iq works for vector x\n'
390        model_text +='#Iq.vectorized = True\n'
391
392        # If polydisperse, create place holders for form_volume, ER and VR
393        if pd_params:
394            model_text +="\n"
395            model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)}
396
397        # Create place holder for Iqxy
398        model_text +="\n"
399        model_text +='#def Iqxy(%s):\n' % ', '.join(["x", "y"] + param_names)
400        model_text +='#    """Absolute scattering of oriented particles."""\n'
401        model_text +='#    ...\n'
402        model_text +='#    return oriented_form(x, y, args)\n'
403        model_text +='## uncomment the following if Iqxy works for vector x, y\n'
404        model_text +='#Iqxy.vectorized = True\n'
405
406        return model_text
407
408    @classmethod
409    def getParamHelper(cls, param_str):
410        """
411        yield a sequence of name, value pairs for the parameters in param_str
412
413        Parameters can be defined by one per line by name=value, or multiple
414        on the same line by separating the pairs by semicolon or comma.  The
415        value is optional and defaults to "1.0".
416        """
417        for line in param_str.replace(';', ',').split('\n'):
418            for item in line.split(','):
419                defn, desc = item.split('#', 1) if '#' in item else (item, '')
420                name, value = defn.split('=', 1) if '=' in defn else (defn, '1.0')
421                if name:
422                    yield [v.strip() for v in (name, value, desc)]
423
424    @classmethod
425    def strFromParamDict(cls, param_dict):
426        """
427        Creates string from parameter dictionary
428        {0: ('variable','value'),
429         1: ('variable','value'),
430         ...}
431        """
432        param_str = ""
433        for _, params in param_dict.items():
434            if not params[0]: continue
435            value = 1
436            if params[1]:
437                try:
438                    value = float(params[1])
439                except ValueError:
440                    # convert to default
441                    value = 1
442            param_str += params[0] + " = " + str(value) + "\n"
443        return param_str
444
445
446CUSTOM_TEMPLATE = '''\
447r"""
448Definition
449----------
450
451Calculates %(name)s.
452
453%(description)s
454
455References
456----------
457
458Authorship and Verification
459---------------------------
460
461* **Author:** --- **Date:** %(date)s
462* **Last Modified by:** --- **Date:** %(date)s
463* **Last Reviewed by:** --- **Date:** %(date)s
464"""
465
466from math import *
467from numpy import inf
468
469name = "%(name)s"
470title = "%(title)s"
471description = """%(description)s"""
472
473'''
474
475CUSTOM_TEMPLATE_PD = '''\
476def form_volume(%(args)s):
477    """
478    Volume of the particles used to compute absolute scattering intensity
479    and to weight polydisperse parameter contributions.
480    """
481    return 0.0
482
483def ER(%(args)s):
484    """
485    Effective radius 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 0.0
490
491def VR(%(args)s):
492    """
493    Volume ratio of particles to be used when computing structure factors.
494
495    Input parameters are vectors ranging over the mesh of polydispersity values.
496    """
497    return 1.0
498'''
499
500SUM_TEMPLATE = """
501from sasmodels.core import load_model_info
502from sasmodels.sasview_model import make_model_from_info
503
504model_info = load_model_info('{model1}{operator}{model2}')
505model_info.name = '{name}'{desc_line}
506Model = make_model_from_info(model_info)
507"""
508
509if __name__ == '__main__':
510    app = QtWidgets.QApplication(sys.argv)
511    sheet = TabbedModelEditor()
512    sheet.show()
513    app.exec_()
514   
Note: See TracBrowser for help on using the repository browser.