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

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 8b480d27 was 8b480d27, checked in by Piotr Rozyczko <rozyczko@…>, 5 years ago

Code cleanup and minor fixes

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