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

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

Code review fix - incorrect file save path in the Editor

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