# global
import sys
import os
import datetime
import logging
import traceback
from PyQt5 import QtWidgets, QtCore
from sas.sascalc.fit import models
import sas.qtgui.Utilities.GuiUtils as GuiUtils
from sas.qtgui.Utilities.UI.TabbedModelEditor import Ui_TabbedModelEditor
from sas.qtgui.Utilities.PluginDefinition import PluginDefinition
from sas.qtgui.Utilities.ModelEditor import ModelEditor
[docs]class TabbedModelEditor(QtWidgets.QDialog, Ui_TabbedModelEditor):
"""
Model editor "container" class describing interaction between
plugin definition widget and model editor widget.
Once the model is defined, it can be saved as a plugin.
"""
# Signals for intertab communication plugin -> editor
def __init__(self, parent=None, edit_only=False):
super(TabbedModelEditor, self).__init__(parent._parent)
self.parent = parent
self.setupUi(self)
# disable the context help icon
self.setWindowFlags(self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint)
# globals
self.filename = ""
self.window_title = self.windowTitle()
self.edit_only = edit_only
self.is_modified = False
self.label = None
self.addWidgets()
self.addSignals()
[docs] def addSignals(self):
"""
Define slots for common widget signals
"""
# buttons
self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).clicked.connect(self.onApply)
self.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel).clicked.connect(self.onCancel)
self.buttonBox.button(QtWidgets.QDialogButtonBox.Help).clicked.connect(self.onHelp)
self.cmdLoad.clicked.connect(self.onLoad)
# signals from tabs
self.plugin_widget.modelModified.connect(self.editorModelModified)
self.editor_widget.modelModified.connect(self.editorModelModified)
self.plugin_widget.txtName.editingFinished.connect(self.pluginTitleSet)
[docs] def setPluginActive(self, is_active=True):
"""
Enablement control for all the controls on the simple plugin editor
"""
self.plugin_widget.setEnabled(is_active)
[docs] def saveClose(self):
"""
Check if file needs saving before closing or model reloading
"""
saveCancelled = False
ret = self.onModifiedExit()
if ret == QtWidgets.QMessageBox.Cancel:
saveCancelled = True
elif ret == QtWidgets.QMessageBox.Save:
self.updateFromEditor()
return saveCancelled
[docs] def closeEvent(self, event):
"""
Overwrite the close even to assure intent
"""
if self.is_modified:
saveCancelled = self.saveClose()
if saveCancelled:
return
event.accept()
[docs] def onLoad(self):
"""
Loads a model plugin file
"""
if self.is_modified:
saveCancelled = self.saveClose()
if saveCancelled:
return
self.is_modified = False
self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
plugin_location = models.find_plugins_dir()
filename = QtWidgets.QFileDialog.getOpenFileName(
self,
'Open Plugin',
plugin_location,
'SasView Plugin Model (*.py)',
None,
QtWidgets.QFileDialog.DontUseNativeDialog)[0]
# Load the file
if not filename:
logging.info("No data file chosen.")
return
# remove c-plugin tab, if present.
if self.tabWidget.count()>1:
self.tabWidget.removeTab(1)
self.loadFile(filename)
[docs] def loadFile(self, filename):
"""
Performs the load operation and updates the view
"""
self.editor_widget.blockSignals(True)
with open(filename, 'r', encoding="utf-8") as plugin:
self.editor_widget.txtEditor.setPlainText(plugin.read())
self.editor_widget.setEnabled(True)
self.editor_widget.blockSignals(False)
self.filename = filename
display_name, _ = os.path.splitext(os.path.basename(filename))
self.setWindowTitle(self.window_title + " - " + display_name)
# Name the tab with .py filename
display_name = os.path.basename(filename)
self.tabWidget.setTabText(0, display_name)
# See if there is filename.c present
c_path = self.filename.replace(".py", ".c")
if not os.path.isfile(c_path): return
# add a tab with the same highlighting
display_name = os.path.basename(c_path)
self.c_editor_widget = ModelEditor(self, is_python=False)
self.tabWidget.addTab(self.c_editor_widget, display_name)
# Read in the file and set in on the widget
with open(c_path, 'r', encoding="utf-8") as plugin:
self.c_editor_widget.txtEditor.setPlainText(plugin.read())
self.c_editor_widget.modelModified.connect(self.editorModelModified)
[docs] def onModifiedExit(self):
msg_box = QtWidgets.QMessageBox(self)
msg_box.setWindowTitle("SasView Model Editor")
msg_box.setText("The document has been modified.")
msg_box.setInformativeText("Do you want to save your changes?")
msg_box.setStandardButtons(QtWidgets.QMessageBox.Save | QtWidgets.QMessageBox.Discard | QtWidgets.QMessageBox.Cancel)
msg_box.setDefaultButton(QtWidgets.QMessageBox.Save)
return msg_box.exec()
[docs] def onCancel(self):
"""
Accept if document not modified, confirm intent otherwise.
"""
if self.is_modified:
saveCancelled = self.saveClose()
if saveCancelled:
return
self.reject()
[docs] def onApply(self):
"""
Write the plugin and update the model editor if plugin editor open
Write/overwrite the plugin if model editor open
"""
if isinstance(self.tabWidget.currentWidget(), PluginDefinition):
self.updateFromPlugin()
else:
self.updateFromEditor()
self.is_modified = False
[docs] def editorModelModified(self):
"""
User modified the model in the Model Editor.
Disable the plugin editor and show that the model is changed.
"""
self.setTabEdited(True)
self.plugin_widget.txtFunction.setStyleSheet("")
self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
self.is_modified = True
[docs] def pluginTitleSet(self):
"""
User modified the model name.
Display the model name in the window title
and allow for model save.
"""
# Ensure plugin name is non-empty
model = self.getModel()
if 'filename' in model and model['filename']:
self.setWindowTitle(self.window_title + " - " + model['filename'])
self.setTabEdited(True)
self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True)
self.is_modified = True
else:
# the model name is empty - disable Apply and clear the editor
self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
self.editor_widget.blockSignals(True)
self.editor_widget.txtEditor.setPlainText('')
self.editor_widget.blockSignals(False)
self.editor_widget.setEnabled(False)
[docs] def setTabEdited(self, is_edited):
"""
Change the widget name to indicate unsaved state
Unsaved state: add "*" to filename display
saved state: remove "*" from filename display
"""
current_text = self.windowTitle()
if is_edited:
if current_text[-1] != "*":
current_text += "*"
else:
if current_text[-1] == "*":
current_text = current_text[:-1]
self.setWindowTitle(current_text)
[docs] def updateFromPlugin(self):
"""
Write the plugin and update the model editor
"""
# get current model
model = self.getModel()
if 'filename' not in model: return
# get required filename
filename = model['filename']
# check if file exists
plugin_location = models.find_plugins_dir()
full_path = os.path.join(plugin_location, filename)
if os.path.splitext(full_path)[1] != ".py":
full_path += ".py"
# Update the global path definition
self.filename = full_path
if not self.canWriteModel(model, full_path):
return
# generate the model representation as string
model_str = self.generateModel(model, full_path)
self.writeFile(full_path, model_str)
# disable "Apply"
self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False)
# test the model
# Run the model test in sasmodels
if not self.isModelCorrect(full_path):
return
self.editor_widget.setEnabled(True)
# Update the editor here.
# Simple string forced into control.
self.editor_widget.blockSignals(True)
self.editor_widget.txtEditor.setPlainText(model_str)
self.editor_widget.blockSignals(False)
# Set the widget title
self.setTabEdited(False)
# Notify listeners
self.parent.communicate.customModelDirectoryChanged.emit()
# Notify the user
msg = "Custom model "+filename + " successfully created."
self.parent.communicate.statusBarUpdateSignal.emit(msg)
logging.info(msg)
[docs] def isModelCorrect(self, full_path):
"""
Run the sasmodels method for model check
and return True if the model is good.
False otherwise.
"""
successfulCheck = True
try:
model_results = GuiUtils.checkModel(full_path)
logging.info(model_results)
# We can't guarantee the type of the exception coming from
# Sasmodels, so need the overreaching general Exception
except Exception as ex:
msg = "Error building model: "+ str(ex)
logging.error(msg)
#print three last lines of the stack trace
# this will point out the exact line failing
last_lines = traceback.format_exc().split('\n')[-4:]
traceback_to_show = '\n'.join(last_lines)
logging.error(traceback_to_show)
# Set the status bar message
self.parent.communicate.statusBarUpdateSignal.emit("Model check failed")
# Remove the file so it is not being loaded on refresh
os.remove(full_path)
# Put a thick, red border around the mini-editor
self.plugin_widget.txtFunction.setStyleSheet("border: 5px solid red")
# Use the last line of the traceback for the tooltip
last_lines = traceback.format_exc().split('\n')[-2:]
traceback_to_show = '\n'.join(last_lines)
self.plugin_widget.txtFunction.setToolTip(traceback_to_show)
successfulCheck = False
return successfulCheck
[docs] def updateFromEditor(self):
"""
Save the current state of the Model Editor
"""
filename = self.filename
if not self.tabWidget.currentWidget().is_python:
base, _ = os.path.splitext(filename)
filename = base + '.c'
# make sure we have the file handle ready
assert(filename != "")
# Retrieve model string
model_str = self.getModel()['text']
# Save the file
self.writeFile(filename, model_str)
# Update the tab title
self.setTabEdited(False)
# Notify listeners, since the plugin name might have changed
self.parent.communicate.customModelDirectoryChanged.emit()
# notify the user
msg = filename + " successfully saved."
self.parent.communicate.statusBarUpdateSignal.emit(msg)
logging.info(msg)
[docs] def canWriteModel(self, model=None, full_path=""):
"""
Determine if the current plugin can be written to file
"""
assert(isinstance(model, dict))
assert(full_path!="")
# Make sure we can overwrite the file if it exists
if os.path.isfile(full_path):
# can we overwrite it?
if not model['overwrite']:
# notify the viewer
msg = "Plugin with specified name already exists.\n"
msg += "Please specify different filename or allow file overwrite."
QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
# Don't accept but return
return False
# Update model editor if plugin definition changed
func_str = model['text']
msg = None
if func_str:
if 'return' not in func_str:
msg = "Error: The func(x) must 'return' a value at least.\n"
msg += "For example: \n\nreturn 2*x"
else:
msg = 'Error: Function is not defined.'
if msg is not None:
QtWidgets.QMessageBox.critical(self, "Plugin Error", msg)
return False
return True
[docs] def onHelp(self):
"""
Bring up the Model Editor Documentation whenever
the HELP button is clicked.
Calls Documentation Window with the path of the location within the
documentation tree (after /doc/ ....".
"""
location = "/user/qtgui/Perspectives/Fitting/plugin.html"
self.parent.showHelp(location)
[docs] def getModel(self):
"""
Retrieves plugin model from the currently open tab
"""
return self.tabWidget.currentWidget().getModel()
[docs] @classmethod
def writeFile(cls, fname, model_str=""):
"""
Write model content to file "fname"
"""
with open(fname, 'w', encoding="utf-8") as out_f:
out_f.write(model_str)
[docs] def generateModel(self, model, fname):
"""
generate model from the current plugin state
"""
name = model['filename']
if not name:
model['filename'] = fname
name = fname
desc_str = model['description']
param_str = self.strFromParamDict(model['parameters'])
pd_param_str = self.strFromParamDict(model['pd_parameters'])
func_str = model['text']
model_text = CUSTOM_TEMPLATE % {
'name': name,
'title': 'User model for ' + name,
'description': desc_str,
'date': datetime.datetime.now().strftime('%YYYY-%mm-%dd'),
}
# Write out parameters
param_names = [] # to store parameter names
pd_params = []
model_text += 'parameters = [ \n'
model_text += '# ["name", "units", default, [lower, upper], "type", "description"],\n'
if param_str:
for pname, pvalue, desc in self.getParamHelper(param_str):
param_names.append(pname)
model_text += " ['%s', '', %s, [-inf, inf], '', '%s'],\n" % (pname, pvalue, desc)
if pd_param_str:
for pname, pvalue, desc in self.getParamHelper(pd_param_str):
param_names.append(pname)
pd_params.append(pname)
model_text += " ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc)
model_text += ' ]\n'
# Write out function definition
model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names)
model_text += ' """Absolute scattering"""\n'
if "scipy." in func_str:
model_text +=" import scipy\n"
if "numpy." in func_str:
model_text +=" import numpy\n"
if "np." in func_str:
model_text +=" import numpy as np\n"
for func_line in func_str.split('\n'):
model_text +='%s%s\n' % (" ", func_line)
model_text +='## uncomment the following if Iq works for vector x\n'
model_text +='#Iq.vectorized = True\n'
# If polydisperse, create place holders for form_volume, ER and VR
if pd_params:
model_text +="\n"
model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)}
# Create place holder for Iqxy
model_text +="\n"
model_text +='#def Iqxy(%s):\n' % ', '.join(["x", "y"] + param_names)
model_text +='# """Absolute scattering of oriented particles."""\n'
model_text +='# ...\n'
model_text +='# return oriented_form(x, y, args)\n'
model_text +='## uncomment the following if Iqxy works for vector x, y\n'
model_text +='#Iqxy.vectorized = True\n'
return model_text
[docs] @classmethod
def getParamHelper(cls, param_str):
"""
yield a sequence of name, value pairs for the parameters in param_str
Parameters can be defined by one per line by name=value, or multiple
on the same line by separating the pairs by semicolon or comma. The
value is optional and defaults to "1.0".
"""
for line in param_str.replace(';', ',').split('\n'):
for item in line.split(','):
defn, desc = item.split('#', 1) if '#' in item else (item, '')
name, value = defn.split('=', 1) if '=' in defn else (defn, '1.0')
if name:
yield [v.strip() for v in (name, value, desc)]
[docs] @classmethod
def strFromParamDict(cls, param_dict):
"""
Creates string from parameter dictionary
Example::
{
0: ('variable','value'),
1: ('variable','value'),
...
}
"""
param_str = ""
for _, params in param_dict.items():
if not params[0]: continue
value = 1
if params[1]:
try:
value = float(params[1])
except ValueError:
# convert to default
value = 1
param_str += params[0] + " = " + str(value) + "\n"
return param_str
CUSTOM_TEMPLATE = '''\
r"""
Definition
----------
Calculates %(name)s.
%(description)s
References
----------
Authorship and Verification
---------------------------
* **Author:** --- **Date:** %(date)s
* **Last Modified by:** --- **Date:** %(date)s
* **Last Reviewed by:** --- **Date:** %(date)s
"""
from sasmodels.special import *
from numpy import inf
name = "%(name)s"
title = "%(title)s"
description = """%(description)s"""
'''
CUSTOM_TEMPLATE_PD = '''\
def form_volume(%(args)s):
"""
Volume of the particles used to compute absolute scattering intensity
and to weight polydisperse parameter contributions.
"""
return 0.0
def ER(%(args)s):
"""
Effective radius of particles to be used when computing structure factors.
Input parameters are vectors ranging over the mesh of polydispersity values.
"""
return 0.0
def VR(%(args)s):
"""
Volume ratio of particles to be used when computing structure factors.
Input parameters are vectors ranging over the mesh of polydispersity values.
"""
return 1.0
'''
SUM_TEMPLATE = """
from sasmodels.core import load_model_info
from sasmodels.sasview_model import make_model_from_info
model_info = load_model_info('{model1}{operator}{model2}')
model_info.name = '{name}'{desc_line}
Model = make_model_from_info(model_info)
"""
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
sheet = TabbedModelEditor()
sheet.show()
app.exec_()