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

Last change on this file since 0101c9f was c5e0d84, checked in by Piotr Rozyczko <rozyczko@…>, 6 years ago

Minor modifications in response to requests during demo session

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