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

Last change on this file since 6c7ebb88 was 33c0561, checked in by Piotr Rozyczko <piotr.rozyczko@…>, 6 years ago

Replace Apply button menu driven functionality with additional button.
Removed Cancel.
Removed the window system context help button from all affected widgets.
SASVIEW-1239

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