diff --git a/src/sas/qtgui/Utilities/PluginDefinition.py b/src/sas/qtgui/Utilities/PluginDefinition.py index fb52ff3432..908dc4798c 100644 --- a/src/sas/qtgui/Utilities/PluginDefinition.py +++ b/src/sas/qtgui/Utilities/PluginDefinition.py @@ -1,9 +1,13 @@ +import os.path +import logging + from PySide6 import QtCore from PySide6 import QtGui from PySide6 import QtWidgets from sas.qtgui.Utilities.UI.PluginDefinitionUI import Ui_PluginDefinition from sas.qtgui.Utilities import GuiUtils +from sas.sascalc.fit.models import find_plugins_dir # txtName # txtDescription @@ -19,16 +23,28 @@ class PluginDefinition(QtWidgets.QDialog, Ui_PluginDefinition): model form and parameters. """ modelModified = QtCore.Signal() + omitPolydisperseFuncsSignal = QtCore.Signal() + includePolydisperseFuncsSignal = QtCore.Signal() + enablePyCheckboxSignal = QtCore.Signal() + sendNewParamSignal = QtCore.Signal(list) + sendNewDescriptionSignal = QtCore.Signal(str) + sendNewIqSignal = QtCore.Signal(str) + sendNewFormVolumeSignal = QtCore.Signal(str) + def __init__(self, parent=None): super(PluginDefinition, self).__init__(parent) + self.setupUi(self) + self.infoLabel.setVisible(False) + # globals self.initializeModel() # internal representation of the parameter list # {: (, )} self.parameter_dict = {} self.pd_parameter_dict = {} + self.displayed_default_form_volume = False # Initialize widgets self.addWidgets() @@ -39,33 +55,43 @@ def __init__(self, parent=None): # Initialize signals self.addSignals() - def addTooltip(self): + def addTooltips(self): """ - Add the default tooltip to the text field + Add the default tooltips to the Iq and form_volume function text boxes """ - hint_function = "#Example:\n\n" - hint_function += "if x <= 0:\n" - hint_function += " y = A + B\n" + hint_function = "This function returns the scattering intensity for a given q.\n" + hint_function += "Example:\n\n" + hint_function += "if q <= 0:\n" + hint_function += " intensity = A + B\n" hint_function += "else:\n" - hint_function += " y = A + B * cos(2 * pi * x)\n" - hint_function += "return y\n" + hint_function += " intensity = A + B * cos(2 * pi * q)\n" + hint_function += "return intensity\n" self.txtFunction.setToolTip(hint_function) + hint_function = "This function returns the volume of the particle.\n" + hint_function += "Example:\n\n" + hint_function += "volume = (4 / 3) * pi * R**3\n" + hint_function += "return volume\n" + self.txtFormVolumeFunction.setToolTip(hint_function) + def addWidgets(self): """ Initialize various widgets in the dialog """ - self.addTooltip() + self.addTooltips() # Initial text in the function table text = \ -"""y = x +"""intensity = q -return y +return intensity """ + self.model['func_text'] = text self.txtFunction.insertPlainText(text) self.txtFunction.setFont(GuiUtils.getMonospaceFont()) + self.txtFormVolumeFunction.setFont(GuiUtils.getMonospaceFont()) + # Validators rx = QtCore.QRegularExpression("^[A-Za-z0-9_]*$") @@ -75,7 +101,8 @@ def addWidgets(self): # importing QSyntaxHighlighter # DO NOT MOVE TO TOP from sas.qtgui.Utilities.PythonSyntax import PythonHighlighter - self.highlight = PythonHighlighter(self.txtFunction.document()) + self.highlightFunction = PythonHighlighter(self.txtFunction.document()) + self.highlightFormVolumeFunction = PythonHighlighter(self.txtFormVolumeFunction.document()) def initializeModel(self): """ @@ -85,29 +112,50 @@ def initializeModel(self): self.model = { 'filename':'', 'overwrite':False, + 'gen_python':True, + 'gen_c':False, 'description':'', 'parameters':{}, 'pd_parameters':{}, - 'text':''} + 'func_text':'', + 'form_volume_text':'' + } def addSignals(self): """ Define slots for widget signals """ - self.txtName.editingFinished.connect(self.onPluginNameChanged) - self.txtDescription.editingFinished.connect(self.onDescriptionChanged) + self.txtName.textChanged.connect(self.onPluginNameChanged) + self.txtDescription.textChanged.connect(self.onDescriptionChanged) self.tblParams.cellChanged.connect(self.onParamsChanged) self.tblParamsPD.cellChanged.connect(self.onParamsPDChanged) # QTextEdit doesn't have a signal for edit finish, so we respond to text changed. # Possibly a slight overkill. self.txtFunction.textChanged.connect(self.onFunctionChanged) + self.txtFormVolumeFunction.textChanged.connect(self.onFormVolumeFunctionChanged) self.chkOverwrite.toggled.connect(self.onOverwrite) + self.chkGenPython.toggled.connect(self.onGenPython) + self.chkGenC.toggled.connect(self.onGenC) + self.enablePyCheckboxSignal.connect(lambda: self.checkPyModelExists(self.model['filename'])) + self.sendNewParamSignal.connect(self.updateParamTableFromEditor) + self.sendNewDescriptionSignal.connect(lambda description: self.txtDescription.setText(description)) + self.sendNewIqSignal.connect(lambda iq: self.txtFunction.setPlainText(iq)) + self.sendNewFormVolumeSignal.connect(lambda form_volume: self.txtFormVolumeFunction.setPlainText(form_volume)) + + #Boolean flags + self.chkSingle.clicked.connect(self.modelModified.emit) + self.chkOpenCL.clicked.connect(self.modelModified.emit) + self.chkStructure.clicked.connect(self.modelModified.emit) + self.chkFQ.clicked.connect(self.modelModified.emit) def onPluginNameChanged(self): """ Respond to changes in plugin name """ self.model['filename'] = self.txtName.text() + + self.checkPyModelExists(self.model['filename']) + self.modelModified.emit() def onDescriptionChanged(self): @@ -139,7 +187,7 @@ def onParamsChanged(self, row, column): def onParamsPDChanged(self, row, column): """ - Respond to changes in non-polydisperse parameter table + Respond to changes in polydisperse parameter table """ param = value = None if self.tblParamsPD.item(row, 0): @@ -152,9 +200,40 @@ def onParamsPDChanged(self, row, column): self.model['pd_parameters'] = self.pd_parameter_dict # Check if the update was Value for last row. If so, add a new row - if column == 1 and row == self.tblParamsPD.rowCount()-1: + if column == 1 and row == self.tblParamsPD.rowCount() - 1: # Add a row self.tblParamsPD.insertRow(self.tblParamsPD.rowCount()) + + # Check to see if there is any polydisperse parameter text present + any_text_present = False + for row in range(self.tblParamsPD.rowCount()): + for column in range(self.tblParamsPD.columnCount()): + table_cell_contents = self.tblParamsPD.item(row, column) + if table_cell_contents and table_cell_contents.text(): + # There is text in at least one cell + any_text_present = True + break + if any_text_present: + # Display the Form Function box because there are polydisperse parameters + # First insert the first user-specified parameter into sample form volume function + if not self.displayed_default_form_volume: + text = \ +"""volume = {0} * 0.0 + +return volume +""".format(self.model['pd_parameters'][0][0]) + self.model['form_volume_text'] = text + self.txtFormVolumeFunction.insertPlainText(text) + self.displayed_default_form_volume = True + + self.formFunctionBox.setVisible(True) + self.includePolydisperseFuncsSignal.emit() + break + else: + # Hide the Form Function box because there are no polydisperse parameters + self.formFunctionBox.setVisible(False) + self.omitPolydisperseFuncsSignal.emit() + self.modelModified.emit() @@ -166,8 +245,20 @@ def onFunctionChanged(self): # mind the performance! #self.addTooltip() new_text = self.txtFunction.toPlainText().lstrip().rstrip() - if new_text != self.model['text']: - self.model['text'] = new_text + if new_text != self.model['func_text']: + self.model['func_text'] = new_text + self.modelModified.emit() + + def onFormVolumeFunctionChanged(self): + """ + Respond to changes in form volume function body + """ + # keep in mind that this is called every time the text changes. + # mind the performance! + #self.addTooltip() + new_text = self.txtFormVolumeFunction.toPlainText().lstrip().rstrip() + if new_text != self.model['form_volume_text']: + self.model['form_volume_text'] = new_text self.modelModified.emit() def onOverwrite(self): @@ -176,6 +267,65 @@ def onOverwrite(self): """ self.model['overwrite'] = self.chkOverwrite.isChecked() self.modelModified.emit() + + def onGenPython(self): + """ + Respond to change in generate Python checkbox + """ + self.model['gen_python'] = self.chkGenPython.isChecked() + self.modelModified.emit() + + def onGenC(self): + """ + Respond to change in generate C checkbox + """ + self.model['gen_c'] = self.chkGenC.isChecked() + self.modelModified.emit() + + def checkPyModelExists(self, filename): + """ + Checks if a Python model exists in the user plugin directory and forces enabling Python checkbox if not + :param filename: name of the file (without extension) + """ + if not os.path.exists(os.path.join(find_plugins_dir(), filename + '.py')): + # If the user has not yet created a Python file for a specific filename, then force them to create one + self.chkGenPython.setChecked(True) + self.chkGenPython.setEnabled(False) + self.infoLabel.setText("No Python model of the same name detected. Generating Python model is required.") + self.infoLabel.setVisible(True) + else: + self.infoLabel.setVisible(False) + self.chkGenPython.setEnabled(True) + return os.path.exists(os.path.join(find_plugins_dir(), filename + '.py')) + + def updateParamTableFromEditor(self, param_list): + """ + Add parameters sent from model editor to the parameter tables + :param param_list: list of parameters to add to the parameter tables [name, default_value, type] + """ + updated_params_non_pd = [param for param in param_list if param[2] != 'volume'] + updated_params_pd = [param for param in param_list if param[2] == 'volume'] + + # Prepare the table for updating + self.tblParams.blockSignals(True) + self.tblParamsPD.blockSignals(True) + self.tblParams.clearContents() + self.tblParamsPD.clearContents() + self.tblParams.setRowCount(len(updated_params_non_pd) + 1) + self.tblParamsPD.setRowCount(len(updated_params_pd) + 1) + + # Iterate over cells and add the new parameters to them + for table, params in [[self.tblParams, updated_params_non_pd], [self.tblParamsPD, updated_params_pd]]: + for row, param in enumerate(params): + for column in range(2): + if column < len(param): # Check if the column index is within the bounds of param length + item = QtWidgets.QTableWidgetItem(str(param[column])) + table.setItem(row, column, item) + else: + logging.info(f"Missing data for Row {row}, Column {column}") + + self.tblParams.blockSignals(False) + self.tblParamsPD.blockSignals(False) def getModel(self): """ diff --git a/src/sas/qtgui/Utilities/TabbedModelEditor.py b/src/sas/qtgui/Utilities/TabbedModelEditor.py index 88328a8c2f..ddaebc0ae8 100644 --- a/src/sas/qtgui/Utilities/TabbedModelEditor.py +++ b/src/sas/qtgui/Utilities/TabbedModelEditor.py @@ -6,6 +6,7 @@ import datetime import logging import traceback +import importlib.util from PySide6 import QtWidgets, QtCore, QtGui from pathlib import Path @@ -25,6 +26,7 @@ class TabbedModelEditor(QtWidgets.QDialog, Ui_TabbedModelEditor): 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, model=False, load_file=None): super(TabbedModelEditor, self).__init__(parent._parent) @@ -33,7 +35,8 @@ def __init__(self, parent=None, edit_only=False, model=False, load_file=None): self.setupUi(self) # globals - self.filename = "" + self.filename_py = "" + self.filename_c = "" self.is_python = True self.is_documentation = False self.window_title = self.windowTitle() @@ -41,8 +44,10 @@ def __init__(self, parent=None, edit_only=False, model=False, load_file=None): self.load_file = load_file.lstrip("//") if load_file else None self.model = model self.is_modified = False + self.showNoCompileWarning = True self.label = None self.file_to_regenerate = "" + self.include_polydisperse = False self.addWidgets() @@ -65,17 +70,23 @@ def addWidgets(self): self.setPluginActive(True) self.editor_widget = ModelEditor(self) - # Initially, nothing in the editor + self.c_editor_widget = ModelEditor(self) + # Initially, nothing in the editors self.editor_widget.setEnabled(False) - self.tabWidget.addTab(self.editor_widget, "Model editor") + self.c_editor_widget.setEnabled(False) self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False) + + # Initially hide form function box + self.plugin_widget.formFunctionBox.setVisible(False) + if self.edit_only: self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setText("Save") # Hide signals from the plugin widget self.plugin_widget.blockSignals(True) # and hide the tab/widget itself self.tabWidget.removeTab(0) + self.addTab("python", "Model Editor") if self.model is not None: self.cmdLoad.setText("Load file...") @@ -92,7 +103,10 @@ def addSignals(self): # signals from tabs self.plugin_widget.modelModified.connect(self.editorModelModified) self.editor_widget.modelModified.connect(self.editorModelModified) + self.c_editor_widget.modelModified.connect(self.editorModelModified) self.plugin_widget.txtName.editingFinished.connect(self.pluginTitleSet) + self.plugin_widget.includePolydisperseFuncsSignal.connect(self.includePolydisperseFuncs) + self.plugin_widget.omitPolydisperseFuncsSignal.connect(self.omitPolydisperseFuncs) def setPluginActive(self, is_active=True): """ @@ -192,8 +206,8 @@ def loadFile(self, filename): self.editor_widget.setEnabled(True) self.editor_widget.blockSignals(False) self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True) - self.filename = Path(filename) - display_name = self.filename.stem + self.filename_py = Path(filename) + display_name = self.filename_py.stem if not self.model: self.setWindowTitle(self.window_title + " - " + display_name) else: @@ -201,28 +215,29 @@ def loadFile(self, filename): # Name the tab with .py filename self.tabWidget.setTabText(0, display_name) + # In case previous model was incorrect, change the frame colours back + self.editor_widget.txtEditor.setStyleSheet("") + self.editor_widget.txtEditor.setToolTip("") + # Check the validity of loaded model if the model is python if self.is_python: - error_line = self.checkModel(plugin_text) + error_line = self.checkModel(self.filename_py) if error_line > 0: # select bad line cursor = QtGui.QTextCursor(self.editor_widget.txtEditor.document().findBlockByLineNumber(error_line-1)) self.editor_widget.txtEditor.setTextCursor(cursor) - return - - # In case previous model was incorrect, change the frame colours back - self.editor_widget.txtEditor.setStyleSheet("") - self.editor_widget.txtEditor.setToolTip("") + # Do not return because we still want to load C file if it exists + QtWidgets.QMessageBox.warning(self, "Model check failed", "The loaded model contains errors. Please correct all errors before using model.") # See if there is filename.c present - c_path = self.filename.parent / self.filename.name.replace(".py", ".c") - if not c_path.exists() or ".rst" in c_path.name: return + self.filename_c = self.filename_py.parent / self.filename_py.name.replace(".py", ".c") + if not self.filename_c.exists() or ".rst" in self.filename_c.name: return # add a tab with the same highlighting - c_display_name = c_path.name + c_display_name = self.filename_c.name self.c_editor_widget = ModelEditor(self, is_python=False) self.tabWidget.addTab(self.c_editor_widget, c_display_name) # Read in the file and set in on the widget - with open(c_path, 'r', encoding="utf-8") as plugin: + with open(self.filename_c, 'r', encoding="utf-8") as plugin: self.c_editor_widget.txtEditor.setPlainText(plugin.read()) self.c_editor_widget.modelModified.connect(self.editorModelModified) @@ -265,7 +280,23 @@ def editorModelModified(self): self.plugin_widget.txtFunction.setStyleSheet("") self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(True) self.is_modified = True - + + def omitPolydisperseFuncs(self): + """ + User has no polydisperse parameters. + Omit polydisperse-only functions from model text. + Note that this is necessary because Form Volume Function text box does not clear its text when it disappears. + """ + self.include_polydisperse = False + + def includePolydisperseFuncs(self): + """ + User has defined polydisperse parameters. + Include polydisperse-only functions from model text. + By default these are not included even if text exists in Form Volume Function text box. + """ + self.include_polydisperse = True + def pluginTitleSet(self): """ User modified the model name. @@ -314,36 +345,87 @@ def updateFromPlugin(self): # get required filename filename = model['filename'] + # If user has not specified an output file type, throw error message + if model['gen_python'] == False and model['gen_c'] == False: + msg = "No output model language specified.\n" + msg += "Please select which types of model (Python, C) to generate." + QtWidgets.QMessageBox.critical(self, "Plugin Error", msg) + return + # 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 full path of the python path for the model + full_path_py = os.path.join(plugin_location, filename) + if os.path.splitext(full_path_py)[1] != ".py": + full_path_py += ".py" - # generate the model representation as string - model_str = self.generateModel(model, full_path) - self.writeFile(full_path, model_str) + if model['gen_python'] == True: + # Update the global path definition + self.filename_py = full_path_py + if not self.canWriteModel(model, full_path_py): + return + # generate the model representation as string + model_str = self.generatePyModel(model, full_path_py) + self.writeFile(full_path_py, model_str) + + if model['gen_c'] == True: + c_path = os.path.join(plugin_location, filename) + if os.path.splitext(c_path)[1] != ".c": + c_path += ".c" + # Update the global path definition + self.filename_c = c_path + if not self.canWriteModel(model, c_path): + return + # generate the model representation as string + c_model_str = self.generateCModel(model, c_path) + self.writeFile(c_path, c_model_str) # disable "Apply" self.buttonBox.button(QtWidgets.QDialogButtonBox.Apply).setEnabled(False) - # Run the model test in sasmodels - if not self.isModelCorrect(full_path): - return + # Allow user to toggle 'Generate Python model' checkbox + self.plugin_widget.enablePyCheckboxSignal.emit() - self.editor_widget.setEnabled(True) + # Run the model test in sasmodels and check model syntax. Returns error line if checks fail. + if os.path.exists(full_path_py): + error_line = self.checkModel(full_path_py) + if error_line > 0: + return + else: + if self.showNoCompileWarning: + # Show message box that tells user no model checks will be run until a python file of the same name is created in the plugins directory. + self.noModelCheckWarning() + - # Update the editor here. + self.editor_widget.setEnabled(True) + + # Update the editor(s) here. # Simple string forced into control. - self.editor_widget.blockSignals(True) - self.editor_widget.txtEditor.setPlainText(model_str) - self.editor_widget.blockSignals(False) + if model['gen_python'] == True: + # Add a tab to TabbedModelEditor for the Python model if not already open + if not self.isWidgetInTab(self.tabWidget, self.editor_widget): + self.addTab("python", Path(self.filename_py).name) + elif self.tabWidget.tabText(self.tabWidget.indexOf(self.editor_widget)) != Path(self.filename_py).name: + # If title of tab is not what the filename is, update the tab title + self.tabWidget.setTabText(self.tabWidget.indexOf(self.editor_widget), Path(self.filename_py).name) + + self.editor_widget.blockSignals(True) + self.editor_widget.txtEditor.setPlainText(model_str) + self.editor_widget.blockSignals(False) + + if model['gen_c'] == True: + # Add a tab to TabbedModelEditor for the C model if not already open + if not self.isWidgetInTab(self.tabWidget, self.c_editor_widget): + self.addTab("c", Path(self.filename_c).name) + elif self.tabWidget.tabText(self.tabWidget.indexOf(self.c_editor_widget)) != Path(self.filename_c).name: + # If title of tab is not what the filename is, update the tab title + self.tabWidget.setTabText(self.tabWidget.indexOf(self.c_editor_widget), Path(self.filename_c).name) + + # Update the editor + self.c_editor_widget.blockSignals(True) + self.c_editor_widget.txtEditor.setPlainText(c_model_str) + self.c_editor_widget.blockSignals(False) # Set the widget title self.setTabEdited(False) @@ -356,18 +438,21 @@ def updateFromPlugin(self): self.parent.communicate.statusBarUpdateSignal.emit(msg) logging.info(msg) - def checkModel(self, model_str): + def checkModel(self, full_path): """ - Run the ast check - and return True if the model is good. - False otherwise. + Run ast and model checks + Attempt to return the line number of the error if any + :param full_path: full path to the model file """ # successfulCheck = True error_line = 0 try: + with open(full_path, 'r', encoding="utf-8") as plugin: + model_str = plugin.read() ast.parse(model_str) + GuiUtils.checkModel(full_path) - except SyntaxError as ex: + except Exception as ex: msg = "Error building model: " + str(ex) logging.error(msg) # print four last lines of the stack trace @@ -380,91 +465,111 @@ def checkModel(self, model_str): # Set the status bar message # GuiUtils.Communicate.statusBarUpdateSignal.emit("Model check failed") self.parent.communicate.statusBarUpdateSignal.emit("Model check failed") - # Put a thick, red border around the mini-editor - self.tabWidget.currentWidget().txtEditor.setStyleSheet("border: 5px solid red") - # last_lines = traceback.format_exc().split('\n')[-4:] - traceback_to_show = '\n'.join(last_lines) - self.tabWidget.currentWidget().txtEditor.setToolTip(traceback_to_show) + + # Put a thick, red border around the editor. + from sas.qtgui.Utilities.CodeEditor import QCodeEditor + + # Find all QTextBrowser and QCodeEditor children + text_browsers = self.tabWidget.currentWidget().findChildren(QtWidgets.QTextBrowser) + code_editors = self.tabWidget.currentWidget().findChildren(QCodeEditor) + + # Combine the lists and apply the stylesheet + for child in text_browsers + code_editors: + child.setStyleSheet("border: 5px solid red") + # last_lines = traceback.format_exc().split('\n')[-4:] + traceback_to_show = '\n'.join(last_lines) + child.setToolTip(traceback_to_show) + # attempt to find the failing command line number, usually the last line with # `File ... line` syntax - for line in reversed(all_lines): - if 'File' in line and 'line' in line: + reversed_error_text = list(reversed(all_lines)) + for line in reversed_error_text: + if ('File' in line and 'line' in line): + # If model check fails (not syntax) then 'line' and 'File' will be in adjacent lines error_line = re.split('line ', line)[1] try: error_line = int(error_line) break except ValueError: - error_line = 0 - return error_line + # Sometimes the line number is followed by more text + try: + error_line = error_line.split(',')[0] + error_line = int(error_line) + break + except ValueError: + error_line = 0 - 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 + return error_line def updateFromEditor(self): """ Save the current state of the Model Editor """ - filename = self.filename + clear_error_formatting = True # Assume we will clear error formating (if any) after saving + filename = self.filename_py w = self.tabWidget.currentWidget() if not w.is_python: base, _ = os.path.splitext(filename) filename = base + '.c' - # make sure we have the file handle ready - assert(filename != "") + assert filename != "" + # Retrieve model string model_str = self.getModel()['text'] + # Save the file + self.writeFile(filename, model_str) + + # Get model filepath + plugin_location = models.find_plugins_dir() + full_path = os.path.join(plugin_location, filename) + if not w.is_python and self.is_python: + pass + elif os.path.splitext(full_path)[1] != ".py": + full_path += ".py" + + # Check model as long as there is a .py file in one of the tabs if w.is_python and self.is_python: - error_line = self.checkModel(model_str) + check_model = True + elif not w.is_python and self.is_python: + # Set full_path to the .py file so that we can run a model check on it (the .py model should link to the .c model) + full_path = self.filename_py + check_model = True + + if check_model: + error_line = self.checkModel(full_path) if error_line > 0: # select bad line cursor = QtGui.QTextCursor(w.txtEditor.document().findBlockByLineNumber(error_line-1)) w.txtEditor.setTextCursor(cursor) - return - # change the frame colours back - w.txtEditor.setStyleSheet("") - w.txtEditor.setToolTip("") - # Save the file - self.writeFile(filename, model_str) + # Ask the user if they want to save the file with errors or continue editing + user_decision = self.saveOverrideWarning(filename, model_str) + if user_decision == False: + # If the user decides to continue editing without saving, return + return + else: + clear_error_formatting = False + + if clear_error_formatting: + # change the frame colours back, if errors were fixed + try: + self.c_editor_widget.txtEditor.setStyleSheet("") + self.c_editor_widget.txtEditor.setToolTip("") + except AttributeError: + pass + self.editor_widget.txtEditor.setStyleSheet("") + self.editor_widget.txtEditor.setToolTip("") + # Update the tab title self.setTabEdited(False) # Notify listeners, since the plugin name might have changed self.parent.communicate.customModelDirectoryChanged.emit() + if self.isWidgetInTab(self.tabWidget, self.plugin_widget): + # Attempt to update the plugin widget with updated model information + self.updateToPlugin(full_path) + # notify the user msg = str(filename) + " successfully saved." self.parent.communicate.statusBarUpdateSignal.emit(msg) @@ -480,7 +585,67 @@ def regenerateDocumentation(self): # in order for the documentation regeneration process to run. # The regen method is part of the documentation window. If the window is closed, the method no longer exists. if hasattr(self.parent, 'helpWindow'): - self.parent.helpWindow.regenerateHtml(self.filename) + self.parent.helpWindow.regenerateHtml(self.filename_py) + + def noModelCheckWarning(self): + """ + Throw popup informing the user that no model checks will be run on a pure C model. + Ask user to acknowledge and give option to not display again. + """ + msgBox = QtWidgets.QMessageBox(self) + msgBox.setIcon(QtWidgets.QMessageBox.Information) + msgBox.setText("No model checks will be run on your C file until a python file of the same name is created in your plugin directory.") + msgBox.setWindowTitle("No Python File Detected") + buttonContinue = msgBox.addButton("OK", QtWidgets.QMessageBox.AcceptRole) + doNotShowAgainCheckbox = QtWidgets.QCheckBox("Do not show again") + msgBox.setCheckBox(doNotShowAgainCheckbox) + + msgBox.exec_() + + if doNotShowAgainCheckbox.isChecked(): + # Update flag to not show popup again while this instance of TabbedModelEditor is open + self.showNoCompileWarning = False + + def saveOverrideWarning(self, filename, model_str): + """ + Throw popup asking user if they want to save the model despite a bad model check. + Save model if user chooses to save, and do nothing if the user chooses to continue editing. + + Returns True if user wanted to save file anyways, False if user wanted to continue editing without saving + """ + msgBox = QtWidgets.QMessageBox(self) + msgBox.setIcon(QtWidgets.QMessageBox.Warning) + msgBox.setText("Model check failed. Do you want to save the file anyways?") + msgBox.setWindowTitle("Model Error") + + # Add buttons + buttonContinue = msgBox.addButton("Continue editing", QtWidgets.QMessageBox.NoRole) + buttonSave = msgBox.addButton("Save anyways", QtWidgets.QMessageBox.AcceptRole) + # Set default button + msgBox.setDefaultButton(buttonContinue) + + # Execute the message box and wait for the user's response + userChoice = msgBox.exec_() + + # Check which button was clicked and execute the corresponding code + if msgBox.clickedButton() == buttonContinue: + return False + elif msgBox.clickedButton() == buttonSave: + # Save files anyways + py_file = os.path.splitext(filename)[0] + ".py" + c_file = os.path.splitext(filename)[0] + ".c" + py_tab_open = self.isWidgetInTab(self.tabWidget, self.editor_widget) + c_tab_open = self.isWidgetInTab(self.tabWidget, self.c_editor_widget) + + # Check to see if we have a certain model type open, and if so, write models + if py_tab_open and c_tab_open: + self.writeFile(py_file, self.editor_widget.getModel()['text']) + self.writeFile(c_file, self.c_editor_widget.getModel()['text']) + elif py_tab_open: + self.writeFile(py_file, self.editor_widget.getModel()['text']) + elif c_tab_open: + self.writeFile(c_file, self.c_editor_widget.getModel()['text']) + return True def canWriteModel(self, model=None, full_path=""): """ @@ -508,12 +673,17 @@ def canWriteModel(self, model=None, full_path=""): # Don't accept but return return False # Update model editor if plugin definition changed - func_str = model['text'] + func_str = model['func_text'] + form_vol_str = model['form_volume_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" + elif form_vol_str: + if 'return' not in form_vol_str: + msg = "Error: The form_volume() must 'return' a value at least.\n" + msg += "For example: \n\nreturn 0.0" else: msg = 'Error: Function is not defined.' if msg is not None: @@ -536,6 +706,72 @@ def getModel(self): Retrieves plugin model from the currently open tab """ return self.tabWidget.currentWidget().getModel() + + def addTab(self, filetype, name): + """ + Add a tab to the tab widget + :param filetype: filetype of tab to add: "python" or "c" + :param name: name to display on tab + """ + if filetype == "python": + #display_name = Path(self.filename_py).name + self.editor_widget = ModelEditor(self, is_python=True) + self.tabWidget.addTab(self.editor_widget, name) + self.editor_widget.modelModified.connect(self.editorModelModified) + elif filetype == "c": + #display_name = Path(self.filename_c).name + self.c_editor_widget = ModelEditor(self, is_python=False) + self.tabWidget.addTab(self.c_editor_widget, name) + self.c_editor_widget.modelModified.connect(self.editorModelModified) + + def removeTab(self, filetype): + """ + Remove a tab from the tab widget. + Assume that the tab to remove exists. + :param filetype: filetype of tab to remove: "python" or "c" + """ + if filetype == "python": + self.tabWidget.removeTab(self.tabWidget.indexOf(self.editor_widget)) + elif filetype == "c": + self.tabWidget.removeTab(self.tabWidget.indexOf(self.c_editor_widget)) + + def updateToPlugin(self, full_path): + """ + Update the plugin tab with new info from the model editor + """ + self.model = self.getModel() + model_text = self.model['text'] + + spec = importlib.util.spec_from_file_location("model", full_path) # Easier to import than use regex + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + description = module.description + parameters = module.parameters + iq_text = self.extractFunctionBody(model_text, "Iq") + form_volume_text = self.extractFunctionBody(model_text, "form_volume") + + slim_param_list = [] + for param in parameters: + if param[0]: + # Extract parameter name, default value, and whether "volume" parameter + slim_param_list.append([param[0], param[2], param[4]]) + + # Send parameters in a list of lists format to listening widget + self.plugin_widget.sendNewParamSignal.emit(slim_param_list) + self.plugin_widget.sendNewDescriptionSignal.emit(description) + self.plugin_widget.sendNewIqSignal.emit(iq_text) + self.plugin_widget.sendNewFormVolumeSignal.emit(form_volume_text) + + @classmethod + def isWidgetInTab(cls, tabWidget, widget_to_check): + """ + Check to see if a `widget_to_check` is a tab in the `tabWidget` + """ + for i in range(tabWidget.count()): + if tabWidget.widget(i) == widget_to_check: + return True + return False @classmethod def writeFile(cls, fname, model_str=""): @@ -544,11 +780,87 @@ def writeFile(cls, fname, model_str=""): """ with open(fname, 'w', encoding="utf-8") as out_f: out_f.write(model_str) + + def generateCModel(self, model, fname): + """ + Generate C model from the current plugin state + :param model: plugin model + :param fname: filename + """ - def generateModel(self, model, fname): + model_text = C_COMMENT_TEMPLATE + + param_names = [] + pd_param_names = [] + param_str = self.strFromParamDict(model['parameters']) + pd_param_str = self.strFromParamDict(model['pd_parameters']) + for pname, _, _ in self.getParamHelper(param_str): + param_names.append('double ' + pname) + for pd_pname, _, _ in self.getParamHelper(pd_param_str): + pd_param_names.append('double ' + pd_pname) + + #Format Python into comments to be put into I(Q) section + iq_text: str = model['func_text'] + iq_lines = iq_text.splitlines() + commented_lines = ["//" + line for line in iq_lines] + commented_iq_function = "\n ".join(commented_lines) + + # Add polydisperse-dependent functions if polydisperse parameters are present + if pd_param_names != []: + model_text += C_PD_TEMPLATE.format(poly_args = ', '.join(pd_param_names), + poly_arg1 = pd_param_names[0].split(' ')[1]) # Remove 'double' from the first argument + # Add all other function templates + model_text += C_TEMPLATE.format(args = ',\n'.join(param_names), + Iq = commented_iq_function) + return model_text + + + def generatePyModel(self, model, fname): """ generate model from the current plugin state """ + + def formatPythonFlags(): + """Get python flags for model and format into text""" + header = "\n# Optional flags (can be removed). Read documentation by pressing 'Help' for more information.\n\n" + flag_string = header + checkbox_defaults = { + 'chkSingle': True, + 'chkOpenCL': False, + 'chkStructure': False, + 'chkFQ': False + } + # Get the values of the checkboxes + checkbox_values = {} + for name in checkbox_defaults.keys(): + checkbox_values[name] = getattr(self.plugin_widget, name).isChecked() + # Create output string + for name in checkbox_values.keys(): + # Check to see if the checkbox is set to a non-default value + if checkbox_defaults[name] != checkbox_values[name]: + match name: + case 'chkSingle': + flag_string += """\ +# single = True indicates that the model can be run using single precision floating point values. Defaults to True. +single = True\n\n""" + case 'chkOpenCL': + flag_string += """\ +# opencl = False indicates that the model should not be run using OpenCL. Defaults to False. +opencl = False\n\n""" + case 'chkStructure': + flag_string += """\ +# structure_factor = False indicates that the model cannot be used as a structure factor to account for interactions between particles. Defaults to False. +structure_factor = False\n\n""" + case 'chkFQ': + flag_string += """\ +# have_fq = False indicates that the model does not define F(Q) calculations in a linked C model. Note that F(Q) calculations are only necessary for accomadating beta approximation. Defaults to False. +have_fq = False\n\n""" + + if flag_string == header: + # If no flags are set, do not include the header + flag_string = "" + return flag_string.rstrip() + "\n" # Remove trailing newline + name = model['filename'] if not name: model['filename'] = fname @@ -556,13 +868,14 @@ def generateModel(self, model, 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('%Y-%m-%d'), - } + func_str = model['func_text'] + form_vol_str = model['form_volume_text'] + model_text = CUSTOM_TEMPLATE.format(name = name, + title = 'User model for ' + name, + description = desc_str, + date = datetime.datetime.now().strftime('%Y-%m-%d'), + flags= formatPythonFlags() + ) # Write out parameters param_names = [] # to store parameter names @@ -578,10 +891,16 @@ def generateModel(self, model, fname): param_names.append(pname) pd_params.append(pname) model_text += " ['%s', '', %s, [-inf, inf], 'volume', '%s'],\n" % (pname, pvalue, desc) - model_text += ' ]\n' + model_text += ' ]\n\n' + + # If creating a C model, link it to the Python file + + if model['gen_c']: + model_text += LINK_C_MODEL_TEMPLATE.format(c_model_name = name + '.c') + model_text += '\n\n' # Write out function definition - model_text += 'def Iq(%s):\n' % ', '.join(['x'] + param_names) + model_text += 'def Iq(%s):\n' % ', '.join(['q'] + param_names) model_text += ' """Absolute scattering"""\n' if "scipy." in func_str: model_text +=" import scipy\n" @@ -591,13 +910,19 @@ def generateModel(self, model, fname): 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 +='\n## 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: + # Add parameters to ER and VR functions and include placeholder functions + model_text += "\n" + model_text += ER_VR_TEMPLATE.format(args = ', '.join(param_names)) + + # If polydisperse, create place holders for form_volume + if pd_params and self.include_polydisperse == True: model_text +="\n" - model_text +=CUSTOM_TEMPLATE_PD % {'args': ', '.join(pd_params)} + model_text +=CUSTOM_TEMPLATE_PD.format(args = ', '.join(pd_params)) + for func_line in form_vol_str.split('\n'): + model_text +='%s%s\n' % (" ", func_line) # Create place holder for Iqxy model_text +="\n" @@ -651,6 +976,38 @@ def strFromParamDict(cls, param_dict): value = 1 param_str += params[0] + " = " + str(value) + "\n" return param_str + + @classmethod + def extractFunctionBody(cls, source_code, function_name): + """ + Extract the body of a function from a model file + """ + tree = ast.parse(source_code) + extractor = cls.FunctionBodyExtractor(function_name) + extractor.visit(tree) + return extractor.function_body_source + + class FunctionBodyExtractor(ast.NodeVisitor): + """ + Class to extract the body of a function from a model file + """ + def __init__(self, function_name): + self.function_name = function_name + self.function_body_source = None + + def visit_FunctionDef(self, node): + """ + Extract the source code of the function with the given name. + NOTE: Do NOT change the name of this method-- visit_ is a prefix that ast.NodeVisitor uses + """ + if node.name == self.function_name: + body = node.body + # Check if the first statement is an Expr node containing a constant (docstring) + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant): + body = body[1:] # Exclude the docstring + self.function_body_source = ast.unparse(body) + # Continue traversing to find nested functions or other function definitions + self.generic_visit(node) CUSTOM_TEMPLATE = '''\ @@ -658,9 +1015,9 @@ def strFromParamDict(cls, param_dict): Definition ---------- -Calculates %(name)s. +Calculates {name}. -%(description)s +{description} References ---------- @@ -668,29 +1025,22 @@ def strFromParamDict(cls, param_dict): Authorship and Verification --------------------------- -* **Author:** --- **Date:** %(date)s -* **Last Modified by:** --- **Date:** %(date)s -* **Last Reviewed by:** --- **Date:** %(date)s +* **Author:** --- **Date:** {date} +* **Last Modified by:** --- **Date:** {date} +* **Last Reviewed by:** --- **Date:** {date} """ from sasmodels.special import * from numpy import inf -name = "%(name)s" -title = "%(title)s" -description = """%(description)s""" - +name = "{name}" +title = "{title}" +description = """{description}""" +{flags} ''' -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): +ER_VR_TEMPLATE = '''\ +def ER({args}): """ Effective radius of particles to be used when computing structure factors. @@ -698,7 +1048,7 @@ def ER(%(args)s): """ return 0.0 -def VR(%(args)s): +def VR({args}): """ Volume ratio of particles to be used when computing structure factors. @@ -707,6 +1057,14 @@ def VR(%(args)s): return 1.0 ''' +CUSTOM_TEMPLATE_PD = '''\ +def form_volume({args}): + """ + Volume of the particles used to compute absolute scattering intensity + and to weight polydisperse parameter contributions. + """ +''' + SUM_TEMPLATE = """ from sasmodels.core import load_model_info from sasmodels.sasview_model import make_model_from_info @@ -716,6 +1074,143 @@ def VR(%(args)s): Model = make_model_from_info(model_info) """ +LINK_C_MODEL_TEMPLATE = '''\ +# To Enable C model, uncomment the line defining `source` and +# delete the I(Q) function in this Python model after converting your code to C +# Note: removing or commenting the "source = []" line will unlink the C model from the Python model, +# which means the C model will not be checked for errors when edited. + +# source = ['{c_model_name}'] +''' + +C_COMMENT_TEMPLATE = '''\ +// :::Custom C model template::: +// This is a template for a custom C model. +// C Models are used for a variety of reasons in SasView, including better +// performance and the ability to perform calculations not possible in Python. +// For example, all oriented and magnetic models, as well as most models +// using structure factor calculations, are written in C. +// HOW TO USE THIS TEMPLATE: +// 1. Determine which functions you will need to perform your calculations; +// delete unused functions. +// 1.1 Note that you must define either Iq, Fq, or one of Iqac, Iqabc: +// Iq if your model does not use orientation parameters or use structure +// factor calculations; +// Fq if your model uses structure factor calculations; +// Iqac or Iqabc if your model uses orientation parameters/is magnetic; +// Fq AND Iqac/Iqabc if your model uses orientation parameters or +// is magnetic and has structure factor calculations. +// 2. Write C code independently of this editor and paste it into the +// appropriate functions. +// 2.1 Note that the C editor does not support C syntax checking, so +// writing C code directly into the SasView editor is not reccomended. +// 3. Ensure a python file links to your C model (source = ['filename.c']) +// 4. Press 'Apply' or 'Save' to save your model and run a model check +// (note that the model check will fail if there is no python file of the +// same name in your plugins directory) +// +// NOTE: SasView has many built-in functions that you can use in your C model-- +// for example, spherical Bessel functions (lib/sas_3j1x_x.c), Gaussian +// quadrature (lib/sas_J1.c), and more. +// To include, add their filename to the `source = []` list in the python +// file linking to your C model. +// NOTE: It also has many common constants following the C99 standard, such as +// M_PI, M_SQRT1_2, and M_E. Check documentation for full list. + +''' + +C_PD_TEMPLATE = '''\ +static double +form_volume({poly_args}) // Remove arguments as needed +{{ + return 0.0*{poly_arg1}; +}} +''' + +C_TEMPLATE = """\ +static double +radius_effective(int mode) // Add arguments as needed +{{ + switch (mode) {{ + default: + case 1: + // Define effective radius calculations here... + return 0.0; + }} +}} + +static void +Fq(double q, + double *F1, + double *F2, + {args}) // Remove arguments as needed +{{ + // Define F(Q) calculations here... + //IMPORTANT: You do not have to define Iq if your model uses Fq for + // beta approximation; the *F2 value is F(Q)^2 and equivalent to + // the output of Iq. You may use Fq instead of Iq even if you do + // not need F(Q) (*F1) for beta approximation, but this is not recommended. + // Additionally, you must still define Iqac or Iqabc if your + // model has orientation parameters. + *F1 = 0.0; + *F2 = 0.0; +}} + +static double +Iq(double q, + {args}) // Remove arguments as needed +{{ + // Define I(Q) calculations here for models independent of shape orientation + // IMPORTANT: Only define ONE calculation for I(Q): either Iq, Iqac, or Iqabc; + // remove others. + // TO USE: Convert your copied Python code to C below and uncomment it + // Ensure that you delete the I(Q) function in the corresponding Python file. + + {Iq} + return 1.0; +}} + +static double +Iqac(double qab, + double qc, + {args}) // Remove arguments as needed +{{ + // Define I(Q) calculations here for models dependent on shape orientation in + // which the shape is rotationally symmetric about *c* axis. + // Note: *psi* angle not needed for shapes symmetric about *c* axis + // IMPORTANT: Only define ONE calculation for I(Q): either Iq, Iqac, Iqabc, or Iqxy; + // remove others. + return 1.0; +}} + +static double +Iqabc(double qa, + double qb, + double qc, + {args}) // Remove arguments as needed +{{ + // Define I(Q) calculations here for models dependent on shape orientation in + // all three axes. + // IMPORTANT: Only define ONE calculation for I(Q): either Iq, Iqac, Iqabc, or Iqxy; + // remove others. + return 1.0; +}} + +static double +Iqxy(double qx, + double qy, + {args}) // Remove arguments as needed +{{ + // Define I(Q) calculations here for 2D magnetic models. + // WARNING: The use of Iqxy is generally discouraged; Use Iqabc instead + // for its better orientational averaging and documentation for details. + // IMPORTANT: Only define ONE calculation for I(Q): either Iq, Iqac, Iqabc, or Iqxy; + // remove others. + + return 1.0; +}} +""" + if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) sheet = TabbedModelEditor() diff --git a/src/sas/qtgui/Utilities/UI/PluginDefinitionUI.ui b/src/sas/qtgui/Utilities/UI/PluginDefinitionUI.ui index 00b365b52b..ad67ef8c3c 100755 --- a/src/sas/qtgui/Utilities/UI/PluginDefinitionUI.ui +++ b/src/sas/qtgui/Utilities/UI/PluginDefinitionUI.ui @@ -1,158 +1,290 @@ - - - PluginDefinition - - - - 0 - 0 - 723 - 784 - - - - Plugin Definition - - - - - - Plugin name - - - - - - Enter a plugin name - - - - - - - Overwrite existing plugin model of this name - - - - - - - - - - Description - - - - - - Enter a description of the model - - - - - - - - - - Fit parameters - - - - - - Non-polydisperse - - - - - - - - - - - - Parameters - - - - - Initial -value - - - - - - - - - - - Polydisperse - - - - - - - - - - - - Parameters - - - - - Initial -value - - - - - - - - - - - - - - Function(x) - - - - - - false - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:6.6pt; font-weight:400; font-style:normal;"> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:7.8pt;"><br /></p></body></html> - - - - - - - - - - txtName - chkOverwrite - txtDescription - tblParams - tblParamsPD - txtFunction - - - - + + + PluginDefinition + + + + 0 + 0 + 723 + 784 + + + + Plugin Definition + + + + + + Fit parameters + + + + + + Non-polydisperse + + + + + + QFrame::Sunken + + + false + + + false + + + QAbstractItemView::NoDragDrop + + + Qt::IgnoreAction + + + Qt::ElideRight + + + true + + + false + + + 100 + + + false + + + true + + + + + + + + + Parameters + + + + + Initial value + + + + + + + + + + + Polydisperse + + + + + + true + + + + + + + + + Parameters + + + + + Initial value + + + + + + + + + + + + + + Plugin name + + + + + + Enter a plugin name + + + + + + + + + false + + + Generate Python model + + + true + + + + + + + Generate C model template + + + + + + + + + Overwrite existing plugin model of this name + + + + + + + font-size: 8pt + + + + + + + + + + + + + Description + + + + + + Enter a description of the model + + + + + + + + + + Enter function for calculating volume of the particle: + + + + + + IBeamCursor + + + false + + + + + + + + + + Enter function for calculating scattering intensity I(Q): + + + + + + IBeamCursor + + + false + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> +p, li { white-space: pre-wrap; } +hr { height: 1px; border-width: 0; } +li.unchecked::marker { content: "\2610"; } +li.checked::marker { content: "\2612"; } +</style></head><body style=" font-family:'Segoe UI'; font-size:9pt; font-weight:400; font-style:normal;"> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:7.8pt;"><br /></p></body></html> + + + + + + + + + + Model Options + + + + + + Can use OpenCL + + + + + + + Can use single precision floating point values + + + true + + + + + + + Has F(Q) calculations + + + + + + + Can be used as structure factor + + + + + + + + + + txtName + chkOverwrite + txtDescription + tblParamsPD + txtFunction + + + +