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

ESS_GUIESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since f2e199e was f2e199e, checked in by Piotr Rozyczko <piotr.rozyczko@…>, 5 years ago

Allow editing models with corresponding C-files. SASVIEW-1208

  • Property mode set to 100644
File size: 19.4 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        # make sure we have the file handly ready
325        assert(self.filename != "")
326        # Retrieve model string
327        model_str = self.getModel()['text']
328        # Save the file
329        self.writeFile(self.filename, model_str)
330        # Update the tab title
331        self.setTabEdited(False)
332        # notify the user
333        msg = self.filename + " successfully saved."
334        self.parent.communicate.statusBarUpdateSignal.emit(msg)
335        logging.info(msg)
336
337    def canWriteModel(self, model=None, full_path=""):
338        """
339        Determine if the current plugin can be written to file
340        """
341        assert(isinstance(model, dict))
342        assert(full_path!="")
343
344        # Make sure we can overwrite the file if it exists
345        if os.path.isfile(full_path):
346            # can we overwrite it?
347            if not model['overwrite']:
348                # notify the viewer
349                msg = "Plugin with specified name already exists.\n"
350                msg += "Please specify different filename or allow file overwrite."
351                QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
352                # Don't accept but return
353                return False
354        # Update model editor if plugin definition changed
355        func_str = model['text']
356        msg = None
357        if func_str:
358            if 'return' not in func_str:
359                msg = "Error: The func(x) must 'return' a value at least.\n"
360                msg += "For example: \n\nreturn 2*x"
361        else:
362            msg = 'Error: Function is not defined.'
363        if msg is not None:
364            QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
365            return False
366        return True
367
368    def onHelp(self):
369        """
370        Bring up the Model Editor Documentation whenever
371        the HELP button is clicked.
372        Calls Documentation Window with the path of the location within the
373        documentation tree (after /doc/ ....".
374        """
375        location = "/user/qtgui/Perspectives/Fitting/plugin.html"
376        self.parent.showHelp(location)
377
378    def getModel(self):
379        """
380        Retrieves plugin model from the currently open tab
381        """
382        return self.tabWidget.currentWidget().getModel()
383
384    @classmethod
385    def writeFile(cls, fname, model_str=""):
386        """
387        Write model content to file "fname"
388        """
389        with open(fname, 'w') as out_f:
390            out_f.write(model_str)
391
392    def generateModel(self, model, fname):
393        """
394        generate model from the current plugin state
395        """
396        name = model['filename']
397        desc_str = model['description']
398        param_str = self.strFromParamDict(model['parameters'])
399        pd_param_str = self.strFromParamDict(model['pd_parameters'])
400        func_str = model['text']
401        model_text = CUSTOM_TEMPLATE % {
402            'name': name,
403            'title': 'User model for ' + name,
404            'description': desc_str,
405            'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'),
406        }
407
408        # Write out parameters
409        param_names = []    # to store parameter names
410        pd_params = []
411        model_text += 'parameters = [ \n'
412        model_text += '#   ["name", "units", default, [lower, upper], "type", "description"],\n'
413        if param_str:
414            for pname, pvalue, desc in self.getParamHelper(param_str):
415                param_names.append(pname)
416                model_text += "    ['%s', '', %s, [-inf, inf], '', '%s'],\n" % (pname, pvalue, desc)
417        if pd_param_str:
418            for pname, pvalue, desc in self.getParamHelper(pd_param_str):
419                param_names.append(pname)
420                pd_params.append(pname)
421                model_text += "    ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc)
422        model_text += '    ]\n'
423
424        # Write out function definition
425        model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names)
426        model_text += '    """Absolute scattering"""\n'
427        if "scipy." in func_str:
428            model_text +="    import scipy\n"
429        if "numpy." in func_str:
430            model_text +="    import numpy\n"
431        if "np." in func_str:
432            model_text +="    import numpy as np\n"
433        for func_line in func_str.split('\n'):
434                model_text +='%s%s\n' % ("    ", func_line)
435        model_text +='## uncomment the following if Iq works for vector x\n'
436        model_text +='#Iq.vectorized = True\n'
437
438        # If polydisperse, create place holders for form_volume, ER and VR
439        if pd_params:
440            model_text +="\n"
441            model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)}
442
443        # Create place holder for Iqxy
444        model_text +="\n"
445        model_text +='#def Iqxy(%s):\n' % ', '.join(["x", "y"] + param_names)
446        model_text +='#    """Absolute scattering of oriented particles."""\n'
447        model_text +='#    ...\n'
448        model_text +='#    return oriented_form(x, y, args)\n'
449        model_text +='## uncomment the following if Iqxy works for vector x, y\n'
450        model_text +='#Iqxy.vectorized = True\n'
451
452        return model_text
453
454    @classmethod
455    def getParamHelper(cls, param_str):
456        """
457        yield a sequence of name, value pairs for the parameters in param_str
458
459        Parameters can be defined by one per line by name=value, or multiple
460        on the same line by separating the pairs by semicolon or comma.  The
461        value is optional and defaults to "1.0".
462        """
463        for line in param_str.replace(';', ',').split('\n'):
464            for item in line.split(','):
465                defn, desc = item.split('#', 1) if '#' in item else (item, '')
466                name, value = defn.split('=', 1) if '=' in defn else (defn, '1.0')
467                if name:
468                    yield [v.strip() for v in (name, value, desc)]
469
470    @classmethod
471    def strFromParamDict(cls, param_dict):
472        """
473        Creates string from parameter dictionary
474        {0: ('variable','value'),
475         1: ('variable','value'),
476         ...}
477        """
478        param_str = ""
479        for _, params in param_dict.items():
480            if not params[0]: continue
481            value = 1
482            if params[1]:
483                try:
484                    value = float(params[1])
485                except ValueError:
486                    # convert to default
487                    value = 1
488            param_str += params[0] + " = " + str(value) + "\n"
489        return param_str
490
491
492CUSTOM_TEMPLATE = '''\
493r"""
494Definition
495----------
496
497Calculates %(name)s.
498
499%(description)s
500
501References
502----------
503
504Authorship and Verification
505---------------------------
506
507* **Author:** --- **Date:** %(date)s
508* **Last Modified by:** --- **Date:** %(date)s
509* **Last Reviewed by:** --- **Date:** %(date)s
510"""
511
512from math import *
513from numpy import inf
514
515name = "%(name)s"
516title = "%(title)s"
517description = """%(description)s"""
518
519'''
520
521CUSTOM_TEMPLATE_PD = '''\
522def form_volume(%(args)s):
523    """
524    Volume of the particles used to compute absolute scattering intensity
525    and to weight polydisperse parameter contributions.
526    """
527    return 0.0
528
529def ER(%(args)s):
530    """
531    Effective radius of particles to be used when computing structure factors.
532
533    Input parameters are vectors ranging over the mesh of polydispersity values.
534    """
535    return 0.0
536
537def VR(%(args)s):
538    """
539    Volume ratio of particles to be used when computing structure factors.
540
541    Input parameters are vectors ranging over the mesh of polydispersity values.
542    """
543    return 1.0
544'''
545
546SUM_TEMPLATE = """
547from sasmodels.core import load_model_info
548from sasmodels.sasview_model import make_model_from_info
549
550model_info = load_model_info('{model1}{operator}{model2}')
551model_info.name = '{name}'{desc_line}
552Model = make_model_from_info(model_info)
553"""
554
555if __name__ == '__main__':
556    app = QtWidgets.QApplication(sys.argv)
557    sheet = TabbedModelEditor()
558    sheet.show()
559    app.exec_()
560   
Note: See TracBrowser for help on using the repository browser.