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

ESS_GUIESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_openclESS_GUI_sync_sascalc
Last change on this file since 14673d52 was 14673d52, checked in by wojciech, 5 years ago

Tabbed model editor update 2

  • Property mode set to 100644
File size: 20.0 KB
Line 
1# global
2import sys
3import os
4import datetime
5import logging
6import traceback
7
8from PyQt5 import QtWidgets
9
10from sas.sascalc.fit import models
11
12import sas.qtgui.Utilities.GuiUtils as GuiUtils
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__(parent._parent)
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        self.label = None
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.plugin_widget.modelModified.connect(self.editorModelModified)
79        self.editor_widget.modelModified.connect(self.editorModelModified)
80        self.plugin_widget.txtName.editingFinished.connect(self.pluginTitleSet)
81
82    def setPluginActive(self, is_active=True):
83        """
84        Enablement control for all the controls on the simple plugin editor
85        """
86        self.plugin_widget.setEnabled(is_active)
87
88    def saveClose(self):
89        """
90        Checking if files is subject of saving before cloasing or reloading or reloading model
91        """
92        saveCancelled = False
93        ret = self.onModifiedExit()
94        if ret == QtWidgets.QMessageBox.Cancel:
95            saveCancelled = True
96        elif ret == QtWidgets.QMessageBox.Save:
97            self.updateFromEditor()
98        return saveCancelled
99
100    def closeEvent(self, event):
101        """
102        Overwrite the close even to assure intent
103        """
104        if self.is_modified:
105            saveCancelled = self.saveClose()
106            if saveCancelled:
107                return
108        event.accept()
109
110    def onLoad(self):
111        """
112        Loads a model plugin file
113        """
114        if self.is_modified:
115            saveCancelled = self.saveClose()
116            if saveCancelled:
117                return
118            self.is_modified = False
119        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
120
121        plugin_location = models.find_plugins_dir()
122        filename = QtWidgets.QFileDialog.getOpenFileName(
123                                        self,
124                                        'Open Plugin',
125                                        plugin_location,
126                                        'SasView Plugin Model (*.py)',
127                                        None,
128                                        QtWidgets.QFileDialog.DontUseNativeDialog)[0]
129
130        # Load the file
131        if not filename:
132            logging.info("No data file chosen.")
133            return
134
135        # remove c-plugin tab, if present.
136        if self.tabWidget.count()>1:
137            self.tabWidget.removeTab(1)
138        self.loadFile(filename)
139
140    def loadFile(self, filename):
141        """
142        Performs the load operation and updates the view
143        """
144        self.editor_widget.blockSignals(True)
145        with open(filename, 'r') as plugin:
146            self.editor_widget.txtEditor.setPlainText(plugin.read())
147        self.editor_widget.setEnabled(True)
148        self.editor_widget.blockSignals(False)
149        self.filename = filename
150        display_name, _ = os.path.splitext(os.path.basename(filename))
151        self.setWindowTitle(self.window_title + " - " + display_name)
152        # Name the tab with .py filename
153        display_name = os.path.basename(filename)
154        self.tabWidget.setTabText(0, display_name)
155
156        # See if there is filename.c present
157        c_path = self.filename.replace(".py", ".c")
158        if not os.path.isfile(c_path): return
159        # add a tab with the same highlighting
160        display_name = os.path.basename(c_path)
161        self.c_editor_widget = ModelEditor(self, is_python=False)
162        self.tabWidget.addTab(self.c_editor_widget, display_name)
163        # Read in the file and set in on the widget
164        with open(c_path, 'r') as plugin:
165            self.c_editor_widget.txtEditor.setPlainText(plugin.read())
166        self.c_editor_widget.modelModified.connect(self.editorModelModified)
167
168    def onModifiedExit(self):
169        msg_box = QtWidgets.QMessageBox(self)
170        msg_box.setWindowTitle("SasView Model Editor")
171        msg_box.setText("The document has been modified.")
172        msg_box.setInformativeText("Do you want to save your changes?")
173        msg_box.setStandardButtons(QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel)
174        msg_box.setDefaultButton(QtWidgets.QMessageBox.Save)
175        return msg_box.exec()
176
177    def onCancel(self):
178        """
179        Accept if document not modified, confirm intent otherwise.
180        """
181        if self.is_modified:
182            saveCancelled = self.saveClose()
183            if saveCancelled:
184                return
185        self.reject()
186
187    def onApply(self):
188        """
189        Write the plugin and update the model editor if plugin editor open
190        Write/overwrite the plugin if model editor open
191        """
192        if isinstance(self.tabWidget.currentWidget(), PluginDefinition):
193            self.updateFromPlugin()
194        else:
195            self.updateFromEditor()
196        self.is_modified = False
197
198    def editorModelModified(self):
199        """
200        User modified the model in the Model Editor.
201        Disable the plugin editor and show that the model is changed.
202        """
203        self.setTabEdited(True)
204        self.plugin_widget.txtFunction.setStyleSheet("")
205        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
206        self.is_modified = True
207
208    def pluginTitleSet(self):
209        """
210        User modified the model name.
211        Display the model name in the window title
212        and allow for model save.
213        """
214        # Ensure plugin name is non-empty
215        model = self.getModel()
216        if 'filename' in model and model['filename']:
217            self.setWindowTitle(self.window_title + " - " + model['filename'])
218            self.setTabEdited(True)
219            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
220            self.is_modified = True
221        else:
222            # the model name is empty - disable Apply and clear the editor
223            self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
224            self.editor_widget.blockSignals(True)
225            self.editor_widget.txtEditor.setPlainText('')
226            self.editor_widget.blockSignals(False)
227            self.editor_widget.setEnabled(False)
228
229    def setTabEdited(self, is_edited):
230        """
231        Change the widget name to indicate unsaved state
232        Unsaved state: add "*" to filename display
233        saved state: remove "*" from filename display
234        """
235        current_text = self.windowTitle()
236
237        if is_edited:
238            if current_text[-1] != "*":
239                current_text += "*"
240        else:
241            if current_text[-1] == "*":
242                current_text = current_text[:-1]
243        self.setWindowTitle(current_text)
244
245    def updateFromPlugin(self):
246        """
247        Write the plugin and update the model editor
248        """
249        # get current model
250        model = self.getModel()
251        if 'filename' not in model: return
252
253        # get required filename
254        filename = model['filename']
255
256        # check if file exists
257        plugin_location = models.find_plugins_dir()
258        full_path = os.path.join(plugin_location, filename)
259        if os.path.splitext(full_path)[1] != ".py":
260            full_path += ".py"
261
262        # Update the global path definition
263        self.filename = full_path
264
265        if not self.canWriteModel(model, full_path):
266            return
267
268        # generate the model representation as string
269        model_str = self.generateModel(model, full_path)
270        self.writeFile(full_path, model_str)
271
272        # disable "Apply"
273        self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
274        # test the model
275
276        # Run the model test in sasmodels
277        if not self.isModelCorrect(full_path):
278            return
279
280        self.editor_widget.setEnabled(True)
281
282        # Update the editor here.
283        # Simple string forced into control.
284        self.editor_widget.blockSignals(True)
285        self.editor_widget.txtEditor.setPlainText(model_str)
286        self.editor_widget.blockSignals(False)
287
288        # Set the widget title
289        self.setTabEdited(False)
290
291        # Notify listeners
292        self.parent.communicate.customModelDirectoryChanged.emit()
293
294        # Notify the user
295        msg = "Custom model "+filename + " successfully created."
296        self.parent.communicate.statusBarUpdateSignal.emit(msg)
297        logging.info(msg)
298
299    def isModelCorrect(self, full_path):
300        """
301        Run the sasmodels method for model check
302        and return True if the model is good.
303        False otherwise.
304        """
305        successfulCheck = True
306        try:
307            model_results = GuiUtils.checkModel(full_path)
308            logging.info(model_results)
309        # We can't guarantee the type of the exception coming from
310        # Sasmodels, so need the overreaching general Exception
311        except Exception as ex:
312            msg = "Error building model: "+ str(ex)
313            logging.error(msg)
314            #print three last lines of the stack trace
315            # this will point out the exact line failing
316            last_lines = traceback.format_exc().split('\n')[-4:]
317            traceback_to_show = '\n'.join(last_lines)
318            logging.error(traceback_to_show)
319
320            # Set the status bar message
321            self.parent.communicate.statusBarUpdateSignal.emit("Model check failed")
322
323            # Remove the file so it is not being loaded on refresh
324            os.remove(full_path)
325            # Put a thick, red border around the mini-editor
326            self.plugin_widget.txtFunction.setStyleSheet("border: 5px solid red")
327            # Use the last line of the traceback for the tooltip
328            last_lines = traceback.format_exc().split('\n')[-2:]
329            traceback_to_show = '\n'.join(last_lines)
330            self.plugin_widget.txtFunction.setToolTip(traceback_to_show)
331            successfulCheck = False
332        return successfulCheck
333
334    def updateFromEditor(self):
335        """
336        Save the current state of the Model Editor
337        """
338        filename = self.filename
339        if not self.tabWidget.currentWidget().is_python:
340            base, _ = os.path.splitext(filename)
341            filename = base + '.c'
342
343        # make sure we have the file handle ready
344        assert(filename != "")
345        # Retrieve model string
346        model_str = self.getModel()['text']
347        # Save the file
348        self.writeFile(filename, model_str)
349        # Update the tab title
350        self.setTabEdited(False)
351        # notify the user
352        msg = filename + " successfully saved."
353        self.parent.communicate.statusBarUpdateSignal.emit(msg)
354        logging.info(msg)
355
356    def canWriteModel(self, model=None, full_path=""):
357        """
358        Determine if the current plugin can be written to file
359        """
360        assert(isinstance(model, dict))
361        assert(full_path!="")
362
363        # Make sure we can overwrite the file if it exists
364        if os.path.isfile(full_path):
365            # can we overwrite it?
366            if not model['overwrite']:
367                # notify the viewer
368                msg = "Plugin with specified name already exists.\n"
369                msg += "Please specify different filename or allow file overwrite."
370                QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
371                # Don't accept but return
372                return False
373        # Update model editor if plugin definition changed
374        func_str = model['text']
375        msg = None
376        if func_str:
377            if 'return' not in func_str:
378                msg = "Error: The func(x) must 'return' a value at least.\n"
379                msg += "For example: \n\nreturn 2*x"
380        else:
381            msg = 'Error: Function is not defined.'
382        if msg is not None:
383            QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
384            return False
385        return True
386
387    def onHelp(self):
388        """
389        Bring up the Model Editor Documentation whenever
390        the HELP button is clicked.
391        Calls Documentation Window with the path of the location within the
392        documentation tree (after /doc/ ....".
393        """
394        location = "/user/qtgui/Perspectives/Fitting/plugin.html"
395        self.parent.showHelp(location)
396
397    def getModel(self):
398        """
399        Retrieves plugin model from the currently open tab
400        """
401        return self.tabWidget.currentWidget().getModel()
402
403    @classmethod
404    def writeFile(cls, fname, model_str=""):
405        """
406        Write model content to file "fname"
407        """
408        with open(fname, 'w') as out_f:
409            out_f.write(model_str)
410
411    def generateModel(self, model, fname):
412        """
413        generate model from the current plugin state
414        """
415        name = model['filename']
416        desc_str = model['description']
417        param_str = self.strFromParamDict(model['parameters'])
418        pd_param_str = self.strFromParamDict(model['pd_parameters'])
419        func_str = model['text']
420        model_text = CUSTOM_TEMPLATE % {
421            'name': name,
422            'title': 'User model for ' + name,
423            'description': desc_str,
424            'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'),
425        }
426
427        # Write out parameters
428        param_names = []    # to store parameter names
429        pd_params = []
430        model_text += 'parameters = [ \n'
431        model_text += '#   ["name", "units", default, [lower, upper], "type", "description"],\n'
432        if param_str:
433            for pname, pvalue, desc in self.getParamHelper(param_str):
434                param_names.append(pname)
435                model_text += "    ['%s', '', %s, [-inf, inf], '', '%s'],\n" % (pname, pvalue, desc)
436        if pd_param_str:
437            for pname, pvalue, desc in self.getParamHelper(pd_param_str):
438                param_names.append(pname)
439                pd_params.append(pname)
440                model_text += "    ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc)
441        model_text += '    ]\n'
442
443        # Write out function definition
444        model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names)
445        model_text += '    """Absolute scattering"""\n'
446        if "scipy." in func_str:
447            model_text +="    import scipy\n"
448        if "numpy." in func_str:
449            model_text +="    import numpy\n"
450        if "np." in func_str:
451            model_text +="    import numpy as np\n"
452        for func_line in func_str.split('\n'):
453                model_text +='%s%s\n' % ("    ", func_line)
454        model_text +='## uncomment the following if Iq works for vector x\n'
455        model_text +='#Iq.vectorized = True\n'
456
457        # If polydisperse, create place holders for form_volume, ER and VR
458        if pd_params:
459            model_text +="\n"
460            model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)}
461
462        # Create place holder for Iqxy
463        model_text +="\n"
464        model_text +='#def Iqxy(%s):\n' % ', '.join(["x", "y"] + param_names)
465        model_text +='#    """Absolute scattering of oriented particles."""\n'
466        model_text +='#    ...\n'
467        model_text +='#    return oriented_form(x, y, args)\n'
468        model_text +='## uncomment the following if Iqxy works for vector x, y\n'
469        model_text +='#Iqxy.vectorized = True\n'
470
471        return model_text
472
473    @classmethod
474    def getParamHelper(cls, param_str):
475        """
476        yield a sequence of name, value pairs for the parameters in param_str
477
478        Parameters can be defined by one per line by name=value, or multiple
479        on the same line by separating the pairs by semicolon or comma.  The
480        value is optional and defaults to "1.0".
481        """
482        for line in param_str.replace(';', ',').split('\n'):
483            for item in line.split(','):
484                defn, desc = item.split('#', 1) if '#' in item else (item, '')
485                name, value = defn.split('=', 1) if '=' in defn else (defn, '1.0')
486                if name:
487                    yield [v.strip() for v in (name, value, desc)]
488
489    @classmethod
490    def strFromParamDict(cls, param_dict):
491        """
492        Creates string from parameter dictionary
493        {0: ('variable','value'),
494         1: ('variable','value'),
495         ...}
496        """
497        param_str = ""
498        for _, params in param_dict.items():
499            if not params[0]: continue
500            value = 1
501            if params[1]:
502                try:
503                    value = float(params[1])
504                except ValueError:
505                    # convert to default
506                    value = 1
507            param_str += params[0] + " = " + str(value) + "\n"
508        return param_str
509
510
511CUSTOM_TEMPLATE = '''\
512r"""
513Definition
514----------
515
516Calculates %(name)s.
517
518%(description)s
519
520References
521----------
522
523Authorship and Verification
524---------------------------
525
526* **Author:** --- **Date:** %(date)s
527* **Last Modified by:** --- **Date:** %(date)s
528* **Last Reviewed by:** --- **Date:** %(date)s
529"""
530
531from math import *
532from numpy import inf
533
534name = "%(name)s"
535title = "%(title)s"
536description = """%(description)s"""
537
538'''
539
540CUSTOM_TEMPLATE_PD = '''\
541def form_volume(%(args)s):
542    """
543    Volume of the particles used to compute absolute scattering intensity
544    and to weight polydisperse parameter contributions.
545    """
546    return 0.0
547
548def ER(%(args)s):
549    """
550    Effective radius of particles to be used when computing structure factors.
551
552    Input parameters are vectors ranging over the mesh of polydispersity values.
553    """
554    return 0.0
555
556def VR(%(args)s):
557    """
558    Volume ratio of particles to be used when computing structure factors.
559
560    Input parameters are vectors ranging over the mesh of polydispersity values.
561    """
562    return 1.0
563'''
564
565SUM_TEMPLATE = """
566from sasmodels.core import load_model_info
567from sasmodels.sasview_model import make_model_from_info
568
569model_info = load_model_info('{model1}{operator}{model2}')
570model_info.name = '{name}'{desc_line}
571Model = make_model_from_info(model_info)
572"""
573
574if __name__ == '__main__':
575    app = QtWidgets.QApplication(sys.argv)
576    sheet = TabbedModelEditor()
577    sheet.show()
578    app.exec_()
579   
Note: See TracBrowser for help on using the repository browser.