diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt
index b5b0fea925..76210f56dc 100644
--- a/build_tools/requirements.txt
+++ b/build_tools/requirements.txt
@@ -16,7 +16,7 @@ numpy
packaging
periodictable
platformdirs
-pyausaxs==1.0.4
+pyausaxs
pybind11
pylint
pyopencl
diff --git a/src/sas/qtgui/Calculators/GenericScatteringCalculator.py b/src/sas/qtgui/Calculators/GenericScatteringCalculator.py
index f99c2c527d..6a6bf2cc77 100644
--- a/src/sas/qtgui/Calculators/GenericScatteringCalculator.py
+++ b/src/sas/qtgui/Calculators/GenericScatteringCalculator.py
@@ -27,6 +27,7 @@
from sas.qtgui.Utilities.ModelEditors.TabbedEditor.TabbedModelEditor import TabbedModelEditor
from sas.sascalc.calculator import sas_gen
from sas.sascalc.calculator.geni import create_beta_plot, f_of_q, radius_of_gyration
+from sas.sascalc.calculator.sas_gen import ComputationType
from sas.system.user import find_plugins_dir
# Local UI
@@ -89,7 +90,7 @@ def __init__(self, parent=None):
self.setup_display()
# combox box
- self.cbOptionsCalc.currentIndexChanged.connect(self.change_is_avg)
+ self.cbOptionsCalc.currentIndexChanged.connect(self.change_computation_type)
# prevent layout shifting when widget hidden
# TODO: Is there a way to lcoate this policy in the ui file?
sizePolicy = self.cbOptionsCalc.sizePolicy()
@@ -621,7 +622,7 @@ def update_cbOptionsCalc_visibility(self):
self.cbOptionsCalc.setVisible(allow)
if (allow):
# A helper function to set up the averaging system
- self.change_is_avg()
+ self.change_computation_type()
else:
# If magnetic data present then no averaging is allowed
self.is_avg = False
@@ -633,7 +634,7 @@ def update_cbOptionsCalc_visibility(self):
self.checkboxLogSpace.setEnabled(not self.is_mag)
- def change_is_avg(self):
+ def change_computation_type(self):
"""Adjusts the GUI for whether 1D averaging is enabled
If the user has chosen to carry out Debye full averaging then the magnetic sld
@@ -658,6 +659,22 @@ def change_is_avg(self):
self.checkboxLogSpace.setEnabled(self.is_avg)
self.checkboxPluginModel.setEnabled(self.is_avg)
+ # set the type of calculation
+ self.model.set_computation_type(ComputationType(self.cbOptionsCalc.currentIndex()))
+ match self.cbOptionsCalc.currentIndex():
+ case 0 | 1 | 2:
+ pass
+ case 3:
+ self.checkboxPluginModel.setEnabled(False)
+ self.checkboxPluginModel.setChecked(True)
+ self.txtFileName.setText("saxs_fitting")
+ self.txtFileName.setEnabled(False)
+ self.cmdCompute.setText("Generate plugin model")
+ return
+ case _:
+ raise RuntimeError(f"Unknown computation type selected: {self.cbOptionsCalc.currentIndex()}")
+
+ self.cmdCompute.setText("Compute")
if self.is_avg:
self.txtMx.setText("0.0")
self.txtMy.setText("0.0")
@@ -704,13 +721,20 @@ def loadFile(self):
load_nuc = self.sender() == self.cmdNucLoad
# request a file from the user
if load_nuc:
- f_type = """
- All supported files (*.SLD *.sld *.pdb *.PDB, *.vtk, *.VTK);;
- SLD files (*.SLD *.sld);;
- PDB files (*.pdb *.PDB);;
- VTK files (*.vtk *.VTK);;
- All files (*.*)
- """
+ if self.model.type is ComputationType.SAXS:
+ f_type = """
+ All supported files (*.CIF *.cif *.pdb *.PDB);;
+ CIF files (*.CIF *.cif);;
+ PDB files (*.pdb *.PDB);;
+ """
+ else:
+ f_type = """
+ All supported files (*.SLD *.sld *.pdb *.PDB, *.vtk, *.VTK);;
+ SLD files (*.SLD *.sld);;
+ PDB files (*.pdb *.PDB);;
+ VTK files (*.vtk *.VTK);;
+ All files (*.*)
+ """
else:
f_type = """
All supported files (*.OMF *.omf *.SLD *.sld, *.vtk, *.VTK);;
@@ -720,6 +744,11 @@ def loadFile(self):
All files (*.*)
"""
self.datafile = QtWidgets.QFileDialog.getOpenFileName(self, "Choose a file", "", f_type)[0]
+
+ if self.model.type is ComputationType.SAXS:
+ self.txtNucData.setText(os.path.basename(str(self.datafile)))
+ return
+
# If a file has been sucessfully chosen
if self.datafile:
# set basic data about the file
@@ -1412,6 +1441,48 @@ def onCompute(self):
Copied from previous version
"""
+
+ if self.model.type is ComputationType.SAXS:
+ if self.datafile is None:
+ raise RuntimeError("No structure file is loaded! SAXS calculations require a structure file.")
+ from sas.qtgui.Calculators.SAXSPluginModelGenerator import get_base_plugin_name, write_plugin_model
+ write_plugin_model(self.datafile)
+ self.manager.communicator().customModelDirectoryChanged.emit() # notify that a new plugin model is available
+
+ # try to bring the fit panel into focus and select the newly generated plugin
+ try:
+ self.manager.actionFitting() # switch to fitting window
+ per = self.manager.perspective() # internal access into the fitting window's state
+ # currentFittingWidget is provided by the Fitting perspective
+ fw = getattr(per, 'currentFittingWidget', None)
+ if fw is not None:
+ # select the plugin models category & our newly generated model
+ idx = fw.cbCategory.findText("Plugin Models")
+ if idx == -1: return
+
+ # force population of model combobox
+ fw.cbCategory.setCurrentIndex(idx)
+ fw.onSelectCategory()
+
+ # plugin name base is 'SAXS fit'
+ # the actual model name includes a structure tag, e.g. 'SAXS fit (2epe)'
+ model_name = get_base_plugin_name()
+ midx = fw.cbModel.findText(model_name, QtCore.Qt.MatchStartsWith)
+ if midx == -1: return
+
+ # load the model into the parameter table
+ fw.cbModel.setCurrentIndex(midx)
+ fw.onSelectModel()
+
+ # make sure the perspective window is visible and focused
+ self.close() # close the calculator window to highlight the changes to the fitting window
+ per.show()
+
+ except Exception:
+ pass
+
+ return
+
try:
# create the combined sld data and update from gui
sld_data = self.create_full_sld_data()
diff --git a/src/sas/qtgui/Calculators/SAXSPluginModelGenerator.py b/src/sas/qtgui/Calculators/SAXSPluginModelGenerator.py
new file mode 100644
index 0000000000..383f901938
--- /dev/null
+++ b/src/sas/qtgui/Calculators/SAXSPluginModelGenerator.py
@@ -0,0 +1,73 @@
+from pathlib import Path
+
+from sas.system.user import find_plugins_dir
+
+
+def get_base_plugin_name() -> str:
+ """
+ Get the base name for the AUSAXS SAXS plugin model.
+
+ :return: The base name of the plugin model.
+ """
+
+ return "SAXS fit"
+
+def write_plugin_model(structure_path: str):
+ """
+ Write the AUSAXS SAXS plugin model to the plugins directory.
+ The current version will be overwritten if it exists.
+
+ :param structure_path: Path to the structure file to be used by the plugin.
+ """
+
+ path = Path(find_plugins_dir()) / "ausaxs_saxs_plugin.py"
+ text = get_model_text(structure_path)
+ with open(path, 'w') as f:
+ f.write(text)
+
+def get_model_text(structure_path: str) -> str:
+ """
+ Generate the text of the AUSAXS SAXS plugin model.
+
+ :param structure_path: Path to the structure file to be used by the plugin.
+ :return: The text of the plugin model.
+ """
+
+ return (
+
+f'''\
+r"""
+This file is auto-generated, and any changes will be overwritten.
+
+This plugin model uses the AUSAXS library (https://doi.org/10.1107/S160057672500562X) to fit the provided SAXS data to the file:
+ * \"{structure_path}\"
+If this is not the intended structure file, please regenerate the plugin model from the generic scattering calculator.
+"""
+'''
+
+f'''\
+name = "{get_base_plugin_name()} ({Path(structure_path).name.split('.')[0]})"
+title = "AUSAXS"
+description = "Structural validation using AUSAXS"
+category = "plugin"
+parameters = [
+ # name, units, default, [min, max], type, description
+ ['c', '', 1, [0, 100], '', 'Solvent density'],
+ #['d', '', 1, [0, 2], '', 'Excluded volume parameter']
+]
+
+###
+import pyausaxs as ausaxs
+
+structure_path = "{str(Path(structure_path).as_posix())}"
+
+def Iq(q, c):
+ # Initialize on first call to keep objects alive for function lifetime
+ if not hasattr(Iq, '_initialized'):
+ Iq._mol = ausaxs.create_molecule(structure_path)
+ Iq._mol.hydrate()
+ Iq._fitobj = ausaxs.manual_fit(Iq._mol)
+ Iq._initialized = True
+ return Iq._fitobj.evaluate([c], q)
+Iq.vectorized = True
+''')
diff --git a/src/sas/qtgui/Calculators/UI/GenericScatteringCalculator.ui b/src/sas/qtgui/Calculators/UI/GenericScatteringCalculator.ui
index 8e759393c4..ac6c1a3451 100644
--- a/src/sas/qtgui/Calculators/UI/GenericScatteringCalculator.ui
+++ b/src/sas/qtgui/Calculators/UI/GenericScatteringCalculator.ui
@@ -2194,6 +2194,11 @@ NOTE: Currently not impacted by Solvent SLD
Debye full avg. w/ β(Q)
+ -
+
+ SAXS fitting
+
+
diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
index 90fb03f3bd..104e7e2716 100644
--- a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
+++ b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
@@ -39,6 +39,7 @@
from sas.sascalc.fit import models
from sas.sascalc.fit.BumpsFitting import BumpsFit as Fit
from sas.system import HELP_SYSTEM
+from sas.system.user import find_plugins_dir
TAB_MAGNETISM = 4
TAB_POLY = 3
diff --git a/src/sas/sascalc/calculator/ausaxs/ausaxs_sans_debye.py b/src/sas/sascalc/calculator/ausaxs/ausaxs_sans_debye.py
index e7dd409023..8bdf515108 100644
--- a/src/sas/sascalc/calculator/ausaxs/ausaxs_sans_debye.py
+++ b/src/sas/sascalc/calculator/ausaxs/ausaxs_sans_debye.py
@@ -1,6 +1,6 @@
import logging
-from pyausaxs import AUSAXS
+import pyausaxs as ausaxs
from sas.sascalc.calculator.ausaxs.sasview_sans_debye import sasview_sans_debye
@@ -14,8 +14,7 @@ def evaluate_sans_debye(q, coords, w):
*w* is the weight associated with each point.
"""
try:
- ausaxs = AUSAXS()
- Iq = ausaxs.debye(q, coords[0,:], coords[1,:], coords[2,:], w)
+ Iq = ausaxs.sasview.debye_no_ff(q, coords[0,:], coords[1,:], coords[2,:], w)
return Iq
except Exception as e:
logging.warning("AUSAXS Debye calculation failed: %s. Falling back to default implementation.", e)
diff --git a/src/sas/sascalc/calculator/sas_gen.py b/src/sas/sascalc/calculator/sas_gen.py
index ae2c6bb548..300543a622 100644
--- a/src/sas/sascalc/calculator/sas_gen.py
+++ b/src/sas/sascalc/calculator/sas_gen.py
@@ -9,6 +9,7 @@
import logging
import os
import sys
+from enum import Enum
import numpy as np
from periodictable import formula, nsf
@@ -53,6 +54,12 @@ def transform_center(pos_x, pos_y, pos_z):
posz = pos_z - (min(pos_z) + max(pos_z)) / 2.0
return posx, posy, posz
+class ComputationType(Enum):
+ SANS_2D = 0
+ SANS_1D = 1
+ SANS_1D_BETA = 2
+ SAXS = 3
+
class GenSAS:
"""
Generic SAS computation Model based on sld (n & m) arrays
@@ -75,6 +82,7 @@ def __init__(self):
self.data_vol = None # [A^3]
self.is_avg = False
self.is_elements = False
+ self.type = ComputationType.SANS_2D
## Name of the model
self.name = "GenSAS"
## Define parameters
@@ -106,6 +114,12 @@ def __init__(self):
# fixed parameters
self.fixed = []
+ def set_computation_type(self, computation_type : ComputationType):
+ """
+ Set the computation type. This will determine which calculation is performed.
+ """
+ self.type = computation_type
+
def set_pixel_volumes(self, volume):
"""
Set the volume of a pixel in (A^3) unit
@@ -180,33 +194,41 @@ def calculate_Iq(self, qx, qy=None):
x, y, z = self.transform_positions()
sld = self.data_sldn - self.params['solvent_SLD']
vol = self.data_vol
- if qy is not None and len(qy) > 0:
- # 2-D calculation
- qx, qy = _vec(qx), _vec(qy)
- # MagSLD can have sld_m = None, although in practice usually a zero array
- # if all are None can continue as normal, otherwise set None to array of zeroes to allow rotations
- mx, my, mz = self.transform_magnetic_slds()
- in_spin = self.params['Up_frac_in']
- out_spin = self.params['Up_frac_out']
- # transform angles from environment to beamline coords
- s_theta, s_phi = self.transform_angles()
-
- if self.is_elements:
- I_out = Iqxy(
- qx, qy, x, y, z, sld, vol, mx, my, mz,
- in_spin, out_spin, s_theta, s_phi,
- self.data_elements, self.is_elements)
- else:
- I_out = Iqxy(
- qx, qy, x, y, z, sld, vol, mx, my, mz,
- in_spin, out_spin, s_theta, s_phi,
- )
- else:
- # 1-D calculation
- q = _vec(qx)
- if self.is_avg:
- x, y, z = transform_center(x, y, z)
- I_out = Iq(q, x, y, z, sld, vol, is_avg=self.is_avg)
+ match self.type:
+ case ComputationType.SANS_2D:
+ if not (qy is not None and len(qy) > 0):
+ raise ValueError("For a SANS_2D computation, qy cannot be None or empty")
+
+ # 2-D calculation
+ qx, qy = _vec(qx), _vec(qy)
+ # MagSLD can have sld_m = None, although in practice usually a zero array
+ # if all are None can continue as normal, otherwise set None to array of zeroes to allow rotations
+ mx, my, mz = self.transform_magnetic_slds()
+ in_spin = self.params['Up_frac_in']
+ out_spin = self.params['Up_frac_out']
+ # transform angles from environment to beamline coords
+ s_theta, s_phi = self.transform_angles()
+
+ if self.is_elements:
+ I_out = Iqxy(
+ qx, qy, x, y, z, sld, vol, mx, my, mz,
+ in_spin, out_spin, s_theta, s_phi,
+ self.data_elements, self.is_elements)
+ else:
+ I_out = Iqxy(
+ qx, qy, x, y, z, sld, vol, mx, my, mz,
+ in_spin, out_spin, s_theta, s_phi,
+ )
+
+ case ComputationType.SANS_1D | ComputationType.SANS_1D_BETA:
+ # 1-D calculation
+ q = _vec(qx)
+ if self.is_avg:
+ x, y, z = transform_center(x, y, z)
+ I_out = Iq(q, x, y, z, sld, vol, is_avg=self.is_avg)
+
+ case ComputationType.SAXS:
+ raise RuntimeError("SAXS calculations can only be performed through a plugin model! Please click the \"plugin model\" button instead.")
vol_correction = self.data_total_volume / self.params['total_volume']
result = ((self.params['scale'] * vol_correction) * I_out
@@ -266,6 +288,7 @@ def run(self, x=0.0):
if len(x[1]) > 0:
raise ValueError("Not a 1D vector.")
# 1D I is found at y=0 in the 2D pattern
+ self.set_computation_type(ComputationType.SANS_1D)
out = self.calculate_Iq(x[0])
return out
else:
diff --git a/test/sascalculator/utest_sas_gen.py b/test/sascalculator/utest_sas_gen.py
index bd10005e0d..d0aff7bc50 100644
--- a/test/sascalculator/utest_sas_gen.py
+++ b/test/sascalculator/utest_sas_gen.py
@@ -225,15 +225,15 @@ def test_debye_impl(self):
"""
Test that the Debye algorithm supplied by the external AUSAXS library agrees with the default implementation.
"""
- from pyausaxs import AUSAXS
+ import pyausaxs as ausaxs
from sas.sascalc.calculator.ausaxs import ausaxs_sans_debye, sasview_sans_debye
rng = np.random.default_rng(1984)
- ausaxs = AUSAXS()
# ensure the library is available and ready to run on all CI systems
- assert ausaxs.ready(), "AUSAXS library not available, test cannot be run."
+ # this awkward syntax will be improved in a future version of pyausaxs ...
+ assert ausaxs.wrapper.AUSAXS.AUSAXS().ready(), "AUSAXS library not available, test cannot be run."
# get all pdb files in the data folder
import glob