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

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

Saving current tab to the file in the model editor

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