diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3b0f25fcc2..e1858184db 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
- python-version: 3.8
+ python-version: 3.9
- uses: actions/checkout@v3
- id: set-matrix
@@ -195,7 +195,7 @@ jobs:
- name: Install utilities to build installer
if: ${{ matrix.installer }}
run: |
- python -m pip install pyinstaller==5.7.0
+ python -m pip install pyinstaller
- name: Build sasview with pyinstaller
if: ${{ matrix.installer }}
@@ -283,7 +283,7 @@ jobs:
strategy:
matrix:
os: [ windows-latest, ubuntu-20.04 ]
- python-version: [ 3.8 ]
+ python-version: [ 3.11 ]
fail-fast: false
name: Test installer
diff --git a/.github/workflows/matrix.py b/.github/workflows/matrix.py
index 79d52b2475..0b9097ce48 100644
--- a/.github/workflows/matrix.py
+++ b/.github/workflows/matrix.py
@@ -38,11 +38,12 @@
# List of python versions to use for release builds
python_release_list = [
- '3.8',
+ '3.11',
]
# List of python versions to use for tests
python_test_list = python_release_list + [
+ '3.8',
'3.9',
'3.10'
]
diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt
index c00355eca1..d652b7e499 100644
--- a/build_tools/requirements.txt
+++ b/build_tools/requirements.txt
@@ -1,16 +1,15 @@
-numpy<1.24
-scipy==1.10.0
+numpy
+scipy
docutils
pytest
pytest_qt
pytest-mock
unittest-xml-reporting
tinycc
-h5py
+h5py
sphinx
pyparsing
html5lib
-reportlab==3.6.6
pybind11
appdirs
six
@@ -22,7 +21,7 @@ xhtml2pdf
pylint
periodictable
uncertainties
-matplotlib~=3.5.2
+matplotlib
lxml
pytools
cffi
@@ -33,7 +32,7 @@ bumps
html2text
jsonschema
pywin32; platform_system == "Windows"
-PySide6==6.2.4
+PySide6==6.4.3
twisted
zope
superqt
diff --git a/setup.py b/setup.py
index 1195979830..e9d102670c 100644
--- a/setup.py
+++ b/setup.py
@@ -14,6 +14,7 @@
from setuptools import setup, Command, find_packages
+
# Manage version number
version_file = os.path.join("src", "sas", "system", "version.py")
with open(version_file) as fid:
@@ -80,6 +81,9 @@ def run(self):
'install', 'build', 'build_py', 'bdist', 'bdist_egg', 'bdist_rpm',
'bdist_wheel', 'develop', 'test'
]
+
+print(sys.argv)
+
# determine if this run requires building of Qt GUI ui->py
build_qt = any(c in sys.argv for c in build_commands)
force_rebuild = "-f" if 'rebuild_ui' in sys.argv or 'clean' in sys.argv else ""
@@ -93,7 +97,7 @@ def run(self):
# Required packages
required = [
'bumps>=0.7.5.9', 'periodictable>=1.5.0', 'pyparsing>=2.0.0',
- 'lxml', 'h5py',
+ 'lxml',
]
if os.name == 'nt':
diff --git a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py
index 8b9c90f05f..7867002987 100644
--- a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py
+++ b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py
@@ -369,14 +369,7 @@ def _findId(self, name):
def _extractData(self, key_id):
""" Extract data from file with id contained in list of filenames """
data_complete = self.filenames[key_id]
- dimension = data_complete.data.__class__.__name__
-
- if dimension in ('Data1D', 'Data2D'):
- return copy.deepcopy(data_complete.data)
-
- else:
- logging.error('Error with data format')
- return
+ return copy.deepcopy(data_complete)
# ########
# PLOTS
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py
new file mode 100644
index 0000000000..783691127a
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py
@@ -0,0 +1,121 @@
+from typing import List, Tuple
+
+from PySide6.QtWidgets import QWidget, QVBoxLayout, QFormLayout, QComboBox, QDoubleSpinBox
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import AngularDistribution
+from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import GeodesicDivisions
+from sas.qtgui.Perspectives.ParticleEditor.sampling.angles import angular_sampling_methods
+from sas.qtgui.Perspectives.ParticleEditor.GeodesicSampleSelector import GeodesicSamplingSpinBox
+
+class ParametersForm(QWidget):
+ """ Form that displays the parameters associated with the class (also responsible for generating the sampler)"""
+ def __init__(self, sampling_class: type, parent=None):
+ super().__init__(parent=parent)
+
+ self.sampling_class = sampling_class
+
+ self.layout = QFormLayout()
+ self.parameter_callbacks = []
+
+ for parameter_name, text, cls in sampling_class.parameters():
+ if cls == GeodesicDivisions:
+ widget = GeodesicSamplingSpinBox()
+
+ def callback():
+ return widget.getNDivisions()
+
+ elif cls == float:
+ widget = QDoubleSpinBox()
+
+ def callback():
+ return widget.value()
+
+
+ else:
+ raise TypeError(f"Cannot create appropriate widget for parameter of type '{cls}'")
+
+ self.layout.addRow(text, widget)
+ self.parameter_callbacks.append((parameter_name, callback))
+
+ self.setLayout(self.layout)
+
+ def generate_sampler(self) -> AngularDistribution:
+ """ Generate a sampler based on the selected parameters """
+
+ parameter_dict = {name: callback() for name, callback in self.parameter_callbacks}
+
+ return self.sampling_class(**parameter_dict)
+
+
+
+class AngularSamplingMethodSelector(QWidget):
+ """ Selects the method for doing angular sampling, and provides access to the parameters """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ layout = QVBoxLayout()
+
+ self.combo = QComboBox()
+ self.combo.addItems([cls.name() for cls in angular_sampling_methods])
+
+ subwidget = QWidget()
+ self.subwidget_layout = QVBoxLayout()
+ subwidget.setLayout(self.subwidget_layout)
+
+ layout.addWidget(self.combo)
+ layout.addWidget(subwidget)
+
+ self.setLayout(layout)
+
+ self.entry_widgets = [ParametersForm(cls) for cls in angular_sampling_methods]
+
+ for widget in self.entry_widgets:
+ self.subwidget_layout.addWidget(widget)
+ widget.hide()
+
+ self.entry_widgets[0].show()
+
+ self.combo.currentIndexChanged.connect(self.on_update)
+
+ def on_update(self):
+ for i in range(self.subwidget_layout.count()):
+ self.subwidget_layout.itemAt(i).widget().hide()
+
+ self.subwidget_layout.itemAt(self.combo.currentIndex()).widget().show()
+
+ def generate_sampler(self) -> AngularDistribution:
+ """ Create the angular distribution sampler spectified by the current settings"""
+ return self.subwidget_layout.itemAt(self.combo.currentIndex()).widget().generate_sampler()
+
+def main():
+ """ Show a demo """
+
+ from PySide6 import QtWidgets
+
+
+ app = QtWidgets.QApplication([])
+
+
+ widget = QWidget()
+ layout = QVBoxLayout()
+
+ sampling = AngularSamplingMethodSelector()
+
+ def callback():
+ print(sampling.generate_sampler())
+
+ button = QtWidgets.QPushButton("Check")
+ button.clicked.connect(callback)
+
+ layout.addWidget(sampling)
+ layout.addWidget(button)
+
+ widget.setLayout(layout)
+
+ widget.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py
new file mode 100644
index 0000000000..2f0061ac17
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py
@@ -0,0 +1,27 @@
+from PySide6 import QtWidgets, QtGui
+
+from sas.qtgui.Perspectives.ParticleEditor.UI.CodeToolBarUI import Ui_CodeToolBar
+
+import sas.qtgui.Perspectives.ParticleEditor.UI.icons_rc
+class CodeToolBar(QtWidgets.QWidget, Ui_CodeToolBar):
+ def __init__(self, parent=None):
+ super().__init__()
+
+ self.setupUi(self)
+
+
+ load_icon = QtGui.QIcon()
+ load_icon.addPixmap(QtGui.QPixmap(":/particle_editor/upload-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
+ self.loadButton.setIcon(load_icon)
+
+ save_icon = QtGui.QIcon()
+ save_icon.addPixmap(QtGui.QPixmap(":/particle_editor/download-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
+ self.saveButton.setIcon(save_icon)
+
+ build_icon = QtGui.QIcon()
+ build_icon.addPixmap(QtGui.QPixmap(":/particle_editor/hammer-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
+ self.buildButton.setIcon(build_icon)
+
+ scatter_icon = QtGui.QIcon()
+ scatter_icon.addPixmap(QtGui.QPixmap(":/particle_editor/scatter-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
+ self.scatterButton.setIcon(scatter_icon)
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py
new file mode 100644
index 0000000000..8f511d0d71
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py
@@ -0,0 +1,419 @@
+import traceback
+from typing import Optional
+
+from datetime import datetime
+
+import numpy as np
+from PySide6 import QtWidgets
+from PySide6.QtWidgets import QSpacerItem, QSizePolicy
+from PySide6.QtCore import Qt
+
+from sas.qtgui.Perspectives.ParticleEditor.UI.DesignWindowUI import Ui_DesignWindow
+
+from sas.qtgui.Perspectives.ParticleEditor.FunctionViewer import FunctionViewer
+from sas.qtgui.Perspectives.ParticleEditor.PythonViewer import PythonViewer
+from sas.qtgui.Perspectives.ParticleEditor.OutputViewer import OutputViewer
+from sas.qtgui.Perspectives.ParticleEditor.CodeToolBar import CodeToolBar
+
+from sas.qtgui.Perspectives.ParticleEditor.Plots.QCanvas import QCanvas
+
+
+from sas.qtgui.Perspectives.ParticleEditor.function_processor import process_code, FunctionDefinitionFailed
+from sas.qtgui.Perspectives.ParticleEditor.vectorise import vectorise_sld
+
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import (
+
+ QSample, ScatteringCalculation, CalculationParameters, AngularDistribution, SpatialDistribution,
+ SLDDefinition, SLDFunction, MagnetismDefinition, ParticleDefinition, CoordinateSystemTransform, ScatteringOutput)
+
+from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableModel import ParameterTableModel
+from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTable import ParameterTable
+from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableButtons import ParameterTableButtons
+
+from sas.qtgui.Perspectives.ParticleEditor.AngularSamplingMethodSelector import AngularSamplingMethodSelector
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid as GridSampling
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import RandomCube as UniformCubeSampling
+
+from sas.qtgui.Perspectives.ParticleEditor.util import format_time_estimate
+
+from sas.qtgui.Perspectives.ParticleEditor.calculations.calculate import calculate_scattering
+
+def safe_float(text: str):
+ try:
+ return float(text)
+ except:
+ return 0.0
+
+
+class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow):
+ """ Main window for the particle editor"""
+ def __init__(self, parent=None):
+ super().__init__()
+
+ self.setupUi(self)
+ self.setWindowTitle("Placeholder title")
+ self.parent = parent
+
+ # TODO: Set validators on fields
+
+ #
+ # First Tab
+ #
+
+ hbox = QtWidgets.QHBoxLayout(self)
+
+ splitter = QtWidgets.QSplitter(Qt.Vertical)
+
+ self.pythonViewer = PythonViewer()
+ self.outputViewer = OutputViewer()
+ self.codeToolBar = CodeToolBar()
+
+ self.pythonViewer.build_trigger.connect(self.doBuild)
+
+ self.codeToolBar.saveButton.clicked.connect(self.onSave)
+ self.codeToolBar.loadButton.clicked.connect(self.onLoad)
+ self.codeToolBar.buildButton.clicked.connect(self.doBuild)
+ self.codeToolBar.scatterButton.clicked.connect(self.doScatter)
+
+ topSection = QtWidgets.QVBoxLayout()
+ topSection.addWidget(self.pythonViewer)
+ topSection.addWidget(self.codeToolBar)
+ topSection.setContentsMargins(0,0,0,5)
+
+ topWidget = QtWidgets.QWidget()
+ topWidget.setLayout(topSection)
+
+ splitter.addWidget(topWidget)
+ splitter.addWidget(self.outputViewer)
+ splitter.setStretchFactor(0, 3)
+ splitter.setStretchFactor(1, 1)
+ hbox.addWidget(splitter)
+
+ # Function viewer
+ self.functionViewer = FunctionViewer()
+ self.functionViewer.radius_control.radiusField.valueChanged.connect(self.onRadiusChanged)
+
+ # A components
+ hbox.addWidget(self.functionViewer)
+ self.definitionTab.setLayout(hbox)
+
+ #
+ # Parameters Tab
+ #
+
+ self._parameterTableModel = ParameterTableModel()
+ self.parametersTable = ParameterTable(self._parameterTableModel)
+ self.parameterTabButtons = ParameterTableButtons()
+
+ self.parameterTabLayout.addWidget(self.parametersTable)
+ self.parameterTabLayout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
+ self.parameterTabLayout.addWidget(self.parameterTabButtons)
+
+ #
+ # Ensemble tab
+ #
+
+
+ self.angularSamplingMethodSelector = AngularSamplingMethodSelector()
+
+ self.topLayout.addWidget(self.angularSamplingMethodSelector, 0, 1)
+
+ self.structureFactorCombo.addItem("None") # TODO: Structure Factor Options
+
+
+ #
+ # Calculation Tab
+ #
+ self.methodComboOptions = ["Grid", "Random"]
+ for option in self.methodComboOptions:
+ self.methodCombo.addItem(option)
+
+ # Spatial sampling changed
+ self.nSamplePoints.valueChanged.connect(self.onTimeEstimateParametersChanged)
+
+ # Q sampling changed
+ # self.useLogQ.clicked.connect(self.updateQSampling)
+ # self.qMinBox.textChanged.connect(self.updateQSampling)
+ # self.qMaxBox.textChanged.connect(self.updateQSampling)
+ # self.qSamplesBox.valueChanged.connect(self.updateQSampling)
+
+ self.qSamplesBox.valueChanged.connect(self.onTimeEstimateParametersChanged)
+
+ #
+ # Output Tabs
+ #
+
+ self.outputCanvas = QCanvas()
+
+ outputLayout = QtWidgets.QVBoxLayout()
+ outputLayout.addWidget(self.outputCanvas)
+
+ self.qSpaceTab.setLayout(outputLayout)
+
+ #
+ # Misc
+ #
+
+ # Populate tables
+
+ self.structureFactorParametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""])
+ self.structureFactorParametersTable.horizontalHeader().setStretchLastSection(True)
+
+ # Set up variables
+
+ self.last_calculation_time: Optional[float] = None
+ self.last_calculation_n_r: int = 0
+ self.last_calculation_n_q: int = 0
+
+ self.sld_function: Optional[SLDFunction] = None
+ self.sld_coordinate_mapping: Optional[CoordinateSystemTransform] = None
+ self.magnetism_function: Optional[np.ndarray] = None
+ self.magnetism_coordinate_mapping: Optional[np.ndarray] = None
+
+ def onRadiusChanged(self):
+ if self.radiusFromParticleTab.isChecked():
+ self.sampleRadius.setValue(self.functionViewer.radius_control.radius())
+
+ def onTimeEstimateParametersChanged(self):
+ """ Called when the number of samples changes """
+
+ # TODO: This needs to be updated based on the number of angular samples now
+ # Should have a n_points term
+ # Should have a n_points*n_angles
+
+ # Update the estimate of time
+ # Assume the amount of time is just determined by the number of
+ # sample points (the sld calculation is the limiting factor)
+
+ if self.last_calculation_time is not None:
+ time_per_sample = self.last_calculation_time / self.last_calculation_n_r
+
+ est_time = time_per_sample * int(self.nSamplePoints.value()) * int(self.qSamplesBox.value())
+
+ self.timeEstimateLabel.setText(f"Estimated Time: {format_time_estimate(est_time)}")
+
+ def onLoad(self):
+ print("Load clicked")
+
+ def onSave(self):
+ print("Save clicked")
+
+ def doBuild(self):
+ """ Build functionality requested"""
+
+ # Get the text from the window
+ code = self.pythonViewer.toPlainText()
+
+ self.outputViewer.reset()
+
+ try:
+ # TODO: Make solvent SLD available (process code_takes it as a parameter currently)
+
+ # Evaluate code
+ function, xyz_converter, extra_parameter_names, extra_parameter_defs = \
+ process_code(code,
+ text_callback=self.codeText,
+ warning_callback=self.codeError,
+ error_callback=self.codeError)
+
+ if function is None:
+ return False
+
+ # TODO: Magnetism
+ self.parametersTable.update_contents(function, None)
+
+
+ # Vectorise if needed
+ maybe_vectorised = vectorise_sld(
+ function,
+ warning_callback=self.codeWarning,
+ error_callback=self.codeError) # TODO: Deal with args
+
+ if maybe_vectorised is None:
+ return False
+
+ self.functionViewer.setSLDFunction(maybe_vectorised, xyz_converter)
+ self.sld_function = maybe_vectorised
+ self.sld_coordinate_mapping = xyz_converter
+
+
+ now = datetime.now()
+ current_time = now.strftime("%Y-%m-%d %H:%M:%S")
+ self.codeText(f"Built Successfully at {current_time}")
+ return True
+
+ except FunctionDefinitionFailed as e:
+ self.codeError(e.args[0])
+ return False
+
+
+ def angularDistribution(self) -> AngularDistribution:
+ """ Get the AngularDistribution object that represents the GUI selected orientational distribution"""
+ return self.angularSamplingMethodSelector.generate_sampler()
+
+ def qSampling(self) -> QSample:
+ q_min = float(self.qMinBox.text()) # TODO: Use better box
+ q_max = float(self.qMaxBox.text())
+ n_q = int(self.qSamplesBox.value())
+ is_log = bool(self.useLogQ.isChecked())
+
+ return QSample(q_min, q_max, n_q, is_log)
+
+ def spatialSampling(self) -> SpatialDistribution:
+ """ Calculate the spatial sampling object based on current gui settings"""
+ sample_type = self.methodCombo.currentIndex()
+
+ # All the methods need the radius, number of points, etc
+ radius = float(self.sampleRadius.value())
+ n_points = int(self.nSamplePoints.value())
+ seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None
+
+ if sample_type == 0:
+ return GridSampling(radius=radius, desired_points=n_points)
+ # return MixedSphereSample(radius=radius, n_points=n_points, seed=seed)
+
+ elif sample_type == 1:
+ return UniformCubeSampling(radius=radius, desired_points=n_points, seed=seed)
+ # return MixedCubeSample(radius=radius, n_points=n_points, seed=seed)
+
+ else:
+ raise ValueError("Unknown index for spatial sampling method combo")
+
+ def sldDefinition(self) -> SLDDefinition:
+
+ if self.sld_function is not None:
+
+ return SLDDefinition(
+ self.sld_function,
+ self.sld_coordinate_mapping)
+
+ else:
+ raise NotImplementedError("Careful handling of SLD Definitions not implemented yet")
+
+ def magnetismDefinition(self) -> Optional[MagnetismDefinition]:
+ return None
+
+ def particleDefinition(self) -> ParticleDefinition:
+ """ Get the ParticleDefinition object that contains the SLD and magnetism functions """
+
+ return ParticleDefinition(
+ self.sldDefinition(),
+ self.magnetismDefinition())
+
+ def parametersForCalculation(self) -> CalculationParameters:
+ return self._parameterTableModel.calculation_parameters()
+
+ def polarisationVector(self) -> np.ndarray:
+ """ Get a numpy vector representing the GUI specified polarisation vector"""
+ return np.array([0,0,1])
+
+ def currentSeed(self):
+ return self.randomSeed
+
+ def scatteringCalculation(self) -> ScatteringCalculation:
+ """ Get the ScatteringCalculation object that represents the calculation that
+ is to be passed to the solver"""
+ angular_distribution = self.angularDistribution()
+ spatial_sampling = self.spatialSampling()
+ q_sampling = self.qSampling()
+ particle_definition = self.particleDefinition()
+ parameter_definition = self.parametersForCalculation()
+ polarisation_vector = self.polarisationVector()
+ seed = self.currentSeed()
+ bounding_surface_check = self.continuityCheck.isChecked()
+
+ return ScatteringCalculation(
+ q_sampling=q_sampling,
+ angular_sampling=angular_distribution,
+ spatial_sampling_method=spatial_sampling,
+ particle_definition=particle_definition,
+ parameter_settings=parameter_definition,
+ polarisation_vector=polarisation_vector,
+ seed=seed,
+ bounding_surface_sld_check=bounding_surface_check,
+ sample_chunk_size_hint=100_000
+ )
+
+ def doScatter(self):
+ """ Scatter functionality requested"""
+
+ # attempt to build
+ # don't do scattering if build fails
+
+ build_success = self.doBuild()
+
+ self.codeText("Calculating scattering...")
+
+ if build_success:
+ calc = self.scatteringCalculation()
+ try:
+ scattering_result = calculate_scattering(calc)
+
+ # Time estimates
+ self.last_calculation_time = scattering_result.calculation_time
+ self.last_calculation_n_r = calc.spatial_sampling_method.n_points
+
+ self.onTimeEstimateParametersChanged()
+
+ # Output info
+ self.codeText("Scattering calculation complete after %g seconds."%scattering_result.calculation_time)
+ self.display_calculation_result(scattering_result)
+
+ except Exception:
+ self.codeError(traceback.format_exc())
+
+
+ else:
+ self.codeError("Build failed, scattering cancelled")
+
+ def display_calculation_result(self, scattering_result: ScatteringOutput):
+ """ Update graphs and select tab"""
+
+ # Plot
+ self.outputCanvas.data = scattering_result
+
+ self.tabWidget.setCurrentIndex(5) # Move to output tab if complete
+ def onFit(self):
+ """ Fit functionality requested"""
+ pass
+
+ def codeError(self, text):
+ """ Show an error concerning input code"""
+ self.outputViewer.addError(text)
+
+ def codeText(self, text):
+ """ Show info about input code / code stdout"""
+ self.outputViewer.addText(text)
+
+ def codeWarning(self, text):
+ """ Show a warning about input code"""
+ self.outputViewer.addWarning(text)
+
+ def qSampling(self) -> QSample:
+ """ Calculate the q sampling object based on current gui settings"""
+ is_log = self.useLogQ.isEnabled() and self.useLogQ.isChecked() # Only when it's an option
+ min_q = float(self.qMinBox.text())
+ max_q = float(self.qMaxBox.text())
+ n_samples = int(self.qSamplesBox.value())
+
+ return QSample(min_q, max_q, n_samples, is_log)
+
+
+def main():
+ """ Demo/testing window"""
+
+ from sas.qtgui.convertUI import main
+
+ main()
+
+ app = QtWidgets.QApplication([])
+ window = DesignWindow()
+
+ window.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py
new file mode 100644
index 0000000000..d6d4d9b038
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py
@@ -0,0 +1,405 @@
+import numpy as np
+
+from PySide6 import QtGui, QtWidgets, QtCore
+from PySide6.QtCore import Qt
+
+
+from sas.qtgui.Perspectives.ParticleEditor.LabelledSlider import LabelledSlider
+from sas.qtgui.Perspectives.ParticleEditor.SLDMagnetismOption import SLDMagnetismOption
+from sas.qtgui.Perspectives.ParticleEditor.ViewerButtons import AxisButtons, PlaneButtons
+from sas.qtgui.Perspectives.ParticleEditor.RadiusSelection import RadiusSelection
+
+from sas.qtgui.Perspectives.ParticleEditor.defaults import sld as default_sld
+from sas.qtgui.Perspectives.ParticleEditor.function_processor import spherical_converter
+
+def rotation_matrix(alpha: float, beta: float):
+
+ sa = np.sin(alpha)
+ ca = np.cos(alpha)
+ sb = np.sin(beta)
+ cb = np.cos(beta)
+
+ xz = np.array([
+ [ ca, 0, -sa],
+ [ 0, 1, 0],
+ [ sa, 0, ca]])
+
+ yz = np.array([
+ [1, 0, 0 ],
+ [0, cb, -sb],
+ [0, sb, cb]])
+
+ return np.dot(xz, yz)
+def cross_section_coordinates(radius: float, alpha: float, beta: float, plane_distance: float, n_points: int):
+
+ xy_values = np.linspace(-radius, radius, n_points)
+
+ x, y = np.meshgrid(xy_values, xy_values)
+ x = x.reshape((-1, ))
+ y = y.reshape((-1, ))
+
+ z = np.zeros_like(x) + plane_distance
+
+ xyz = np.vstack((x, y, z))
+
+ r = rotation_matrix(alpha, beta)
+
+ return np.dot(r, xyz).T
+
+def draw_line_in_place(im, x0, y0, dx, dy, channel):
+ """ Simple line drawing (otherwise need to import something heavyweight)"""
+
+ length = np.sqrt(dx * dx + dy * dy)
+ # print(x, y, dx, dy, length)
+
+ if length == 0:
+ return
+
+ for i in range(int(length)):
+ x = int(x0 + i * dx / length)
+ y = int(y0 + i * dy / length)
+ im[y, x, channel] = 255
+
+
+class FunctionViewer(QtWidgets.QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ self.n_draw_layers = 99
+ self.layer_size = 100
+ self.radius = 1
+ self.upscale = 2
+ self._size_px = self.layer_size*self.upscale
+ self.function = default_sld
+ # self.function = lambda x,y,z: x
+ self.coordinate_mapping = spherical_converter
+
+ self.alpha = 0.0
+ self.beta = np.pi
+ self.normal_offset = 0.0
+ self.mag_theta = 0.0
+ self.mag_phi = 0.0
+
+ self._graphics_viewer_offset = 5
+
+
+ #
+ # Qt Setup
+ #
+
+ # Density
+
+ self.densityViewer = QtWidgets.QGraphicsView()
+
+ self.densityScene = QtWidgets.QGraphicsScene()
+ self.densityViewer.setScene(self.densityScene)
+
+ pixmap = QtGui.QPixmap(self._size_px, self._size_px)
+ self.densityPixmapItem = self.densityScene.addPixmap(pixmap)
+
+ self.densityViewer.setFixedWidth(self._size_px + self._graphics_viewer_offset)
+ self.densityViewer.setFixedHeight(self._size_px + self._graphics_viewer_offset)
+ self.densityViewer.setCursor(Qt.OpenHandCursor)
+
+ # Slice
+
+ self.sliceViewer = QtWidgets.QGraphicsView()
+
+ self.sliceScene = QtWidgets.QGraphicsScene()
+ self.sliceViewer.setScene(self.sliceScene)
+
+ pixmap = QtGui.QPixmap(self._size_px, self._size_px)
+ self.slicePixmapItem = self.sliceScene.addPixmap(pixmap)
+
+ self.sliceViewer.setFixedWidth(self._size_px + self._graphics_viewer_offset)
+ self.sliceViewer.setFixedHeight(self._size_px + self._graphics_viewer_offset)
+ self.sliceViewer.setCursor(Qt.OpenHandCursor)
+
+ # General control
+
+ self.radius_control = RadiusSelection("View Radius")
+ self.radius_control.radiusField.valueChanged.connect(self.onRadiusChanged)
+
+ self.plane_buttons = PlaneButtons(self.setAngles)
+
+ self.depth_slider = LabelledSlider("Depth", -100, 100, 0, name_width=35, value_width=35, value_units="%")
+ self.depth_slider.valueChanged.connect(self.onDepthChanged)
+
+ # Magnetism controls
+
+ self.sld_magnetism_option = SLDMagnetismOption()
+ self.sld_magnetism_option.sldOption.clicked.connect(self.onDisplayTypeSelected)
+ self.sld_magnetism_option.magnetismOption.clicked.connect(self.onDisplayTypeSelected)
+
+ self.mag_theta_slider = LabelledSlider("θ", -180, 180, 0)
+ self.mag_theta_slider.valueChanged.connect(self.onMagThetaChanged)
+
+ self.mag_phi_slider = LabelledSlider("φ", 0, 180, 0)
+ self.mag_phi_slider.valueChanged.connect(self.onMagPhiChanged)
+
+ self.mag_buttons = AxisButtons(lambda x,y: None)
+
+ magLayout = QtWidgets.QVBoxLayout()
+
+ magGroup = QtWidgets.QGroupBox("B Field (display)")
+ magGroup.setLayout(magLayout)
+
+ magLayout.addWidget(self.mag_theta_slider)
+ magLayout.addWidget(self.mag_phi_slider)
+ magLayout.addWidget(self.mag_buttons)
+
+ # Main layout
+
+ spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
+ layout = QtWidgets.QVBoxLayout()
+
+ layout.addWidget(self.densityViewer)
+ layout.addWidget(self.plane_buttons)
+ layout.addWidget(self.radius_control)
+ layout.addWidget(self.sliceViewer)
+ layout.addWidget(self.depth_slider)
+ layout.addItem(spacer)
+ layout.addWidget(self.sld_magnetism_option)
+ layout.addWidget(magGroup)
+
+ self.setLayout(layout)
+
+ self.setFixedWidth(self._size_px + 20) # Perhaps a better way of keeping the viewer width small?
+
+ # Mouse response
+ self.densityViewer.viewport().installEventFilter(self)
+ self.sliceViewer.viewport().installEventFilter(self)
+
+ self.lastMouseX = 0
+ self.lastMouseY = 0
+ self.dThetadX = 0.01
+ self.dPhidY = -0.01
+
+ self.radius = self.radius_control.radius()
+
+ # Show images
+ self.updateImage()
+ def eventFilter(self, source, event):
+ """ Event filter intercept, grabs mouse drags on the images"""
+
+ if event.type() == QtCore.QEvent.MouseButtonPress:
+
+ if (source is self.densityViewer.viewport()) or \
+ (source is self.sliceViewer.viewport()):
+
+ x, y = event.pos().x(), event.pos().y()
+ self.lastMouseX = x
+ self.lastMouseY = y
+
+ return
+
+ elif event.type() == QtCore.QEvent.MouseMove:
+ if (source is self.densityViewer.viewport()) or \
+ (source is self.sliceViewer.viewport()):
+
+ x, y = event.pos().x(), event.pos().y()
+ dx = x - self.lastMouseX
+ dy = y - self.lastMouseY
+
+ self.alpha += self.dThetadX * dx
+ self.beta += self.dPhidY * dy
+
+ self.alpha %= 2 * np.pi
+ self.beta %= 2 * np.pi
+
+ self.lastMouseX = x
+ self.lastMouseY = y
+
+ self.updateImage()
+
+ return
+
+ super().eventFilter(source, event)
+
+
+ def onRadiusChanged(self):
+ """ Draw radius changed """
+ self.radius = self.radius_control.radius()
+ self.updateImage()
+
+ def setSLDFunction(self, fun, coordinate_mapping):
+ """ Set the function to be plotted """
+ self.function = fun
+ self.coordinate_mapping = coordinate_mapping
+
+ self.updateImage()
+
+ def onDisplayTypeSelected(self):
+ """ Switch between SLD and magnetism view """
+
+ if self.sld_magnetism_option.magnetismOption.isChecked():
+ print("Magnetic view selected")
+ if self.sld_magnetism_option.sldOption.isChecked():
+ print("SLD view selected")
+
+ # def onThetaChanged(self):
+ # self.theta = np.pi*float(self.theta_slider.value())/180
+ # self.updateImage()
+ # def onPhiChanged(self):
+ # self.phi = np.pi * float(self.phi_slider.value()) / 180
+ # self.updateImage()
+
+ def onMagThetaChanged(self):
+ """ Magnetic field theta angle changed """
+
+ self.mag_theta = np.pi*float(self.mag_theta_slider.value())/180
+ self.updateImage(mag_only=True)
+ def onMagPhiChanged(self):
+ """ Magnetic field phi angle changed """
+ self.mag_phi = np.pi * float(self.mag_phi_slider.value()) / 180
+ self.updateImage(mag_only=True)
+
+ def onDepthChanged(self):
+ """ Callback for cross section depth slider """
+ self.normal_offset = self.radius * float(self.depth_slider.value()) / 100
+ self.updateImage()
+
+ def setAngles(self, alpha_deg, beta_deg):
+ """ Set the viewer angles """
+ self.alpha = np.pi * alpha_deg / 180
+ self.beta = np.pi * (beta_deg + 180) / 180
+
+ self.updateImage()
+
+ def updateImage(self, mag_only=True):
+
+ """ Update the images in the viewer"""
+
+ # Draw density plot
+
+ bg_values = None
+ for depth in np.linspace(-self.radius, self.radius, self.n_draw_layers+2)[1:-1]:
+ sampling = cross_section_coordinates(self.radius, self.alpha, self.beta, depth, self.layer_size)
+ a, b, c = self.coordinate_mapping(sampling[:, 0], sampling[:, 1], sampling[:, 2])
+
+ values = self.function(a, b, c) # TODO: Need to handle parameters properly
+
+ if bg_values is None:
+ bg_values = values
+ else:
+ bg_values += values
+
+ min_value = np.min(bg_values)
+ max_value = np.max(bg_values)
+
+ if min_value == max_value:
+ bg_values = np.ones_like(bg_values) * 0.5
+ else:
+ diff = max_value - min_value
+ bg_values -= min_value
+ bg_values *= (1/diff)
+
+ bg_values = bg_values.reshape((self.layer_size, self.layer_size, 1))
+ bg_image_values = np.array(bg_values * 255, dtype=np.uint8)
+
+ bg_image_values = bg_image_values.repeat(self.upscale, axis=0).repeat(self.upscale, axis=1)
+
+ image = np.concatenate((bg_image_values, bg_image_values, bg_image_values), axis=2)
+
+ self.drawScale(image)
+ self.drawAxes(image)
+
+ height, width, channels = image.shape
+ bytes_per_line = channels * width
+ qimage = QtGui.QImage(image.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888)
+ pixmap = QtGui.QPixmap(qimage)
+ self.densityPixmapItem.setPixmap(pixmap)
+
+
+ # Cross section image
+
+ sampling = cross_section_coordinates(self.radius, self.alpha, self.beta, self.normal_offset, self._size_px)
+ a,b,c = self.coordinate_mapping(sampling[:, 0], sampling[:, 1], sampling[:, 2])
+
+ values = self.function(a,b,c) # TODO: Need to handle parameters properly
+
+ values = values.reshape((self._size_px, self._size_px, 1))
+
+ min_value = np.min(values)
+ max_value = np.max(values)
+
+ if min_value == max_value:
+ values = np.zeros_like(values)
+ else:
+ diff = max_value - min_value
+ values -= min_value
+ values *= (1/diff)
+
+ image_values = np.array(values*255, dtype=np.uint8)
+
+ image = np.concatenate((image_values, image_values, image_values), axis=2)
+
+ self.drawScale(image)
+ self.drawAxes(image)
+
+ height, width, channels = image.shape
+ bytes_per_line = channels * width
+ qimage = QtGui.QImage(image.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888)
+ pixmap = QtGui.QPixmap(qimage)
+ self.slicePixmapItem.setPixmap(pixmap)
+
+
+
+ def drawScale(self, im):
+ """ Draw a scalebar """
+ pass
+
+ def drawAxes(self, im):
+ """ Draw a small xyz axis on an image"""
+ vectors = 20*rotation_matrix(self.alpha, self.beta)
+
+ y = self._size_px - 30
+ x = 30
+
+ # xy coordinates
+ for i in range(1):
+ for j in range(1):
+
+ draw_line_in_place(im, x+i, y+j, vectors[0, 0], vectors[0, 1], 0)
+ draw_line_in_place(im, x+i, y+j, vectors[1, 0], vectors[1, 1], 1)
+ draw_line_in_place(im, x+i, y+j, vectors[2, 0], vectors[2, 1], 2)
+ draw_line_in_place(im, x+i, y+j, vectors[2, 0], vectors[2, 1], 1) # Blue is hard to see, make it cyan
+
+def main():
+ """ Show a demo of the function viewer"""
+
+ from sas.qtgui.Perspectives.ParticleEditor.function_processor import spherical_converter
+ def micelle(r, theta, phi):
+ out = np.zeros_like(r)
+ out[r<1] = 4
+ out[r<0.8] = 1
+ return out
+
+ def cube_function(x, y, z):
+
+ inside = np.logical_and(np.abs(x) <= 0.5,
+ np.logical_and(
+ np.abs(y) <= 0.5,
+ np.abs(z) <= 0.5 ))
+ #
+ # print(cube_function)
+ # print(np.any(inside), np.any(np.logical_not(inside)))
+
+ out = np.zeros_like(x)
+ out[inside] = 1.0
+
+ return out
+
+ def pseudo_orbital(r, theta, phi):
+ return np.exp(-6*r)*r*np.abs(np.cos(2*theta))
+
+ app = QtWidgets.QApplication([])
+ viewer = FunctionViewer()
+ viewer.setSLDFunction(pseudo_orbital, spherical_converter)
+
+ viewer.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/GeodesicSampleSelector.py b/src/sas/qtgui/Perspectives/ParticleEditor/GeodesicSampleSelector.py
new file mode 100644
index 0000000000..854f65f4eb
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/GeodesicSampleSelector.py
@@ -0,0 +1,57 @@
+from typing import Optional
+
+from PySide6.QtWidgets import QSpinBox, QWidget
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic
+
+class GeodesicSamplingSpinBox(QSpinBox):
+ """ SpinBox that only takes values that corresponds to the number of vertices on a geodesic sphere """
+
+ def __init__(self, parent: Optional[QWidget]=None):
+ super().__init__(parent)
+
+ self.setMaximum(
+ Geodesic.points_for_division_amount(
+ Geodesic.minimal_divisions_for_points(99999999)))
+
+ self.setMinimum(
+ Geodesic.points_for_division_amount(1))
+
+ self.n_divisions = 3
+
+ self.editingFinished.connect(self.onEditingFinished)
+
+ self.updateDisplayValue()
+
+
+
+ def updateDisplayValue(self):
+ self.setValue(Geodesic.points_for_division_amount(self.n_divisions))
+
+ def onEditingFinished(self):
+ value = self.value()
+ self.n_divisions = Geodesic.minimal_divisions_for_points(value)
+ self.updateDisplayValue()
+
+ def getNDivisions(self):
+ return self.n_divisions
+
+ def stepBy(self, steps: int):
+
+ self.n_divisions = max([1, self.n_divisions + steps])
+ self.updateDisplayValue()
+
+
+def main():
+ """ Show a demo of the spinner """
+
+ from PySide6 import QtWidgets
+
+ app = QtWidgets.QApplication([])
+ slider = GeodesicSamplingSpinBox()
+ slider.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/LabelledSlider.py b/src/sas/qtgui/Perspectives/ParticleEditor/LabelledSlider.py
new file mode 100644
index 0000000000..0bd2fd8113
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/LabelledSlider.py
@@ -0,0 +1,41 @@
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+
+class LabelledSlider(QtWidgets.QWidget):
+ """ Slider with labels and value text"""
+ def __init__(self, name: str, min_value: int, max_value: int, value: int, tick_interval: int=10, name_width=10, value_width=30, value_units="°"):
+ super().__init__()
+
+ self.units = value_units
+
+ self.name_label = QtWidgets.QLabel(name)
+ self.name_label.setFixedWidth(name_width)
+
+ self.slider = QtWidgets.QSlider(Qt.Horizontal)
+ self.slider.setRange(min_value, max_value)
+ self.slider.setTickInterval(tick_interval)
+ self.slider.valueChanged.connect(self._on_value_changed)
+
+ self.value_label = QtWidgets.QLabel(str(value) + value_units)
+ self.value_label.setFixedWidth(value_width)
+ self.value_label.setAlignment(Qt.AlignRight)
+
+ layout = QtWidgets.QHBoxLayout()
+ layout.addWidget(self.name_label)
+ layout.addWidget(self.slider)
+ layout.addWidget(self.value_label)
+ layout.setContentsMargins(0,0,0,0)
+
+ self.setLayout(layout)
+
+
+ def _on_value_changed(self):
+ self.value_label.setText("%i"%self.value() + self.units)
+
+ @property
+ def valueChanged(self):
+ return self.slider.valueChanged
+
+ def value(self) -> int:
+ return int(self.slider.value())
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py
new file mode 100644
index 0000000000..7560e62d80
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py
@@ -0,0 +1,64 @@
+
+from PySide6 import QtWidgets
+from PySide6.QtGui import QFont
+
+from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter
+from sas.system.version import __version__ as version
+
+initial_text = f"
Particle Editor Log - SasView {version}
"
+
+class OutputViewer(QtWidgets.QTextEdit):
+ """ Python text editor window"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ # System independent monospace font
+ f = QFont("unexistent")
+ f.setStyleHint(QFont.Monospace)
+ f.setPointSize(9)
+ f.setWeight(QFont.Weight(500))
+
+ self.setFont(f)
+
+ self.setText(initial_text)
+
+ def keyPressEvent(self, e):
+ """ Itercepted key press event"""
+
+ # Do nothing
+
+ return
+
+ def _htmlise(self, text):
+ return "
".join(text.split("\n"))
+
+ def appendAndMove(self, text):
+ self.append(text)
+ scrollbar = self.verticalScrollBar()
+ scrollbar.setValue(scrollbar.maximum())
+
+ def reset(self):
+ self.setText(initial_text)
+
+ def addError(self, text):
+ self.appendAndMove(f'{self._htmlise(text)}')
+
+ def addText(self, text):
+ self.appendAndMove(f'{self._htmlise(text)}')
+
+ def addWarning(self, text):
+ self.appendAndMove(f'{self._htmlise(text)}')
+
+
+
+def main():
+ app = QtWidgets.QApplication([])
+ viewer = OutputViewer()
+
+ viewer.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterEntries.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterEntries.py
new file mode 100644
index 0000000000..b006362af1
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterEntries.py
@@ -0,0 +1,91 @@
+from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QLineEdit, QCheckBox, QSizePolicy, QSpacerItem
+from PySide6.QtGui import QDoubleValidator
+from PySide6.QtCore import Qt
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import Parameter, MagnetismParameterContainer
+from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableModel import ParameterTableModel
+
+
+class ParameterEntry(QWidget):
+ """ GUI Entry"""
+
+ def __init__(self, parameter: Parameter, model: ParameterTableModel, base_size=50):
+ super().__init__()
+
+ self._parameter = parameter
+ self._base_size = base_size
+ self._model = model
+
+ self.main_layout = QHBoxLayout()
+ self.main_layout.setContentsMargins(0,0,0,0)
+
+ # Stuff with space either side
+ self.main_layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
+ self._add_items()
+ self.main_layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
+
+ self.setLayout(self.main_layout)
+
+
+
+ def _add_items(self):
+ """ Add the components to this widget """
+ if self._parameter.in_use:
+ parameter_text = self._parameter.name
+ else:
+ parameter_text = f"{self._parameter.name} (unused)"
+
+ self.name_label = QLabel(parameter_text)
+ self.name_label.setFixedWidth(self._base_size)
+ self.name_label.setAlignment(Qt.AlignRight)
+
+ self.value_field = QLineEdit()
+ doubleValidator = QDoubleValidator()
+ self.value_field.setValidator(doubleValidator)
+ self.value_field.setText(str(self._parameter.value))
+ self.value_field.setFixedWidth(75)
+ self.value_field.textEdited.connect(self._update_value)
+
+ self.fit_check = QCheckBox("Fit")
+ self.fit_check.setFixedWidth(40)
+
+ self.main_layout.addWidget(self.name_label)
+ self.main_layout.addWidget(self.value_field)
+ self.main_layout.addWidget(self.fit_check)
+
+
+ def _update_value(self):
+ """ Called when the value is changed"""
+
+ try:
+ value = float(self.value_field.text())
+ self._parameter.value = value
+ print("Set", self._parameter.name, "=", self._parameter.value)
+
+ except ValueError:
+ # Just don't update
+ pass
+
+
+
+
+class MagneticParameterEntry(ParameterEntry):
+
+ def __init__(self, parameter: MagnetismParameterContainer, model: ParameterTableModel):
+ self._entry = parameter
+
+ super().__init__(parameter.parameter, model)
+
+ def _add_items(self):
+ super()._add_items()
+
+ self.link_check = QCheckBox("Link to SLD")
+ self.link_check.setChecked(self._entry.linked)
+ self.link_check.setFixedWidth(80)
+
+ if self._model.can_link(self._parameter.name):
+ self.link_check.setEnabled(True)
+ else:
+ self.link_check.setEnabled(False)
+
+ self.main_layout.addWidget(self.link_check)
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTable.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTable.py
new file mode 100644
index 0000000000..edc5e747bf
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTable.py
@@ -0,0 +1,94 @@
+from typing import Optional
+
+from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QSpacerItem, QSizePolicy
+
+from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableModel import ParameterTableModel
+from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterEntries import ParameterEntry, MagneticParameterEntry
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import SLDFunction, MagnetismFunction
+
+class ParameterTable(QWidget):
+ """ Main table of parameters """
+ def __init__(self, model: ParameterTableModel):
+ super().__init__()
+
+ self._model = model
+
+ self._layout = QVBoxLayout()
+ self.setLayout(self._layout)
+
+ self.build()
+
+ def build(self):
+ """ Build the list of parameters"""
+ # General parameters
+ self._layout.addWidget(self._section_heading("General Parameters"))
+ for parameter in self._model.fixed_parameters:
+ entry = ParameterEntry(parameter, self._model, base_size=150)
+ self._layout.addWidget(entry)
+
+ self._layout.addWidget(self._section_heading("SLD Function Parameters"))
+ for parameter in self._model.sld_parameters:
+ entry = ParameterEntry(parameter, self._model)
+ self._layout.addWidget(entry)
+
+ self._layout.addWidget(self._section_heading("Magnetism Parameters"))
+ for parameter in self._model.magnetism_parameters:
+ entry = MagneticParameterEntry(parameter, self._model)
+ self._layout.addWidget(entry)
+
+ def _section_heading(self, text: str):
+ span = QWidget()
+ layout = QHBoxLayout()
+ label = QLabel(""+text+"")
+
+ layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
+ layout.addWidget(label)
+ layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum))
+
+
+ span.setLayout(layout)
+ return span
+
+ def clear(self):
+ """ Clear the list of parameters """
+ while self._layout.count():
+ child = self._layout.takeAt(0)
+ if child.widget():
+ child.widget().deleteLater()
+
+ def rebuild(self):
+ """ Rebuild the parameter list"""
+ self.clear()
+ self.build()
+
+ def clean(self):
+ """ Clean up the unused parameters in the parameter list """
+ self._model.clean()
+ self.rebuild()
+
+ def update_contents(self, sld_function: SLDFunction, magnetism_function: Optional[MagnetismFunction]):
+ """ Update the contents of the parameter table with new functions"""
+ self._model.update_from_code(sld_function, magnetism_function)
+ self.rebuild()
+
+
+def main():
+ """ Show a demo of the table """
+ from PySide6 import QtWidgets
+ app = QtWidgets.QApplication([])
+
+ model = ParameterTableModel()
+
+ def test_function_1(x, y, z, a, b, c=7): pass
+ def test_function_2(x, y, z, a, d=2, c=5): pass
+
+ model.update_from_code(test_function_1, test_function_2)
+
+ table = ParameterTable(model)
+
+ table.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py
new file mode 100644
index 0000000000..3fb0c2db5d
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py
@@ -0,0 +1,9 @@
+from PySide6.QtWidgets import QWidget
+from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.UI.ParameterTableButtonsUI import Ui_ParameterTableButtons
+
+
+class ParameterTableButtons(QWidget, Ui_ParameterTableButtons):
+ def __init__(self):
+ super().__init__()
+
+ self.setupUi(self)
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableModel.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableModel.py
new file mode 100644
index 0000000000..6fe8176c3e
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableModel.py
@@ -0,0 +1,250 @@
+
+""" Code that handles the parameter list backend """
+
+from typing import Optional, Dict
+
+import inspect
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import SLDFunction, MagnetismFunction
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import (
+ SolventSLD, Background, Scale, FunctionParameter, MagnetismParameterContainer, ValueSource, CalculationParameters)
+
+class LinkingImpossible(Exception): pass
+
+class ParameterTableModel:
+ """
+ Parameter list backend
+
+ The main issues that this class needs to deal with
+ 1) Having values that don't get overridden arbitrarily
+ 2) Magnetism and SLD may or may not have different values for the same parameter name
+ """
+
+ def __init__(self):
+ # General parameters that are always there
+ self.solvent_sld_parameter = SolventSLD()
+ self.background_parameter = Background()
+ self.scale_parameter = Scale()
+
+ self.fixed_parameters = [
+ self.solvent_sld_parameter,
+ self.background_parameter,
+ self.scale_parameter]
+
+ self._sld_parameters: Dict[str, FunctionParameter] = {}
+ self._magnetism_parameters: Dict[str, MagnetismParameterContainer] = {}
+
+ @property
+ def sld_parameters(self):
+ sorted_keys = sorted(self._sld_parameters.keys())
+ return [self._sld_parameters[key] for key in sorted_keys]
+
+ @property
+ def magnetism_parameters(self):
+ sorted_keys = sorted(self._magnetism_parameters.keys())
+ return [self._magnetism_parameters[key] for key in sorted_keys]
+
+ def can_link(self, name: str):
+ """ Can a magnetism be linked to an SLD parameter? """
+ return (name in self._magnetism_parameters) and (name in self._sld_parameters)
+
+ def set_link_status(self, name: str, linked: bool):
+ """ Set the status of the link of a magnetism to a SLD parameter"""
+
+ if name not in self._magnetism_parameters:
+ raise LinkingImpossible(f"{name} is not a magnetic parameter")
+
+ if linked:
+ if name not in self._sld_parameters:
+ raise LinkingImpossible(f"Cannot link parameters with name '{name}' - not an SLD parameter")
+
+ self._magnetism_parameters[name].linked = linked
+
+ def update_from_code(self, sld: SLDFunction, magnetism: Optional[MagnetismFunction]):
+ """ Update the parameter list with sld and magnetism functions """
+
+ # Mark all SLD/magnetism parameters as not in use, we'll mark them as in use if
+ # they are there in the functions passed here
+ for key in self._sld_parameters:
+ self._sld_parameters[key].in_use = False
+
+ for key in self._magnetism_parameters:
+ self._magnetism_parameters[key].parameter.in_use = False
+
+ #
+ # Update base on the SLD function
+ #
+
+ sig = inspect.signature(sld)
+ function_parameters = list(sig.parameters.items())
+
+ if len(function_parameters) < 3:
+ raise ValueError("SLD Function must have at least 3 parameters")
+
+ # First 3 don't count
+ # This is not quite the same as what we need to do for magnetism
+ for parameter_name, parameter_details in function_parameters[3:]:
+
+ if parameter_name in self._sld_parameters:
+ #
+ # Parameter exists
+ #
+
+ param_model = self._sld_parameters[parameter_name]
+
+ # Do we want to update the value? Depends on how it was set
+ if parameter_details.default is not inspect.Parameter.empty:
+ if param_model.set_by == ValueSource.DEFAULT or param_model.set_by == ValueSource.CODE:
+ param_model.value = parameter_details.default
+ param_model.set_by = ValueSource.CODE
+
+ param_model.in_use = True
+
+ else:
+
+ #
+ # Parameter does not exist
+ #
+
+ if parameter_details.default is not inspect.Parameter.empty:
+ new_parameter = FunctionParameter(
+ name=parameter_name,
+ value=parameter_details.default,
+ in_use=True,
+ set_by=ValueSource.CODE)
+
+ else:
+ new_parameter = FunctionParameter(
+ name=parameter_name,
+ value=1.0,
+ in_use=True,
+ set_by=ValueSource.DEFAULT)
+
+ self._sld_parameters[parameter_name] = new_parameter
+
+ #
+ # Magnetism parameters
+ #
+
+ if magnetism is not None:
+ sig = inspect.signature(magnetism)
+ function_parameters = list(sig.parameters.items())
+
+ if len(function_parameters) < 3:
+ raise ValueError("SLD Function must have at least 3 parameters")
+
+ # Again, first 3 don't count
+ for parameter_name, parameter_details in function_parameters[3:]:
+
+ if parameter_name in self._magnetism_parameters:
+
+ #
+ # Parameter exists
+ #
+
+ param_entry = self._magnetism_parameters[parameter_name]
+ param_model = param_entry.parameter
+
+ # Do we want to update the value? Depends on how it was set
+ if parameter_details.default is not inspect.Parameter.empty:
+ if param_model.set_by == ValueSource.DEFAULT or param_model.set_by == ValueSource.CODE:
+ param_model.value = parameter_details.default
+ param_model.set_by = ValueSource.CODE
+
+ param_model.in_use = True
+
+ # Linking status should be unchanged
+
+ else:
+
+ #
+ # Parameter does not exist
+ #
+
+ if parameter_details.default is not inspect.Parameter.empty:
+ new_parameter = FunctionParameter(
+ name=parameter_name,
+ value=parameter_details.default,
+ in_use=True,
+ set_by=ValueSource.CODE)
+
+ # If it has a default, don't link it
+ new_entry = MagnetismParameterContainer(new_parameter, linked=False)
+
+ else:
+
+ new_parameter = FunctionParameter(
+ name=parameter_name,
+ value=1.0,
+ in_use=True,
+ set_by=ValueSource.DEFAULT)
+
+ # If it does not have a default,
+ # link it as long as there is a corresponding SLD parameter
+
+ if parameter_name in self._sld_parameters:
+ new_entry = MagnetismParameterContainer(new_parameter, linked=True)
+ else:
+ new_entry = MagnetismParameterContainer(new_parameter, linked=False)
+
+ self._magnetism_parameters[parameter_name] = new_entry
+
+ def clean(self):
+ """ Remove parameters that are not in use"""
+ for key in self._sld_parameters:
+ if not self._sld_parameters[key].in_use:
+ del self._sld_parameters[key]
+
+ for key in self._magnetism_parameters:
+ if not self._magnetism_parameters[key].parameter.in_use:
+ del self._magnetism_parameters[key]
+
+ # For magnetic parameters that are linked to SLDs, check that
+ # what they are linked to still exists, if not, remove the link flag
+ for key in self._magnetism_parameters:
+ if self._magnetism_parameters[key].linked:
+ if key not in self._sld_parameters:
+ self._magnetism_parameters[key].linked = False
+
+ def calculation_parameters(self) -> CalculationParameters:
+ sld_parameters = {key: self._sld_parameters[key].value
+ for key in self._sld_parameters
+ if self._sld_parameters[key].in_use}
+
+ # Currently assume no bad linking
+ magnetic_parameters_linked: Dict[str, FunctionParameter] = \
+ {key: self._sld_parameters[key]
+ if self._magnetism_parameters[key].linked
+ else self._magnetism_parameters[key].parameter
+ for key in self._magnetism_parameters}
+
+ magnetism_parameters = {key: magnetic_parameters_linked[key].value
+ for key in magnetic_parameters_linked
+ if magnetic_parameters_linked[key].in_use}
+
+ return CalculationParameters(
+ solvent_sld=self.solvent_sld_parameter.value,
+ background=self.background_parameter.value,
+ scale=self.scale_parameter.value,
+ sld_parameters=sld_parameters,
+ magnetism_parameters=magnetism_parameters
+ )
+
+def main():
+
+ def test_function_1(x, y, z, a, b, c=7): pass
+ def test_function_2(x, y, z, a, d=2, c=5): pass
+
+ param_list = ParameterTableModel()
+ param_list.update_from_code(test_function_1, None)
+ param_list.update_from_code(test_function_2, None)
+ param_list.update_from_code(test_function_2, test_function_1)
+
+ for parameter in param_list.sld_parameters:
+ print(parameter)
+
+ for parameter in param_list.magnetism_parameters:
+ print(parameter)
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/UI/ParameterTableButtonsUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/UI/ParameterTableButtonsUI.ui
new file mode 100644
index 0000000000..da9b953102
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/UI/ParameterTableButtonsUI.ui
@@ -0,0 +1,61 @@
+
+
+ ParameterTableButtons
+
+
+
+ 0
+ 0
+ 364
+ 20
+
+
+
+ Form
+
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Remove Unused
+
+
+
+ -
+
+
+ Scatter
+
+
+
+ -
+
+
+ Series
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/CorrelationCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/CorrelationCanvas.py
new file mode 100644
index 0000000000..35f3ec2b80
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/CorrelationCanvas.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from matplotlib.figure import Figure
+
+from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput
+
+
+class CorrelationCanvas(FigureCanvas):
+ """ Plot window for output from scattering calculations"""
+
+ def __init__(self, parent=None, width=5, height=4, dpi=100):
+ self.parent = parent
+ self.fig = Figure(figsize=(width, height), dpi=dpi)
+ self.axes = self.fig.add_subplot(111)
+
+ FigureCanvas.__init__(self, self.fig)
+
+ self._data: Optional[ScatteringOutput] = None
+
+ @property
+ def data(self):
+ return self._data
+
+ @data.setter
+ def data(self, scattering_output: ScatteringOutput):
+
+ self._data = scattering_output
+
+ self.axes.cla()
+
+ if self._data.q_space is not None:
+ if self._data.q_space.correlation_data is not None:
+
+ plot_data = scattering_output.q_space.correlation_data
+
+ r = plot_data.abscissa
+ rho = plot_data.ordinate
+
+ self.axes.plot(r, rho)
+
+ import numpy as np
+
+ R = 50
+
+ f1 = lambda r: 1 - (3 / 4) * (r / R) + (1 / 16) * (r / R) ** 3
+ def f(x):
+ out = np.zeros_like(x)
+ x_in = x[x <= 2 * R]
+ out[x <= 2 * R] = f1(x_in)
+ return out
+
+ self.axes.plot(r, f(r)/6)
+
+
+ self.draw()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py
new file mode 100644
index 0000000000..95a3b67bc2
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import numpy as np
+from typing import Optional
+
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from matplotlib.figure import Figure
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringOutput
+
+def spherical_form_factor(q, r):
+ rq = r * q
+ f = (np.sin(rq) - rq * np.cos(rq)) / (rq ** 3)
+ return f * f
+
+class QCanvas(FigureCanvas):
+ """ Plot window for output from scattering calculations"""
+
+ def __init__(self, parent=None, width=5, height=4, dpi=100):
+ self.parent = parent
+ self.fig = Figure(figsize=(width, height), dpi=dpi)
+ self.axes = self.fig.add_subplot(111)
+
+ FigureCanvas.__init__(self, self.fig)
+
+ self._data: Optional[ScatteringOutput] = None
+
+ @property
+ def data(self):
+ return self._data
+
+ @data.setter
+ def data(self, scattering_output: ScatteringOutput):
+
+ # print("Setting QPlot Data")
+
+ self._data = scattering_output
+ self.axes.cla()
+
+ if self._data.q_space is not None:
+ # print(self._data.q_space)
+ plot_data = self._data.q_space
+
+ q_sample = plot_data.abscissa
+ q_values = q_sample()
+ i_values = plot_data.ordinate
+
+ if q_sample.is_log:
+ self.axes.loglog(q_values, i_values)
+ #
+ # self.axes.axvline(0.07)
+ # self.axes.axvline(0.13)
+ # self.axes.axvline(0.19)
+ # self.axes.axvline(0.25)
+ # self.axes.axvline(0.30)
+
+ # For comparisons: TODO: REMOVE
+ # thing = spherical_form_factor(q_values, 50)
+ # self.axes.loglog(q_values, thing*np.max(i_values)/np.max(thing))
+ # It works, NICE!
+ else:
+ self.axes.semilogy(q_values, i_values)
+
+
+
+ self.draw()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py
new file mode 100644
index 0000000000..bbaf5ed0da
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from matplotlib.figure import Figure
+
+from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput
+
+
+class RDFCanvas(FigureCanvas):
+ """ Plot window for output from scattering calculations"""
+
+ def __init__(self, parent=None, width=5, height=4, dpi=100):
+ self.parent = parent
+ self.fig = Figure(figsize=(width, height), dpi=dpi)
+ self.axes = self.fig.add_subplot(111)
+
+ FigureCanvas.__init__(self, self.fig)
+
+ self._data: Optional[ScatteringOutput] = None
+
+ @property
+ def data(self):
+ return self._data
+
+ @data.setter
+ def data(self, scattering_output: ScatteringOutput):
+
+ self._data = scattering_output
+
+ self.axes.cla()
+
+ if self._data.radial_distribution is not None:
+
+ self.axes.plot(self._data.radial_distribution[0], self._data.radial_distribution[1])
+
+ self.draw()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/SamplingDistributionCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/SamplingDistributionCanvas.py
new file mode 100644
index 0000000000..6c061b4eb0
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/SamplingDistributionCanvas.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from typing import Optional
+
+from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
+from matplotlib.figure import Figure
+
+from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput
+
+
+class SamplingDistributionCanvas(FigureCanvas):
+ """ Plot window for output from scattering calculations"""
+
+ def __init__(self, parent=None, width=5, height=4, dpi=100):
+ self.parent = parent
+ self.fig = Figure(figsize=(width, height), dpi=dpi)
+ self.axes = self.fig.add_subplot(111)
+
+ FigureCanvas.__init__(self, self.fig)
+
+ self._data: Optional[ScatteringOutput] = None
+
+ @property
+ def data(self):
+ return self._data
+
+ @data.setter
+ def data(self, scattering_output: ScatteringOutput):
+
+ self._data = scattering_output
+
+ self.axes.cla()
+
+ for datum in self._data.sampling_distributions:
+
+ self.axes.plot(datum.bin_edges, datum.counts, label=datum.name)
+
+ self.axes.legend()
+
+ self.draw()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py
new file mode 100644
index 0000000000..a4562c5ef4
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py
@@ -0,0 +1,62 @@
+
+from PySide6 import QtWidgets, QtGui
+from PySide6.QtCore import Qt, Signal, QObject
+from PySide6.QtGui import QFont
+
+
+from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter
+
+from sas.qtgui.Perspectives.ParticleEditor.defaults import default_text
+class PythonViewer(QtWidgets.QTextEdit):
+ """ Python text editor window"""
+
+ build_trigger = Signal()
+
+ def __init__(self, parent=None):
+ super().__init__()
+
+ # System independent monospace font
+ f = QFont("unexistent")
+ f.setStyleHint(QFont.Monospace)
+ f.setPointSize(10)
+ f.setWeight(QFont.Weight(700))
+ self.setFont(f)
+
+ self.code_highlighter = PythonHighlighter(self.document())
+
+ self.setText(default_text)
+
+
+ def keyPressEvent(self, e):
+ """ Itercepted key press event"""
+ if e.key() == Qt.Key_Tab:
+
+ if e.modifiers() == Qt.ShiftModifier:
+ # TODO: Multiline adjust, tab and shift-tab
+ pass
+
+ # Swap out tabs for four spaces
+ self.textCursor().insertText(" ")
+ return
+
+ if e.key() == Qt.Key_Return and e.modifiers() == Qt.ShiftModifier:
+ self.build_trigger.emit()
+
+ else:
+ super().keyPressEvent(e)
+
+ def insertFromMimeData(self, source):
+ """ Keep own highlighting"""
+ self.insertPlainText(source.text())
+
+def main():
+ """ Demo/testing window"""
+ app = QtWidgets.QApplication([])
+ viewer = PythonViewer()
+
+ viewer.show()
+ app.exec_()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/RadiusSelection.py b/src/sas/qtgui/Perspectives/ParticleEditor/RadiusSelection.py
new file mode 100644
index 0000000000..f5cde4932c
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/RadiusSelection.py
@@ -0,0 +1,18 @@
+from typing import Optional
+
+from PySide6 import QtWidgets
+
+from sas.qtgui.Perspectives.ParticleEditor.UI.RadiusSelectionUI import Ui_RadiusSelection
+
+class RadiusSelection(QtWidgets.QWidget, Ui_RadiusSelection):
+ def __init__(self, text: Optional[str]=None, parent=None):
+ super().__init__()
+
+ self.setupUi(self)
+
+ if text is not None:
+ self.label.setText(text)
+
+
+ def radius(self):
+ return float(self.radiusField.value())
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/SLDMagnetismOption.py b/src/sas/qtgui/Perspectives/ParticleEditor/SLDMagnetismOption.py
new file mode 100644
index 0000000000..90bda19e05
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/SLDMagnetismOption.py
@@ -0,0 +1,8 @@
+from PySide6 import QtWidgets
+from sas.qtgui.Perspectives.ParticleEditor.UI.SLDMagOptionUI import Ui_SLDMagnetismOption
+
+class SLDMagnetismOption(QtWidgets.QWidget, Ui_SLDMagnetismOption):
+ def __init__(self, parent=None):
+ super().__init__()
+
+ self.setupUi(self)
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/AxisButtonsUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/AxisButtonsUI.ui
new file mode 100644
index 0000000000..d742ad1035
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/AxisButtonsUI.ui
@@ -0,0 +1,72 @@
+
+
+ AxisSelection
+
+
+
+ 0
+ 0
+ 100
+ 19
+
+
+
+ Form
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+
+ 30
+ 0
+
+
+
+ X
+
+
+
+ -
+
+
+
+ 30
+ 0
+
+
+
+ Y
+
+
+
+ -
+
+
+
+ 30
+ 0
+
+
+
+ Z
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui
new file mode 100644
index 0000000000..1a137e0686
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui
@@ -0,0 +1,77 @@
+
+
+ CodeToolBar
+
+
+
+ 0
+ 0
+ 460
+ 20
+
+
+
+ Form
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+ Load
+
+
+
+ -
+
+
+ Save
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ <html><head/><body><p>Build the current code</p><p>Shortcut: shift-Enter</p></body></html>
+
+
+ Build
+
+
+
+ -
+
+
+ Scatter
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui
new file mode 100644
index 0000000000..bc0dd39ce1
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui
@@ -0,0 +1,502 @@
+
+
+ DesignWindow
+
+
+
+ 0
+ 0
+ 992
+ 477
+
+
+
+ Form
+
+
+ -
+
+
+
+
+
+ 0
+
+
+
+ Definition
+
+
+
+
+ Parameters
+
+
+
-
+
+
+
+
+
+
+ Ensemble
+
+
+ -
+
+
+ 0
+
+
-
+
+
+ 10
+
+
-
+
+
+ 10
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ 10
+
+
+ 10
+
+
-
+
+
+ Structure Factor
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ -
+
+
+ Orientational Distribution
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Structure Factor Parameters
+
+
+ Qt::AlignCenter
+
+
+
+ -
+
+
+ 1
+
+
+ 6
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Calculation
+
+
+ -
+
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ 0
+
+
-
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+ -
+
+
+
-
+
+
+ Q Max
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ Ang<sup>-1</sup>
+
+
+
+ -
+
+
+ 10
+
+
+ 10000
+
+
+ 10
+
+
+ 200
+
+
+
+ -
+
+
+ Logaritmic
+
+
+ true
+
+
+
+ -
+
+
+ Neutron Polarisation
+
+
+
+ -
+
+
+ 0.5
+
+
+
+ -
+
+
+ Q Samples
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
-
+
+
+ 1
+
+
+
+ -
+
+
+ 0
+
+
+
+ -
+
+
+ 0
+
+
+
+
+
+ -
+
+
+ Check sampling boundary for SLD continuity
+
+
+ true
+
+
+
+ -
+
+
+
+
+
+ Qt::AlignCenter
+
+
+
+ -
+
+
+ -
+
+
-
+
+
+ false
+
+
+ 0.100000000000000
+
+
+ 100000.000000000000000
+
+
+ 100.000000000000000
+
+
+
+ -
+
+
+ Get From 'Definition' Tab
+
+
+ true
+
+
+
+
+
+ -
+
+
+ Sample Radius
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ Q Min
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ Sample Points
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ Sample Method
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ Random Seed
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
-
+
+
+ 0
+
+
+
+ -
+
+
+ Fix Seed
+
+
+
+
+
+ -
+
+
-
+
+
+ 100000000
+
+
+ 1000
+
+
+ 100000
+
+
+
+ -
+
+
+
+
+
+
+
+
+ -
+
+
+ Ang<sup>-1</sup>
+
+
+
+ -
+
+
+ 0.0005
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
+
+
+ Fitting
+
+
+
+
+ Q Space
+
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/PlaneButtonsUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/PlaneButtonsUI.ui
new file mode 100644
index 0000000000..23bacb1601
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/PlaneButtonsUI.ui
@@ -0,0 +1,72 @@
+
+
+ PlaneSelection
+
+
+
+ 0
+ 0
+ 100
+ 19
+
+
+
+ Form
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+
+ 30
+ 0
+
+
+
+ XY
+
+
+
+ -
+
+
+
+ 30
+ 0
+
+
+
+ YZ
+
+
+
+ -
+
+
+
+ 30
+ 0
+
+
+
+ XZ
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui
new file mode 100644
index 0000000000..dddf1e7151
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui
@@ -0,0 +1,86 @@
+
+
+ RadiusSelection
+
+
+
+ 0
+ 0
+ 265
+ 20
+
+
+
+ Form
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Radius
+
+
+
+ -
+
+
+ 0.100000000000000
+
+
+ 100000.000000000000000
+
+
+ 100.000000000000000
+
+
+
+ -
+
+
+ Å
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/SLDMagOptionUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/SLDMagOptionUI.ui
new file mode 100644
index 0000000000..1d2d24de5c
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/SLDMagOptionUI.ui
@@ -0,0 +1,50 @@
+
+
+ SLDMagnetismOption
+
+
+
+ 0
+ 0
+ 104
+ 16
+
+
+
+ Form
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+ SLD
+
+
+ true
+
+
+
+ -
+
+
+ Magnetism
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/download-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/download-icon.png
new file mode 100644
index 0000000000..8e83a48250
Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/download-icon.png differ
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/hammer-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/hammer-icon.png
new file mode 100644
index 0000000000..af93d23923
Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/hammer-icon.png differ
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc
new file mode 100644
index 0000000000..624ba0a01a
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc
@@ -0,0 +1,9 @@
+
+
+ download-icon.png
+ upload-icon.png
+ save-icon.png
+ hammer-icon.png
+ scatter-icon.png
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/save-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/save-icon.png
new file mode 100644
index 0000000000..d84dd60913
Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/save-icon.png differ
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/scatter-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/scatter-icon.png
new file mode 100644
index 0000000000..1034d66ae3
Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/scatter-icon.png differ
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/upload-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/upload-icon.png
new file mode 100644
index 0000000000..5de23c53c4
Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/upload-icon.png differ
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ViewerButtons.py b/src/sas/qtgui/Perspectives/ParticleEditor/ViewerButtons.py
new file mode 100644
index 0000000000..5d24ea38fe
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/ViewerButtons.py
@@ -0,0 +1,39 @@
+from typing import Callable
+
+from PySide6 import QtWidgets
+from sas.qtgui.Perspectives.ParticleEditor.UI.AxisButtonsUI import Ui_AxisSelection
+from sas.qtgui.Perspectives.ParticleEditor.UI.PlaneButtonsUI import Ui_PlaneSelection
+
+x_angles = (90, 0)
+y_angles = (0, 90)
+z_angles = (0, 0)
+
+
+class PlaneButtons(QtWidgets.QWidget, Ui_PlaneSelection):
+ """ XY, XZ, YZ plane selection buttons, sets angles """
+
+ def __init__(self, set_angles_function: Callable[[float, float], None]):
+ super().__init__()
+ self.setupUi(self)
+
+ self.setAngles = set_angles_function
+
+ self.selectXY.clicked.connect(lambda: self.setAngles(*z_angles))
+ self.selectYZ.clicked.connect(lambda: self.setAngles(*x_angles))
+ self.selectXZ.clicked.connect(lambda: self.setAngles(*y_angles))
+
+
+
+class AxisButtons(QtWidgets.QWidget, Ui_AxisSelection):
+ """ X, Y, Z axis selection buttons, sets angles """
+
+ def __init__(self, set_angles_function: Callable[[float, float], None]):
+ super().__init__()
+ self.setupUi(self)
+
+ self.setAngles = set_angles_function
+
+ self.selectX.clicked.connect(lambda: self.setAngles(*x_angles))
+ self.selectY.clicked.connect(lambda: self.setAngles(*y_angles))
+ self.selectZ.clicked.connect(lambda: self.setAngles(*z_angles))
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py
new file mode 100644
index 0000000000..02c60e81bb
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py
@@ -0,0 +1,40 @@
+import numpy as np
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringCalculation
+
+
+def check_sld_continuity_at_boundary(calculation: ScatteringCalculation, tol=1e-9):
+ """ Checks the continuity of the SLD at the sampling boundary
+
+ :returns: True if boundary conditions are good
+ """
+
+ expected_sld = calculation.parameter_settings.solvent_sld
+
+ x, y, z = calculation.spatial_sampling_method.bounding_surface_check_points()
+
+ a, b, c = calculation.particle_definition.sld.to_cartesian_conversion(x, y, z)
+
+ parameters = calculation.parameter_settings.sld_parameters
+ slds = calculation.particle_definition.sld.sld_function(a, b, c, **parameters)
+
+ return np.all(np.abs(slds - expected_sld) < tol)
+
+
+def check_mag_zero_at_boundary(calculation: ScatteringCalculation, tol=1e-9):
+ """ Checks the magnetism vector is zero at the sampling boundary
+
+ :returns: True if boundary conditions are good"""
+
+ if calculation.particle_definition.magnetism is None:
+ return True
+
+ x, y, z = calculation.spatial_sampling_method.bounding_surface_check_points()
+
+ a, b, c = calculation.particle_definition.sld.to_cartesian_conversion(x, y, z)
+
+ parameters = calculation.parameter_settings.sld_parameters
+ mag_vectors = calculation.particle_definition.sld.sld_function(a, b, c, **parameters)
+
+ # TODO: Double check this is the right axis, should be quite obvious if it isn't
+ return np.all(np.sum(mag_vectors**2, axis=1) < tol**2)
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py
new file mode 100644
index 0000000000..15259c6f0d
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py
@@ -0,0 +1,68 @@
+import time
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import \
+ ScatteringCalculation, QSpaceScattering, ScatteringOutput
+from sas.qtgui.Perspectives.ParticleEditor.calculations.fq import scattering_via_fq
+from sas.qtgui.Perspectives.ParticleEditor.calculations.boundary_check import (
+ check_sld_continuity_at_boundary, check_mag_zero_at_boundary)
+
+class SLDBoundaryMismatch(Exception):
+ pass
+
+class MagBoundaryNonZero(Exception):
+ pass
+
+
+def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput:
+
+ start_time = time.time()
+
+ # If required, check that SLD/Mag at the boundary of the sample volume matches the rest of the space
+ if calculation.bounding_surface_sld_check:
+ if not check_sld_continuity_at_boundary(calculation):
+ raise SLDBoundaryMismatch("SLD at sampling boundary does not match solvent SLD")
+
+ if not check_mag_zero_at_boundary(calculation):
+ raise MagBoundaryNonZero("Magnetism is non-zero at sampling boundary")
+
+ # Perform the calculation
+ sld_def = calculation.particle_definition.sld
+ mag_def = calculation.particle_definition.magnetism
+ params = calculation.parameter_settings
+ spatial_dist = calculation.spatial_sampling_method
+ q_dist = calculation.q_sampling
+ angular_dist = calculation.angular_sampling
+
+ scattering = scattering_via_fq(
+ sld_definition=sld_def,
+ magnetism_definition=mag_def,
+ parameters=params,
+ point_generator=spatial_dist,
+ q_sample=q_dist,
+ angular_distribution=angular_dist)
+
+ q_data = QSpaceScattering(q_dist, scattering)
+
+ output = ScatteringOutput(
+ q_space=q_data,
+ calculation_time=time.time() - start_time,
+ seed_used=None)
+
+ return output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py
new file mode 100644
index 0000000000..6d3d7e9d68
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py
@@ -0,0 +1,122 @@
+from typing import Optional, Tuple
+import time
+
+import numpy as np
+from scipy.interpolate import interp1d
+from scipy.special import jv as bessel
+from scipy.spatial.distance import cdist
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import (
+ ScatteringCalculation, ScatteringOutput, OrientationalDistribution, SamplingDistribution,
+ QSpaceScattering, QSpaceCalcDatum, RealSpaceScattering,
+ SLDDefinition, MagnetismDefinition, SpatialSample, QSample, CalculationParameters)
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk, pairwise_chunk_iterator
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution
+
+from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism
+
+def debye(
+ sld_definition: SLDDefinition,
+ magnetism_definition: Optional[MagnetismDefinition],
+ parameters: CalculationParameters,
+ point_generator: SpatialDistribution,
+ q_sample: QSample,
+ minor_chunk_size=1000,
+ preallocate=True):
+
+ q = q_sample()
+
+ output = np.zeros_like(q)
+
+
+ # First chunking layer, chunks for dealing with VERY large sample densities
+ # Uses less memory at the cost of using more processing power
+ for (x1_large, y1_large, z1_large), (x2_large, y2_large, z2_large) in SingleChunk(point_generator):
+ sld1 = run_sld(sld_definition, parameters, x1_large, y1_large, z1_large)
+ sld2 = run_sld(sld_definition, parameters, x2_large, y2_large, z2_large)
+
+ if magnetism_definition is None:
+
+ sld1_total_large = sld1
+ sld2_total_large = sld2
+
+ else:
+ raise NotImplementedError("Magnetism not implemented yet")
+ # TODO: implement magnetism
+
+
+
+ # TODO: Preallocation, do we want it
+ if preallocate:
+ r_data_1 = np.zeros((minor_chunk_size, minor_chunk_size))
+ r_data_2 = np.zeros((minor_chunk_size, minor_chunk_size))
+ rq_data = np.zeros((minor_chunk_size**2, q_sample.n_points))
+
+ for one, two in pairwise_chunk_iterator(
+ left_data=(x1_large, y1_large, z1_large, sld1_total_large),
+ right_data=(x2_large, y2_large, z2_large, sld2_total_large),
+ chunk_size=minor_chunk_size):
+
+ (x1, y1, z1, sld_total_1) = one
+ (x2, y2, z2, sld_total_2) = two
+
+ # Optimised calculation of euclidean distance, use pre-allocated memory
+
+ if preallocate:
+ n1 = len(x1)
+ n2 = len(x2)
+
+ r_data_1[:n1, :n2] = np.subtract.outer(x1, x2)
+ r_data_1 **= 2
+
+ r_data_2[:n1, :n2] = np.subtract.outer(y1, y2)
+ r_data_1[:n1, :n2] += r_data_2[:n1, :n2] ** 2
+
+ r_data_2[:n1, :n2] = np.subtract.outer(z1, z2)
+ r_data_1[:n1, :n2] += r_data_2[:n1, :n2] ** 2
+
+ np.sqrt(r_data_1, out=r_data_1) # in place sqrt
+
+ r_data_2[:n1, :n2] = np.multiply.outer(sld_total_1, sld_total_2)
+
+ # Build a table of r times q values
+
+ rq_data[:(n1 * n2), :] = np.multiply.outer(r_data_1[:n1, :n2].reshape(-1), q)
+
+ # Calculate sinc part
+ rq_data[:(n1 * n2), :] = np.sinc(rq_data[:(n1 * n2), :])
+
+
+ # Multiply by paired density
+ rq_data[:(n1 * n2), :] *= r_data_2[:n1, :n2].reshape(-1, 1)
+
+
+ # Multiply by r squared
+ np.multiply(r_data_1, r_data_1, out=r_data_1)
+ rq_data[:(n1 * n2), :] *= r_data_1[:n1, :n2].reshape(-1, 1)
+
+ # Add to q data
+ output += np.sum(rq_data, axis=0)
+
+ else:
+
+ # Non optimised
+ r_squared = np.subtract.outer(x1, x2) ** 2 + \
+ np.subtract.outer(y1, y2) ** 2 + \
+ np.subtract.outer(z1, z2) ** 2
+
+ # print(r_squared.shape)
+
+ correl = sld_total_1.reshape(1, -1) * sld_total_2.reshape(-1, 1)
+ correl = correl.reshape(-1, 1)
+
+ rq = np.sqrt(r_squared.reshape(-1, 1)) * q.reshape(1,-1)
+
+ # print(rq.shape)
+
+ output += np.sum(r_squared.reshape(-1, 1) * correl * np.sinc(rq), axis=0)
+ # output += np.sum(r_squared.reshape(-1, 1) * correl * np.sinc(rq), axis=0)
+
+
+ return output
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py
new file mode 100644
index 0000000000..a4badafed1
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py
@@ -0,0 +1,57 @@
+import numpy as np
+import time
+import matplotlib.pyplot as plt
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import SLDDefinition, CalculationParameters, QSample
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid
+
+from sas.qtgui.Perspectives.ParticleEditor.calculations.debye import debye
+
+def sld(x, y, z):
+ """ Cube sld """
+ out = np.ones_like(x)
+ inds = np.logical_and(
+ np.logical_and(
+ np.abs(x) > 50,
+ np.abs(y) > 50),
+ np.abs(z) > 50)
+ out[inds] = 0
+ return out
+
+def transform(x,y,z):
+ return x,y,z
+
+sld_def = SLDDefinition(sld_function=sld, to_cartesian_conversion=transform)
+
+calc_params = CalculationParameters(
+ solvent_sld=0.0,
+ background=0.0,
+ scale=1.0,
+ sld_parameters={},
+ magnetism_parameters={})
+
+point_generator = Grid(100, 10_000)
+
+q = QSample(1e-3, 1, 101, True)
+
+for chunk_size in [1000]:
+ for preallocate in [False, True]:
+
+ print("Chunk size %i%s"%(chunk_size, ", preallocate" if preallocate else ""))
+
+ start_time = time.time()
+
+ output = debye(
+ sld_definition=sld_def,
+ magnetism_definition=None,
+ parameters=calc_params,
+ point_generator=point_generator,
+ q_sample=q,
+ minor_chunk_size=chunk_size,
+ preallocate=preallocate)
+
+ print(time.time() - start_time)
+
+ plt.loglog(q(), output)
+
+plt.show()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py
new file mode 100644
index 0000000000..f94404e42a
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py
@@ -0,0 +1,54 @@
+from typing import Optional, Tuple
+import time
+
+import numpy as np
+from scipy.interpolate import interp1d
+from scipy.special import jv as bessel
+from scipy.spatial.distance import cdist
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import (
+ SLDDefinition, MagnetismDefinition, AngularDistribution, QSample, CalculationParameters)
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk, pairwise_chunk_iterator
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution, PointGeneratorStepper
+
+from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism
+
+def scattering_via_fq(
+ sld_definition: SLDDefinition,
+ magnetism_definition: Optional[MagnetismDefinition],
+ parameters: CalculationParameters,
+ point_generator: SpatialDistribution,
+ q_sample: QSample,
+ angular_distribution: AngularDistribution,
+ chunk_size=1_000_000) -> np.ndarray:
+
+ q_magnitudes = q_sample()
+
+ direction_vectors, direction_weights = angular_distribution.sample_points_and_weights()
+ fq = np.zeros((angular_distribution.n_points, q_sample.n_points), dtype=complex) # Dictionary for fq for all angles
+
+ for x, y, z in PointGeneratorStepper(point_generator, chunk_size):
+
+ sld = run_sld(sld_definition, parameters, x, y, z).reshape(-1, 1)
+
+ # TODO: Magnetism
+
+ for direction_index, direction_vector in enumerate(direction_vectors):
+
+ projected_distance = x*direction_vector[0] + y*direction_vector[1] + z*direction_vector[2]
+
+ i_r_dot_q = np.multiply.outer(projected_distance, 1j*q_magnitudes)
+
+ if direction_index in fq:
+ fq[direction_index, :] = np.sum(sld*np.exp(i_r_dot_q), axis=0)
+ else:
+ fq[direction_index, :] += np.sum(sld*np.exp(i_r_dot_q), axis=0)
+
+ f_squared = fq.real**2 + fq.imag**2
+ f_squared *= direction_weights.reshape(-1,1)
+
+ return np.sum(f_squared, axis=0)
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py
new file mode 100644
index 0000000000..4f4225340c
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py
@@ -0,0 +1,34 @@
+""" Helper functions that run SLD and magnetism functions """
+import numpy as np
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import (SLDDefinition, MagnetismDefinition, CalculationParameters)
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3
+
+
+def run_sld(sld_definition: SLDDefinition, parameters: CalculationParameters, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray:
+ """ Evaluate the SLD function from the definition object at specified coordinates """
+
+ sld_function = sld_definition.sld_function
+ coordinate_transform = sld_definition.to_cartesian_conversion
+
+ a, b, c = coordinate_transform(x, y, z)
+
+ solvent_sld = parameters.solvent_sld # Hopefully the function can see this, but TODO: take py file environment with us from editor
+ parameter_dict = parameters.sld_parameters.copy()
+
+ return sld_function(a, b, c, **parameter_dict) - solvent_sld
+
+
+def run_magnetism(magnetism_definition: MagnetismDefinition, parameters: CalculationParameters, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> VectorComponents3:
+ """ Evaluate the magnetism function from the definition at specified coordinates """
+
+ magnetism_function = magnetism_definition.magnetism_function
+ coordinate_transform = magnetism_definition.to_cartesian_conversion
+
+ a, b, c = coordinate_transform(x, y, z)
+
+ solvent_sld = parameters.solvent_sld # Hopefully the function can see this, but TODO: take py file environment with us from editor
+ parameter_dict = parameters.sld_parameters.copy()
+
+ return magnetism_function(a, b, c, **parameter_dict)
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py
new file mode 100644
index 0000000000..c55ce7f1e1
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py
@@ -0,0 +1,142 @@
+from typing import Optional, Callable, Tuple, Protocol, List
+import numpy as np
+from enum import Enum
+from dataclasses import dataclass
+from abc import ABC, abstractmethod
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import (
+ SLDFunction, MagnetismFunction, CoordinateSystemTransform)
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import CalculationParameters
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3
+
+
+class QSample:
+ """ Representation of Q Space sampling """
+ def __init__(self, start, end, n_points, is_log):
+ self.start = start
+ self.end = end
+ self.n_points = n_points
+ self.is_log = is_log
+
+ def __repr__(self):
+ return f"QSampling({self.start}, {self.end}, n={self.n_points}, is_log={self.is_log})"
+
+ def __call__(self):
+ if self.is_log:
+ start_log = np.log(self.start)
+ end_log = np.log(self.end)
+ return np.exp(np.linspace(start_log, end_log, self.n_points))
+ else:
+ return np.linspace(self.start, self.end, self.n_points)
+
+
+class SpatialDistribution(ABC):
+ """ Base class for point generators """
+
+ def __init__(self, radius: float, n_points: int, n_desired_points):
+ self.radius = radius
+ self.n_desired_points = n_desired_points
+ self.n_points = n_points
+
+ @property
+ def info(self):
+ """ Information to be displayed in the settings window next to the point number input """
+ return ""
+
+ @abstractmethod
+ def generate(self, start_index: int, end_index: int) -> VectorComponents3:
+ """ Generate points from start_index up to end_index """
+
+ @abstractmethod
+ def _bounding_surface_check_points(self) -> np.ndarray:
+ """ Points used to check that the SLD/magnetism vector are zero outside the sample space"""
+
+ def bounding_surface_check_points(self) -> VectorComponents3:
+ pts = self._bounding_surface_check_points()
+ return pts[:, 0], pts[:, 1], pts[:, 2]
+
+class AngularDistribution(ABC):
+ """ Base class for angular distributions """
+
+ @property
+ @abstractmethod
+ def n_points(self) -> int:
+ """ Number of sample points """
+
+ @staticmethod
+ @abstractmethod
+ def name() -> str:
+ """ Name of this distribution """
+
+
+ @staticmethod
+ @abstractmethod
+ def parameters() -> List[Tuple[str, str, type]]:
+ """ List of keyword arguments to constructor, names for GUI, and the type of value"""
+
+ @abstractmethod
+ def sample_points_and_weights(self) -> Tuple[np.ndarray, np.ndarray]:
+ """ Get sample q vector directions and associated weights"""
+
+
+@dataclass
+class SLDDefinition:
+ """ Definition of the SLD scalar field"""
+ sld_function: SLDFunction
+ to_cartesian_conversion: CoordinateSystemTransform
+
+
+@dataclass
+class MagnetismDefinition:
+ """ Definition of the magnetism vector fields"""
+ magnetism_function: MagnetismFunction
+ to_cartesian_conversion: CoordinateSystemTransform
+
+
+@dataclass
+class ParticleDefinition:
+ """ Object containing the functions that define a particle"""
+ sld: SLDDefinition
+ magnetism: Optional[MagnetismDefinition]
+
+
+@dataclass
+class ScatteringCalculation:
+ """ Specification for a scattering calculation """
+ q_sampling: QSample
+ angular_sampling: AngularDistribution
+ spatial_sampling_method: SpatialDistribution
+ particle_definition: ParticleDefinition
+ parameter_settings: CalculationParameters
+ polarisation_vector: Optional[np.ndarray]
+ seed: Optional[int]
+ bounding_surface_sld_check: bool
+ bin_count = 1_000
+ sample_chunk_size_hint: int = 100_000
+
+
+@dataclass
+class SamplingDistribution:
+ name: str
+ bin_edges: np.ndarray
+ counts: np.ndarray
+
+@dataclass
+class QSpaceScattering:
+ abscissa: QSample
+ ordinate: np.ndarray
+
+@dataclass
+class RealSpaceScattering:
+ abscissa: np.ndarray
+ ordinate: np.ndarray
+
+
+@dataclass
+class ScatteringOutput:
+ q_space: Optional[QSpaceScattering]
+ calculation_time: float
+ seed_used: Optional[int]
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py
new file mode 100644
index 0000000000..b926e8b357
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py
@@ -0,0 +1,94 @@
+from enum import Enum
+from typing import NamedTuple
+
+class ValueSource(Enum):
+ """ Item that decribes where the current parameter came from"""
+ DEFAULT = 0
+ CODE = 1
+ MANUAL = 2
+ FIT = 3
+
+class Parameter:
+ """ Base class for parameter descriptions """
+ is_function_parameter = False
+
+ def __init__(self, name: str, value: float):
+ self.name = name
+ self.value = value
+
+ @property
+ def in_use(self):
+ return True
+
+ @in_use.setter
+ def in_use(self, value):
+ raise Exception("in_use is fixed for this parameter, you should not be trying to set it")
+
+ def __repr__(self):
+ in_use_string = "used" if self.in_use else "not used"
+ return f"FunctionParameter({self.name}, {self.value}, {in_use_string})"
+
+class FunctionParameter(Parameter):
+ is_function_parameter = True
+
+ def __init__(self, name: str, value: float, in_use: bool, set_by: ValueSource):
+ """ Representation of an input parameter to the sld
+
+ The set_by variable describes what the last thing to set it was,
+ this allows it to be updated in a sensible way - it should
+ only have its value changed by the code if it was set by the code,
+ or if it has been set to a default value because nothing specified it.
+ """
+ super().__init__(name, value)
+ self._in_use = in_use
+ self.set_by = set_by
+ @property
+ def in_use(self):
+ return self._in_use
+
+ @in_use.setter
+ def in_use(self, value):
+ self._in_use = value
+
+ def __repr__(self):
+ in_use_string = "used" if self.in_use else "not used"
+ return f"FunctionParameter({self.name}, {self.value}, {in_use_string}, {self.set_by})"
+
+#
+# Parameters that exist for all calculations
+#
+
+
+class SolventSLD(Parameter):
+ """ Parameter representing the solvent SLD, always present"""
+ def __init__(self):
+ super().__init__("Solvent SLD", 0.0)
+
+
+class Background(Parameter):
+ """ Parameter representing the background intensity, always present"""
+ def __init__(self):
+ super().__init__("Background Intensity", 0.0001)
+
+
+class Scale(Parameter):
+ """ Parameter representing the scaling, always present"""
+ def __init__(self):
+ super().__init__("Intensity Scaling", 1.0)
+
+
+class MagnetismParameterContainer(NamedTuple):
+ """ Entry describing a magnetism parameter, keeps track of whether it should be linked to an SLD parameter"""
+ parameter: FunctionParameter
+ linked: bool
+
+
+class CalculationParameters(NamedTuple):
+ """ Object containing the parameters for a simulation"""
+ solvent_sld: float
+ background: float
+ scale: float
+ sld_parameters: dict
+ magnetism_parameters: dict
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py
new file mode 100644
index 0000000000..f01a846f56
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py
@@ -0,0 +1,22 @@
+from typing import Tuple, Protocol
+import numpy as np
+
+# 3D vector output as numpy arrays
+VectorComponents3 = Tuple[np.ndarray, np.ndarray, np.ndarray]
+
+
+class SLDFunction(Protocol):
+ """ Type of functions that can represent SLD profiles"""
+ def __call__(self, a: np.ndarray, b: np.ndarray, c: np.ndarray, **kwargs: float) -> np.ndarray: ...
+
+
+class MagnetismFunction(Protocol):
+ """ Type of functions that can represent magnetism profiles"""
+
+ def __call__(self, a: np.ndarray, b: np.ndarray, c: np.ndarray, **kwargs: float) -> VectorComponents3: ...
+
+
+class CoordinateSystemTransform(Protocol):
+ """ Type of functions that can represent a coordinate transform"""
+
+ def __call__(self, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> VectorComponents3: ...
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py b/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py
new file mode 100644
index 0000000000..e59cdb3384
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py
@@ -0,0 +1,20 @@
+import matplotlib.pyplot as plt
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid
+
+
+
+fig = plt.figure("Grid plot")
+ax = fig.add_subplot(projection='3d')
+
+gen = Grid(100, 250)
+
+n_total = gen.n_points
+
+x,y,z = gen.generate(0, n_total//3)
+ax.scatter(x,y,z,color='b')
+
+x,y,z = gen.generate(n_total//3, n_total)
+ax.scatter(x,y,z,color='r')
+
+plt.show()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py
new file mode 100644
index 0000000000..f359a2e7dc
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py
@@ -0,0 +1,58 @@
+import numpy as np
+
+
+def sld(r, theta, phi):
+ inside = r + 20*np.sin(6*theta)*np.cos(3*phi) < 50
+ out = np.zeros_like(r)
+ out[inside] = 1.0
+ return out
+
+
+default_text = '''"""
+
+Here's a new perspective. It calculates the scattering based on real-space description of a particle.
+
+Basically, define your SLD as a function of either cartesian or polar coordinates and click scatter
+
+ def sld(x,y,z)
+ def sld(r,theta,phi)
+
+The display on the right shows your particle, both as a total projected density (top) and as a slice (bottom).
+
+This is a minimal working system. Currently magnetism doesn't work, neither do extra parameters for your functions,
+nor structure factors, nor fitting, nor 2D plots.
+
+Here's a simple example: """
+
+def sld_cube(x,y,z):
+ """ A cube with 100Ang side length"""
+
+ return rect(0.02*x)*rect(0.02*y)*rect(0.02*z)
+
+
+def sld_sphere(r, theta, phi):
+ """ Sphere r=50Ang"""
+
+ return rect(0.02*r)
+
+sld = sld_sphere
+
+'''
+
+""" Press shift-return to build and update views
+ Click scatter to show the scattering profile"""
+#
+# # TODO: REMOVE
+# default_text = """
+#
+# def sld(r,theta,phi):
+# return rect(r/50)
+# """
+
+#
+# # TODO: REMOVE
+# default_text = """
+#
+# def sld(r,theta,phi):
+# return rect(r/50) - 0.5*rect(r/40)
+# """
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py
new file mode 100644
index 0000000000..d02ccc0b2c
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py
@@ -0,0 +1,189 @@
+from typing import Callable
+
+from io import StringIO
+
+import inspect
+from contextlib import redirect_stdout
+import traceback
+
+import numpy as np
+
+from sas.qtgui.Perspectives.ParticleEditor.helper_functions import rect, step
+
+class FunctionDefinitionFailed(Exception):
+ def __init__(self, *args):
+ super()
+
+# Valid parameterisataions of the sld function,
+# because of how things are checked, the names of
+# the fist parameter currently has to be distinct.
+# things could be changed to make it otherwise, but
+# at the moment this seems reasonble
+
+parameter_sets = [["x", "y", "z"], ["r", "theta", "phi"]]
+
+
+def cartesian_converter(x,y,z):
+ """ Converter from calculation coordinates to function definition
+
+ For functions specified in cartesian coordinates
+ """
+ return x,y,z
+
+
+def spherical_converter(x,y,z):
+ """ Converter from calculation coordinates to function definition
+
+ For functions specified in spherical coordinates
+ """
+ r = np.sqrt(x**2 + y**2 + z**2)
+ theta = np.arccos(z/r)
+ phi = np.sign(y)*np.arccos(x / np.sqrt(x**2 + y**2))
+
+ return r, theta, phi
+
+parameter_converters = [cartesian_converter, spherical_converter]
+
+def default_callback(string: str):
+ """ Just for default"""
+ print(string)
+
+
+
+#
+# Main processor
+#
+
+def process_code(input_text: str,
+ solvent_sld: float = 0.0,
+ text_callback: Callable[[str], None] = default_callback,
+ warning_callback: Callable[[str], None] = default_callback,
+ error_callback: Callable[[str], None] = default_callback):
+
+ """ Process the code for generating functions that specify sld/magnetism
+
+ """
+
+ new_locals = {}
+ new_globals = {"np": np, "solvent_sld": solvent_sld, "step": step, "rect": rect}
+
+ stdout_output = StringIO()
+ with redirect_stdout(stdout_output):
+ try:
+ exec(input_text, new_globals, new_locals) # TODO: provide access to solvent SLD somehow
+
+ text_callback(stdout_output.getvalue())
+
+ except Exception:
+
+ text_callback(stdout_output.getvalue())
+ error_callback(traceback.format_exc())
+
+ return None, None, None, None
+ # print(ev)
+ # print(new_globals)
+ # print(new_locals)
+
+ # look for function
+ if "sld" not in new_locals:
+ raise FunctionDefinitionFailed("No function called 'sld' found")
+
+ sld_function = new_locals["sld"]
+
+ if not isinstance(sld_function, Callable):
+ raise FunctionDefinitionFailed("sld object exists, but is not Callable")
+
+ # Check for acceptable signatures
+ sig = inspect.signature(sld_function)
+
+ params = list(sig.parameters.items())
+
+ # check for first parameter being equal to one of the acceptable options
+ # this assumes that there are only
+ parameter_set_index = -1
+ for index, parameter_set in enumerate(parameter_sets):
+ if params[0][0] == parameter_set[0]:
+ parameter_set_index = index
+
+ if parameter_set_index < 0:
+ s = " or ".join([("("+", ".join(v) + ")") for v in parameter_sets])
+ raise FunctionDefinitionFailed("sld function not correctly parameterised, first three parameters must be "+s)
+
+ parameter_set = parameter_sets[parameter_set_index]
+
+ for i in range(3):
+ if params[i][0] != parameter_set[i]:
+ s = ", ".join(parameter_set)
+ raise FunctionDefinitionFailed(f"Parameter {i+1} should be {parameter_set[i]} for ({s}) parameterisation")
+
+ converter = parameter_converters[parameter_set_index]
+
+ #
+ # gather parameters
+ #
+
+ remaining_parameter_names = [x[0] for x in params[3:]]
+
+ return sld_function, converter, remaining_parameter_names, params[3:]
+
+def main():
+
+ test_text_valid_sld_xyz = """
+
+ print("test print string")
+
+ def sld(x,y,z,p1,p2,p3):
+ print(x,y,z)
+
+ """
+
+ test_text_valid_sld_radial = """
+ def sld(r,theta,phi,p1,p2,p3):
+ print(x,y,z)
+
+ """
+
+
+ test_text_invalid_start_sld = """
+ def sld(theta,phi,p1,p2,p3):
+ print(x,y,z)
+
+ """
+
+ test_text_invalid_rest_sld = """
+ def sld(r,p1,p2,p3):
+ print(x,y,z)
+
+ """
+
+ test_sld_class = """
+
+ class SLD:
+ def __call__(self,x,y,z):
+ print(x,y,z)
+
+ sld = SLD()
+
+ """
+
+ test_bad_class = """
+
+ class SLD:
+ def __call__(self,x,y,q):
+ print(x,y,z)
+
+ sld = SLD()
+
+ """
+
+
+ x = process_code(test_text_valid_sld_xyz)
+ # x = process_text(test_text_valid_sld_radial)
+ # x = process_text(test_text_invalid_start_sld)
+ # x = process_text(test_text_invalid_rest_sld)
+ # x = process_text(test_bad_class)
+
+ print(x)
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py b/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py
new file mode 100644
index 0000000000..d32689edc6
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py
@@ -0,0 +1,24 @@
+"""
+
+Functions that get automatically included in the build window
+
+"""
+
+
+import numpy as np
+
+
+def step(x: np.ndarray):
+ """ Step function, 0 if input < 0, 1 if input >= 0"""
+ out = np.ones_like(x)
+ out[x < 0] = 0.0
+ return out
+
+
+def rect(x: np.ndarray):
+ """ Rectangle function, zero if |input| > 1, 1 otherwise"""
+ out = np.zeros_like(x)
+ out[np.abs(x) <= 1] = 1.0
+ return out
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py
new file mode 100644
index 0000000000..6d69de8cbf
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py
@@ -0,0 +1,69 @@
+
+"""
+
+Different methods for sampling the angular distribution of q vectors
+
+A list of the different methods available can be found at the bottom of the code and needs to be updated if new ones
+are added.
+
+
+"""
+
+
+from typing import List, Tuple
+import numpy as np
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import AngularDistribution
+from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic, GeodesicDivisions
+
+
+class ZDelta(AngularDistribution):
+ """ Perfectly oriented sample """
+
+ @staticmethod
+ def name():
+ return "Oriented"
+
+ def sample_points_and_weights(self):
+ return np.array([[0.0, 0.0, -1.0]]), np.array([1.0])
+
+ @property
+ def n_points(self):
+ return 1
+
+ @staticmethod
+ def parameters():
+ return []
+
+ def __repr__(self):
+ return f"ZDelta()"
+
+
+class Uniform(AngularDistribution):
+ """ Spherically averaged sample """
+
+ def __init__(self, geodesic_divisions: int):
+ self.divisions = geodesic_divisions
+ self._n_points = Geodesic.points_for_division_amount(geodesic_divisions)
+
+ @staticmethod
+ def name():
+ return "Unoriented"
+
+ def sample_points_and_weights(self) -> Tuple[np.ndarray, np.ndarray]:
+ return Geodesic.by_divisions(self.divisions)
+
+ @property
+ def n_points(self) -> int:
+ return self._n_points
+
+ @staticmethod
+ def parameters() -> List[Tuple[str, str, type]]:
+ return [("geodesic_divisions", "Angular Samples", GeodesicDivisions)]
+
+ def __repr__(self):
+ return f"Uniform({self.divisions})"
+
+
+angular_sampling_methods = [ZDelta, Uniform]
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py
new file mode 100644
index 0000000000..9bc4adec0f
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py
@@ -0,0 +1,126 @@
+
+
+"""
+
+Functions designed to help avoid using too much memory, chunks up the pairwise distributions
+
+Something like this
+
+
+
+ First Point
+
+ 1 2 3 | 4 5 6 | 7 8 9 | 0
+ 1 | | |
+S | | |
+e 2 CHUNK 1 | CHUNK 2 | CHUNK 3 | CHUNK 4
+c | | |
+o 3 | | |
+n ---------------+-----------+-----------+------------
+d 4 | | |
+ | | |
+P 5 CHUNK 5 | CHUNK 6 | CHUNK 7 | CHUNK 8
+o | | |
+i 6 | | |
+n ---------------+-----------+-----------+------------
+t 7 | | |
+ | | |
+ 8 CHUNK 9 | CHUNK 10 | CHUNK 11 | CHUNK 12
+ | | |
+ 9 | | |
+ ---------------+-----------+-----------+------------
+ 0 | | |
+ CHUNK 13 | CHUNK 14 | CHUNK 15 | CHUNK 16
+ | | |
+"""
+
+
+from typing import Tuple, Sequence, Any
+
+import math
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3
+
+from abc import ABC, abstractmethod
+
+
+class Chunker(ABC):
+ def __init__(self, point_generator: SpatialDistribution):
+ self.point_generator = point_generator
+
+ def __iter__(self):
+ return self._iterator()
+
+ @abstractmethod
+ def _iterator(self) -> Tuple[VectorComponents3, VectorComponents3]:
+ """ Python generator function that yields chunks """
+
+
+class Chunks(Chunker):
+ """ Class that takes a point generator, and produces all pairwise combinations in chunks
+
+ This trades off speed for space.
+ """
+
+ # TODO
+
+
+class SingleChunk(Chunker):
+ """ Chunker that doesn't chunk """
+
+ def _iterator(self):
+ points = self.point_generator.generate(0, self.point_generator.n_points)
+ yield points, points
+
+
+class InputLengthMismatch(Exception):
+ pass
+
+def sublist_lengths(data: Sequence[Sequence[Any]]) -> int:
+ """ Return the length of the """
+ iterator = iter(data)
+ len_0 = len(next(iterator))
+ if not all(len(x) == len_0 for x in iterator):
+ raise InputLengthMismatch("Input lists do not have the same length")
+
+ return len_0
+
+def pairwise_chunk_iterator(
+ left_data: Sequence[Sequence[Any]],
+ right_data: Sequence[Sequence[Any]],
+ chunk_size: int) -> Tuple[Sequence[Sequence[Any]], Sequence[Sequence[Any]]]:
+
+ """ Generator to do chunking as described in the module docs above"""
+
+ left_len = sublist_lengths(left_data)
+ right_len = sublist_lengths(right_data)
+
+ # Ceiling using divmod (because ceil on normal division isn't robust)
+ n_left, rem = divmod(left_len, chunk_size)
+ if rem > 0:
+ n_left += 1
+
+ n_right, rem = divmod(right_len, chunk_size)
+ if rem > 0:
+ n_right += 1
+
+
+ # Iterate through pairs of points
+ for i in range(n_left):
+
+ left_index_1 = i*chunk_size
+ left_index_2 = min(((i+1)*chunk_size, left_len))
+
+ left_chunk = tuple(datum[left_index_1:left_index_2] for datum in left_data)
+
+ for j in range(n_right):
+
+ right_index_1 = j*chunk_size
+ right_index_2 = min(((j+1)*chunk_size, right_len))
+
+ right_chunk = tuple(datum[right_index_1:right_index_2] for datum in right_data)
+
+ # print([i, left_index_1, left_index_2], [j, right_index_1, right_index_2])
+
+ yield left_chunk, right_chunk
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/geodesic.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/geodesic.py
new file mode 100644
index 0000000000..56c00999c6
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/geodesic.py
@@ -0,0 +1,316 @@
+from typing import Tuple
+from collections import defaultdict
+
+import numpy as np
+from scipy.spatial import ConvexHull
+
+_ico_ring_h = np.sqrt(1 / 5)
+_ico_ring_r = np.sqrt(4 / 5)
+
+class Geodesic():
+ """ Points arranged pretty uniformly and regularly on a sphere"""
+
+ #
+ # Need to define an icosahedron, upon which everything else is based
+ #
+
+ _base_vertices = \
+ [(0.0, 0.0, 1.0)] + \
+ [(_ico_ring_r * np.cos(angle), _ico_ring_r * np.sin(angle), _ico_ring_h) for angle in
+ 2 * np.pi * np.arange(5) / 5] + \
+ [(_ico_ring_r * np.cos(angle), _ico_ring_r * np.sin(angle), -_ico_ring_h) for angle in
+ 2 * np.pi * (np.arange(5) + 0.5) / 5] + \
+ [(0.0, 0.0, -1.0)]
+
+ _base_edges = [
+ (0, 1), # Top converging
+ (0, 2),
+ (0, 3),
+ (0, 4),
+ (0, 5),
+ (1, 2), # Top radial
+ (2, 3),
+ (3, 4),
+ (4, 5),
+ (5, 1), # Middle diagonals, advanced
+ (1, 6),
+ (2, 7),
+ (3, 8),
+ (4, 9),
+ (5, 10),
+ (1, 10), # Middle diagonals, delayed
+ (2, 6),
+ (3, 7),
+ (4, 8),
+ (5, 9),
+ (6, 7), # Bottom radial
+ (7, 8),
+ (8, 9),
+ (9, 10),
+ (10, 6),
+ (6, 11), # Bottom converging
+ (7, 11),
+ (8, 11),
+ (9, 11),
+ (10, 11),
+ ]
+
+ _base_triangles = [
+ (0, 1, 2), # Top cap
+ (0, 2, 3),
+ (0, 3, 4),
+ (0, 4, 5),
+ (0, 5, 1),
+ (2, 1, 6), # Top middle ring
+ (3, 2, 7),
+ (4, 3, 8),
+ (5, 4, 9),
+ (1, 5, 10),
+ (6, 10, 1), # Bottom middle ring
+ (7, 6, 2),
+ (8, 7, 3),
+ (9, 8, 4),
+ (10, 9, 5),
+ (6, 7, 11), # Bottom cap
+ (7, 8, 11),
+ (8, 9, 11),
+ (9, 10, 11),
+ (10, 6, 11)
+ ]
+
+ _cache = {}
+
+
+ @staticmethod
+ def points_for_division_amount(n_divisions):
+ """ Get the number of points on the sphere for a given number of geodesic divisions
+ by which I mean how many sections is each edge of the initial icosahedron split into
+ (not how many times it is divided in half, which might be what you think given some other
+ geodesic generation methods)
+
+ Icosahedron counts as 1 division
+
+ """
+
+ return 10*n_divisions*n_divisions + 2
+
+
+
+ @staticmethod
+ def minimal_divisions_for_points(n_points):
+ """ What division number should I use if I want at least n_points points on my geodesic
+
+ rounded (ciel) inverse of points_for_division_amount
+ """
+
+ n_ish = np.sqrt((n_points - 2)/10)
+
+ return int(max([1.0, np.ceil(n_ish)]))
+
+ @staticmethod
+ def by_point_count(self, n_points) -> Tuple[np.ndarray, np.ndarray]:
+ """ Get point sample on a unit geodesic sphere, *at least* n_points will be returned
+
+ Weights of each point are calculated by fractional spherical area of dual polyhedron, and total weight = 4pi
+ """
+
+ return self.by_divisions(self.minimal_divisions_for_points(n_points))
+
+ @staticmethod
+ def by_divisions(n_divisions) -> Tuple[np.ndarray, np.ndarray]:
+
+ """ Get point sample on a unit geodesic sphere, points are creating by dividing each
+ face of an icosahedron into smaller triangles so that each edge is split into n_divisions pieces
+
+ Weights of each point are calculated by fractional spherical area of dual polyhedron, and total weight = 4pi
+ """
+
+ # Check cache for pre-existing data
+ if n_divisions in Geodesic._cache:
+ return Geodesic._cache[n_divisions]
+
+
+ #
+ # Main bit: We have to treat faces, edges and vertices individually to avoid duplicate points
+ #
+
+ # Original vertices will become new vertices
+ points = [np.array(p) for p in Geodesic._base_vertices]
+
+ # Iterate over edges
+ for i, j in Geodesic._base_edges:
+ p1 = np.array(Geodesic._base_vertices[i])
+ p2 = np.array(Geodesic._base_vertices[j])
+
+ delta = (p2 - p1) / n_divisions
+
+ for a in range(1, n_divisions):
+ new_point = p1 + a*delta
+ new_point /= np.sqrt(np.sum(new_point ** 2))
+
+ points.append(new_point)
+
+
+ # Iterate over faces need to, and add divsions
+ for i, j, k in Geodesic._base_triangles:
+ p1 = np.array(Geodesic._base_vertices[i])
+ p2 = np.array(Geodesic._base_vertices[j])
+ p3 = np.array(Geodesic._base_vertices[k])
+
+ d1 = p1 - p3
+ d2 = p2 - p3
+
+ # Division of the shape, just fill in the inside
+ for a_plus_b in range(1, n_divisions):
+ for a in range(1, a_plus_b):
+
+ b = a_plus_b - a
+
+ new_point = p3 + d1*(a / n_divisions) + d2*(b / n_divisions)
+ new_point /= np.sqrt(np.sum(new_point**2))
+
+ points.append(new_point)
+
+ points = np.array(points)
+
+ weights = Geodesic._calculate_weights(points)
+
+
+ # Cache the data
+ Geodesic._cache[n_divisions] = (points, weights)
+
+ return points, weights
+
+ @staticmethod
+ def _calculate_weights(points) -> np.ndarray:
+ """
+ Calculate the anular area associated with each point
+ """
+
+ z = np.array([0.0, 0.0, 1.0]) # Used in area calculation
+
+ # Calculate convex hull to get the triangles, because it's easy
+ hull = ConvexHull(points)
+
+ centroids = []
+
+ # Dictionary to track which centroids are associated with each point
+ point_to_centroid = defaultdict(list)
+
+ # calculate centroid, lets not check for correct hull data, as nothing is user
+ # facing, and has a reliably triangular and convex input
+ for centroid_index, simplex in enumerate(hull.simplices):
+ p1 = points[simplex[0], :]
+ p2 = points[simplex[1], :]
+ p3 = points[simplex[2], :]
+
+ centroid = p1 + p2 + p3
+ centroid /= np.sqrt(np.sum(centroid**2))
+
+ centroids.append(centroid)
+
+ point_to_centroid[simplex[0]].append(centroid_index)
+ point_to_centroid[simplex[1]].append(centroid_index)
+ point_to_centroid[simplex[2]].append(centroid_index)
+
+ # Find the area of the spherical polygon associated with each point
+ # 1) get the associated centroids
+ # 2) order them to allow the identification of triangles
+ # 3) for each triangle [point, centroid_i, centroid_i+1], calculate the area
+
+ areas = []
+ for point_index, point in enumerate(points):
+
+ # 1) Get centroids
+ centroid_indices = point_to_centroid[point_index]
+ centroid_points = [centroids[i] for i in centroid_indices]
+
+ n_centroids = len(centroid_indices)
+
+
+ # 2) Order them around the point
+ rotation = Geodesic._rotation_matrix_to_z_vector(point)
+
+ centroid_points = [np.matmul(rotation, centroid) for centroid in centroid_points]
+
+ centroid_points = sorted(centroid_points, key=lambda x: np.arctan2(x[0], x[1])) # Any angle in x,y will do
+
+
+ # 3) Calculate areas
+ area = 0
+ for i in range(n_centroids):
+ p1 = centroid_points[i]
+ p2 = centroid_points[(i+1)%n_centroids]
+
+ area += 2 * np.abs(
+ np.arctan2(
+ np.cross(p1, p2)[2],
+ 1 + np.dot(p1, p2) + p1[2] + p2[2]))
+
+ areas.append(area)
+
+ return np.array(areas)
+
+
+ @staticmethod
+ def _rotation_matrix_to_z_vector(point_on_sphere: np.ndarray) -> np.ndarray:
+ """ Calculate *a* rotation matrix that moves a given point onto the z axis"""
+
+ # Find the rotation around the x axis
+ y = point_on_sphere[1]
+ z = point_on_sphere[2]
+
+ x_angle = np.arctan2(y, z)
+
+ c = np.cos(x_angle)
+ s = np.sin(x_angle)
+
+ m1 = np.array([
+ [1, 0, 0],
+ [0, c, -s],
+ [0, s, c]])
+
+ # apply rotation to point
+ new_point = np.matmul(m1, point_on_sphere)
+
+ # find the rotation about the y axis for this new point
+ x = new_point[0]
+ z = new_point[2]
+
+ y_angle = np.arctan2(x, z)
+
+ c = np.cos(y_angle)
+ s = np.sin(y_angle)
+
+ m2 = np.array([
+ [c, 0, -s],
+ [0, 1, 0],
+ [s, 0, c]])
+
+ return np.matmul(m2, m1)
+
+
+class GeodesicDivisions:
+ """ Use this so that the GUI program gives geodesic divisions box, rather than a plain int one"""
+
+
+if __name__ == "__main__":
+
+ """ Shows a point sample"""
+
+ import matplotlib.pyplot as plt
+
+ fig = plt.figure()
+ ax = fig.add_subplot(projection='3d')
+
+ points, weights = Geodesic.by_divisions(4)
+ ax.scatter(points[:,0], points[:,1], points[:,2])
+
+ ax.set_xlim3d([-1.1, 1.1])
+ ax.set_ylim3d([-1.1, 1.1])
+ ax.set_zlim3d([-1.1, 1.1])
+
+ ax.set_box_aspect([1,1,1])
+ ax.set_proj_type('ortho')
+
+ plt.show()
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py
new file mode 100644
index 0000000000..b07f41eb35
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py
@@ -0,0 +1,110 @@
+"""
+
+Instances of the spatial sampler
+
+"""
+
+import math
+import numpy as np
+
+from collections import defaultdict
+
+
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3
+from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import SpatialDistribution
+
+class BoundedByCube(SpatialDistribution):
+
+ _boundary_base_points = np.array([
+ [-1, 0, 0],
+ [ 1, 0, 0],
+ [ 0,-1, 0],
+ [ 0, 1, 0],
+ [ 0, 0,-1],
+ [ 0, 0, 1],
+ [ 1, 1, 1],
+ [ 1, 1,-1],
+ [ 1,-1, 1],
+ [ 1,-1,-1],
+ [-1, 1, 1],
+ [-1, 1,-1],
+ [-1,-1, 1],
+ [-1,-1,-1],
+ ], dtype=float)
+ def _bounding_surface_check_points(self) -> VectorComponents3:
+ return BoundedByCube._boundary_base_points * self.radius
+
+
+class Grid(BoundedByCube):
+ """ Generate points on a grid within a cube with side length 2*radius """
+ def __init__(self, radius: float, desired_points: int):
+ self.desired_points = desired_points
+ self.n_points_per_axis = math.ceil(desired_points**(1/3))
+
+ super().__init__(radius, n_points=self.n_points_per_axis**3, n_desired_points=desired_points)
+
+ @property
+ def info(self):
+ return f"{self.n_points_per_axis}x{self.n_points_per_axis}x{self.n_points_per_axis} = {self.n_points}"
+
+ def generate(self, start_index: int, end_index: int) -> VectorComponents3:
+ point_indices = np.arange(start_index, end_index)
+
+ z_inds = point_indices % self.n_points_per_axis
+ y_inds = (point_indices // self.n_points_per_axis) % self.n_points_per_axis
+ x_inds = (point_indices // (self.n_points_per_axis**2)) % self.n_points_per_axis
+
+ return (
+ (((x_inds + 0.5) / self.n_points_per_axis) - 0.5) * self.radius,
+ (((y_inds + 0.5) / self.n_points_per_axis) - 0.5) * self.radius,
+ (((z_inds + 0.5) / self.n_points_per_axis) - 0.5) * self.radius)
+
+
+
+class RandomCube(BoundedByCube):
+ """ Generate random points in a cube with side length 2*radius"""
+ def __init__(self, radius: float, desired_points: int, seed=None):
+
+ super().__init__(radius, n_points=desired_points, n_desired_points=desired_points)
+
+ self._seed_rng = np.random.default_rng(seed)
+
+ # Accessing this will generate random seeds if they don't exist and store them to be accessed if they do
+ self.seeds = defaultdict(lambda: self._seed_rng.integers(0, 0x7fff_ffff_ffff_ffff))
+
+ def generate(self, start_index: int, end_index: int) -> VectorComponents3:
+ # This method of tracking seeds only works if the same start_indices are used each time points
+ # in a region are requested - i.e. if they're chunks form a grid
+
+ n_points = end_index - start_index
+ seed = self.seeds[start_index]
+
+ rng = np.random.default_rng(seed=seed)
+
+ xyz = rng.random(size=(n_points, 3))
+
+ xyz -= 0.5
+ xyz *= 2*self.radius
+
+ return xyz[:, 0], xyz[:, 1], xyz[:, 2]
+
+
+class PointGeneratorStepper:
+ """ Generate batches of step_size points from a PointGenerator instance"""
+
+ def __init__(self, point_generator: SpatialDistribution, step_size: int):
+ self.point_generator = point_generator
+ self.step_size = step_size
+
+ def _iterator(self):
+ n_sections, remainder = divmod(self.point_generator.n_points, self.step_size)
+
+ for i in range(n_sections):
+ yield self.point_generator.generate(i*self.step_size, (i+1)*self.step_size)
+
+ if remainder != 0:
+ yield self.point_generator.generate(n_sections*self.step_size, self.point_generator.n_points)
+
+ def __iter__(self):
+ return self._iterator()
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py
new file mode 100644
index 0000000000..84e15f3b46
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py
@@ -0,0 +1,218 @@
+"""
+
+
+Modified from: art1415926535/PyQt5-syntax-highlighting on github
+
+It's not great, should all really be implemented as a finite state machine with a stack
+
+"""
+
+import sys
+from PySide6.QtCore import QRegularExpression, QRegularExpressionMatchIterator
+from PySide6.QtGui import QColor, QTextCharFormat, QFont, QSyntaxHighlighter
+
+
+def format(color, style=''):
+ """
+ Return a QTextCharFormat with the given attributes.
+ """
+ _color = QColor()
+ if type(color) is not str:
+ _color.setRgb(color[0], color[1], color[2])
+ else:
+ _color.setNamedColor(color)
+
+ _format = QTextCharFormat()
+ _format.setForeground(_color)
+ if 'bold' in style:
+ _format.setFontWeight(QFont.Bold)
+ if 'italic' in style:
+ _format.setFontItalic(True)
+
+ return _format
+
+
+# Syntax styles that can be shared by all languages
+
+STYLES = {
+ 'keyword': format([200, 120, 50], 'bold'),
+ 'operator': format([150, 150, 150]),
+ 'brace': format('darkGray'),
+ 'defclass': format([220, 220, 255], 'bold'),
+ 'string': format([20, 110, 100]),
+ 'string2': format([30, 120, 110]),
+ 'comment': format([128, 128, 128]),
+ 'self': format([150, 85, 140], 'italic'),
+ 'numbers': format([100, 150, 190]),
+ 'special': format([90, 80, 200], 'bold')
+}
+
+
+class PythonHighlighter(QSyntaxHighlighter):
+ """Syntax highlighter for the Python language.
+ """
+ # Python keywords
+
+
+ keywords = [
+ 'and', 'assert', 'break', 'class', 'continue', 'def',
+ 'del', 'elif', 'else', 'except', 'exec', 'finally',
+ 'for', 'from', 'global', 'if', 'import', 'in',
+ 'is', 'lambda', 'not', 'or', 'pass', 'print',
+ 'raise', 'return', 'try', 'while', 'yield',
+ 'None', 'True', 'False',
+ ]
+
+ special = [
+ 'sld', 'solvent_sld', 'magnetism'
+ ]
+
+ # Python operators
+ operators = [
+ '=',
+ # Comparison
+ '==', '!=', '<', '<=', '>', '>=',
+ # Arithmetic
+ '\+', '-', '\*', '/', '//', '\%', '\*\*',
+ # In-place
+ '\+=', '-=', '\*=', '/=', '\%=',
+ # Bitwise
+ '\^', '\|', '\&', '\~', '>>', '<<',
+ ]
+
+ # Python braces
+ braces = [
+ '\{', '\}', '\(', '\)', '\[', '\]',
+ ]
+
+ def __init__(self, document):
+ QSyntaxHighlighter.__init__(self, document)
+
+ # Multi-line strings (expression, flag, style)
+ # FIXME: The triple-quotes in these two lines will mess up the
+ # syntax highlighting from this point onward
+ self.tri_single = (QRegularExpression("'''"), 1, STYLES['string2'])
+ self.tri_double = (QRegularExpression('"""'), 2, STYLES['string2'])
+
+ rules = []
+
+ # Keyword, operator, and brace rules
+ rules += [(r'\b%s\b' % w, 0, STYLES['keyword'])
+ for w in PythonHighlighter.keywords]
+ rules += [(r'%s' % o, 0, STYLES['operator'])
+ for o in PythonHighlighter.operators]
+ rules += [(r'%s' % b, 0, STYLES['brace'])
+ for b in PythonHighlighter.braces]
+
+ # All other rules
+ rules += [
+ # 'self'
+ (r'\bself\b', 0, STYLES['self']),
+
+ # Double-quoted string, possibly containing escape sequences
+ (r'[rf]?"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']),
+ # Single-quoted string, possibly containing escape sequences
+ (r"[rf]?'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']),
+
+ # From '#' until a newline
+ (r'#[^\n]*', 0, STYLES['comment']),
+
+ # Numeric literals
+ (r'\b[+-]?[0-9]+[lL]?\b', 0, STYLES['numbers']),
+ (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, STYLES['numbers']),
+ (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, STYLES['numbers']),
+ ]
+
+ rules += [(r'\b%s\b' % w, 0, STYLES['special'])
+ for w in PythonHighlighter.special]
+
+ # Build a QRegExp for each pattern
+ self.rules = [(QRegularExpression(pat), index, fmt)
+ for (pat, index, fmt) in rules]
+
+
+ def highlightBlock(self, text):
+ """Apply syntax highlighting to the given block of text.
+ """
+
+ # Go through each of the rules and each of the matches setting the highlighting
+ for expression, nth, format in self.rules:
+ matchIterator = QRegularExpressionMatchIterator(expression.globalMatch(text))
+ while matchIterator.hasNext():
+ match = matchIterator.next()
+ index = match.capturedStart()
+ length = match.capturedLength()
+ self.setFormat(index, length, format)
+
+ # def and class
+ #
+ # # 'def' followed by an identifier
+ # (r'\bdef\b\s*(\w+)', 1, STYLES['defclass']),
+ # # 'class' followed by an identifier
+ # (r'\bclass\b\s*(\w+)', 1, STYLES['defclass']),
+
+ # Multiblock comments
+
+ # Find all the tripple quotes
+
+ doubleMatchIterator = QRegularExpressionMatchIterator(self.tri_double[0].globalMatch(text))
+ singleMatchIterator = QRegularExpressionMatchIterator(self.tri_single[0].globalMatch(text))
+
+ doubleMatches = []
+ while doubleMatchIterator.hasNext():
+ doubleMatches.append((
+ self.tri_double[1],
+ doubleMatchIterator.next(),
+ self.tri_double[2]))
+
+ singleMatches = []
+ while singleMatchIterator.hasNext():
+ singleMatches.append((
+ self.tri_single[1],
+ singleMatchIterator.next(),
+ self.tri_single[2]))
+
+ allMatches = sorted(
+ singleMatches + doubleMatches,
+ key=lambda x: x[1].capturedStart())
+
+ # Step through the matches
+
+ state = self.previousBlockState()
+ start_index = 0
+
+ for in_state, match, style in allMatches:
+
+ if state == in_state:
+ # Comment end
+
+ state = -1
+ index = match.capturedStart()
+ length = match.capturedLength()
+ end_index = index + length
+
+ self.setFormat(start_index, end_index, style)
+
+ start_index = end_index
+
+ elif state == -1:
+ # Comment start
+
+ state = in_state
+ start_index = match.capturedStart()
+
+
+ # format rest of block
+ end_index = len(text)
+ if state != -1:
+ if len(allMatches) > 0:
+ self.setFormat(start_index, end_index, allMatches[-1][2])
+ else:
+ if state == self.tri_single[1]:
+ self.setFormat(0, end_index, self.tri_single[2])
+ elif state == self.tri_double[1]:
+ self.setFormat(0, end_index, self.tri_double[2])
+
+ # set block state
+ self.setCurrentBlockState(state)
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py
new file mode 100644
index 0000000000..ae56ce5f80
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py
@@ -0,0 +1,54 @@
+import pytest
+from pytest import mark
+import numpy as np
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic
+
+@mark.parametrize("n_divisions", [1,2,3,4,5])
+def test_point_number_prediction(n_divisions):
+ """ Geodesic point number function should give the number of points the generating function returns"""
+ geodesic_points, _ = Geodesic.by_divisions(n_divisions)
+
+ assert geodesic_points.shape[0] == Geodesic.points_for_division_amount(n_divisions)
+
+
+@mark.parametrize("n_divisions", [1,2,3,4,5])
+def test_inverse_point_calc_exact(n_divisions):
+ """ Check the calculation of divisions for point number works, when it is an exact input"""
+ assert n_divisions == Geodesic.minimal_divisions_for_points(Geodesic.points_for_division_amount(n_divisions))
+
+
+@mark.parametrize("n_divisions", [1,2,3,4,5])
+def test_inverse_point_calc_minimally_greater(n_divisions):
+ """ Check the calculation of divisions rounds correctly (up) when it is one more than an exact input"""
+ assert n_divisions + 1 == Geodesic.minimal_divisions_for_points(Geodesic.points_for_division_amount(n_divisions) + 1)
+
+
+@mark.parametrize("n_divisions", [1,2,3,4,5])
+def test_inverse_point_calc_maximally_greater(n_divisions):
+ """ Check the calculation of divisions rounds correctly (up) when it is one less than the next exact input"""
+ assert n_divisions + 1 == Geodesic.minimal_divisions_for_points(Geodesic.points_for_division_amount(n_divisions + 1) - 1)
+
+
+@mark.parametrize("vector", [
+ [ 1, 2, 3],
+ [ 7, 2, 0],
+ [-1, 0, 0],
+ [ 0, 1, 0],
+ [ 0, 0, 1]])
+def test_rotation_to_z(vector):
+ """ Check the method that rotates vectors to the z axis"""
+ m = Geodesic._rotation_matrix_to_z_vector(np.array(vector))
+
+ v_test = np.matmul(m, vector)
+
+ assert np.abs(v_test[0]) < 1e-9
+ assert np.abs(v_test[1]) < 1e-9
+
+
+@mark.parametrize("n_divisions", [1,2,3,4,5,6])
+def test_total_weights(n_divisions):
+ """ Total weights should add to 4pi"""
+ points, weights = Geodesic.by_divisions(n_divisions)
+
+ assert abs(sum(weights) - 4*np.pi) < 1e-9
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py
new file mode 100644
index 0000000000..61c17d40e1
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py
@@ -0,0 +1,32 @@
+
+from pytest import mark
+
+from sas.qtgui.Perspectives.ParticleEditor.sampling.points import (
+ Grid,
+ RandomCube,
+ PointGeneratorStepper)
+
+
+
+@mark.parametrize("splits", [42, 100, 381,999])
+@mark.parametrize("npoints", [100,1000,8001])
+def test_with_grid(npoints, splits):
+ point_generator = Grid(100, npoints)
+ n_measured = 0
+ for x, y, z in PointGeneratorStepper(point_generator, splits):
+ n_measured += len(x)
+
+ assert n_measured == point_generator.n_points # desired points may not be the same as n_points
+
+@mark.parametrize("splits", [42, 100, 381, 999])
+@mark.parametrize("npoints", [100, 1000, 8001])
+def test_with_grid(npoints, splits):
+ point_generator = RandomCube(100, npoints)
+ n_measured = 0
+ for x, y, z in PointGeneratorStepper(point_generator, splits):
+ n_measured += len(x)
+
+ assert n_measured == npoints
+
+
+
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/util.py b/src/sas/qtgui/Perspectives/ParticleEditor/util.py
new file mode 100644
index 0000000000..63af287358
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/util.py
@@ -0,0 +1,90 @@
+def format_time_estimate(est_time_seconds):
+ """ Get easily understandable string for a computational time estimate"""
+ # This is some silly code, but eh, why not
+ #
+ # I'm not even sure this is bad code, it's quite readable,
+ # easily modifiable, relatively fast, and it is optimised for
+ # smaller numbers, which is the bias we expect in the input
+
+ est_time = est_time_seconds * 1_000_000
+ time_units = "microsecond"
+
+ if est_time < 1:
+ # anything shorter than a microsecond, return as a decimal
+ return "%.3g microseconds" % est_time
+
+ # Everything else as an integer in nice units
+ if est_time > 1000:
+ est_time /= 1000
+ time_units = "millisecond"
+
+ if est_time > 1000:
+ est_time /= 1000
+ time_units = "second"
+
+ if est_time > 60:
+ est_time /= 60
+ time_units = "minute"
+
+ if est_time > 60:
+ est_time /= 60
+ time_units = "hour"
+
+ if est_time > 24:
+ est_time /= 24
+ time_units = "day"
+
+ if est_time > 7:
+ est_time /= 7
+ time_units = "week"
+
+ if est_time > 30 / 7:
+ est_time /= 30 / 7
+ time_units = "month"
+
+ if est_time > 365 / 30:
+ est_time /= 365 / 30
+ time_units = "year"
+
+ if est_time > 100:
+ est_time /= 100
+ time_units = ("century", "centuries")
+
+ if est_time > 10:
+ est_time /= 10
+ time_units = ("millennium", "millennia")
+
+ if est_time > 1000:
+ est_time /= 1000
+ time_units = ("megaannum", "megaanna")
+
+ if est_time > 230:
+ est_time /= 230
+ time_units = "galactic year"
+
+ if est_time > 13787 / 230:
+ est_time /= 13787 / 230
+ time_units = "universe age"
+
+ rounded = int(est_time)
+ if isinstance(time_units, str):
+ if rounded == 1:
+ unit = time_units
+ else:
+ unit = time_units + "s"
+ else:
+ if rounded == 1:
+ unit = time_units[0]
+ else:
+ unit = time_units[1]
+
+ return f"{rounded} {unit}"
+
+
+if __name__ == "__main__":
+
+ # time format demo
+ t = 1e-9
+ for i in range(100):
+ t *= 2
+ print(format_time_estimate(t))
\ No newline at end of file
diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py
new file mode 100644
index 0000000000..b24c56c3aa
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py
@@ -0,0 +1,96 @@
+import traceback
+from typing import List, Union, Callable
+import numpy as np
+
+test_n = 7
+
+def clean_traceback(trace: str):
+ """ Tracebacks from vectorise contain potentially confusing information from the
+ vectorisation infrastructure. Clean it up and replace empty filename with something else"""
+
+
+ parts = trace.split('File ""')[1:]
+
+ return "".join(["Input data" + part for part in parts])
+
+
+def vectorise_sld(fun: Callable,
+ warning_callback: Callable[[str], None],
+ error_callback: Callable[[str], None],
+ *args, **kwargs):
+ """ Check whether an SLD function can handle numpy arrays properly,
+ if not, create a wrapper that that can"""
+
+ # Basically, the issue is with if statements,
+ # and we're looking for a ValueError with certain text
+
+ input_values = np.zeros((test_n,))
+
+ try:
+ output = fun(input_values, input_values, input_values, *args, **kwargs)
+
+ if not isinstance(output, np.ndarray):
+ return None
+
+ elif output.shape != (test_n,):
+ return None
+
+ else:
+ return fun
+
+ except ValueError as ve:
+ # Check for a string that signifies a problem with parallelisation
+ if ve.args[0].startswith("The truth value of an array"):
+ # check it works with basic values
+ try:
+ fun(0, 0, 0, *args, **kwargs)
+
+
+ def vectorised(x,y,z,*args,**kwargs):
+ out = np.zeros_like(x)
+ for i, (xi,yi,zi) in enumerate(zip(x,y,z)):
+ out[i] = fun(xi, yi, zi, *args, **kwargs)
+ return out
+
+ warning_callback("The specified SLD function does not handle vector values of coordinates, "
+ "a vectorised version has been created, but is probably **much** slower than "
+ "one that uses numpy (np.) functions. See the vectorisation example for "
+ "more details.")
+
+ return vectorised
+
+ except:
+ error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc()))
+
+ else:
+ error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc()))
+
+ except Exception:
+ error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc()))
+
+def vectorise_magnetism(fun: Callable, warning_callback: Callable[[str], None], *args, **kwargs):
+ """ Check whether a magnetism function can handle numpy arrays properly,
+ if not, create a wrapper that that can"""
+ pass
+
+def main():
+
+ def vector_sld(x, y, z):
+ return x+y+z
+
+ def non_vector_sld(x,y,z):
+ if x > y:
+ return 1
+ else:
+ return 0
+
+ def bad_sld(x,y,z):
+ return 7
+
+ print(vectorise_sld(vector_sld, print, print))
+ print(vectorise_sld(non_vector_sld, print, print))
+ print(vectorise_sld(bad_sld, print, print))
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py
index 2828b3a54c..9b942a8af2 100644
--- a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py
+++ b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py
@@ -19,6 +19,7 @@ class BoxInteractor(BaseInteractor, SlicerModel):
function of Q_x and BoxInteractorY averages all the points from
-x to +x as a function of Q_y
"""
+
def __init__(self, base, axes, item=None, color='black', zorder=3):
BaseInteractor.__init__(self, base, axes, color=color)
SlicerModel.__init__(self)
@@ -244,7 +245,7 @@ def setParams(self, params):
self.horizontal_lines.update(x=self.x, y=self.y)
self.vertical_lines.update(x=self.x, y=self.y)
- self._post_data(nbins=None)
+ self._post_data()
self.draw()
def draw(self):
@@ -261,6 +262,7 @@ class HorizontalLines(BaseInteractor):
on the x direction. The two lines move symmetrically (in opposite
directions). It also defines the x and -x position of a box.
"""
+
def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5):
"""
"""
@@ -371,6 +373,7 @@ class VerticalLines(BaseInteractor):
on the y direction. The two lines move symmetrically (in opposite
directions). It also defines the y and -y position of a box.
"""
+
def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5):
"""
"""
@@ -481,12 +484,13 @@ class BoxInteractorX(BoxInteractor):
averaged together to provide a 1D array in Qx (to be plotted as a function
of Qx)
"""
+
def __init__(self, base, axes, item=None, color='black', zorder=3):
BoxInteractor.__init__(self, base, axes, item=item, color=color)
self.base = base
- self._post_data()
+ super()._post_data()
- def _post_data(self):
+ def _post_data(self, new_slab=None, nbins=None, direction=None):
"""
Post data creating by averaging in Qx direction
"""
@@ -514,12 +518,13 @@ class BoxInteractorY(BoxInteractor):
averaged together to provide a 1D array in Qy (to be plotted as a function
of Qy)
"""
+
def __init__(self, base, axes, item=None, color='black', zorder=3):
BoxInteractor.__init__(self, base, axes, item=item, color=color)
self.base = base
- self._post_data()
+ super()._post_data()
- def _post_data(self):
+ def _post_data(self, new_slab=None, nbins=None, direction=None):
"""
Post data creating by averaging in Qy direction
"""
diff --git a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py
index af873dd8e5..bd6ee37d22 100644
--- a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py
+++ b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py
@@ -380,13 +380,13 @@ def update(self, phi=None, delta=None, mline=None,
else:
self.phi = numpy.fabs(self.phi)
if side:
- self.theta = mline.theta + self.phi
+ self.theta = mline.alpha + self.phi
if mline is not None:
if delta != 0:
self.theta2 = mline + delta
else:
- self.theta2 = mline.theta
+ self.theta2 = mline.alpha
if delta == 0:
theta3 = self.theta + delta
else:
diff --git a/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py
index 66355f012b..ac76911f24 100644
--- a/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py
+++ b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py
@@ -9,6 +9,7 @@
from sas.qtgui.Plotting.Slicers.RadiusInteractor import RadiusInteractor
from sas.qtgui.Plotting.Slicers.SectorSlicer import LineInteractor
+
class WedgeInteractor(BaseInteractor, SlicerModel):
"""
This WedgeInteractor is a cross between the SectorInteractor and the
@@ -29,6 +30,7 @@ class WedgeInteractor(BaseInteractor, SlicerModel):
AnnulusSlicer) and WedgeInteractorQ averages all phi points at constant Q
(as for the SectorSlicer).
"""
+
def __init__(self, base, axes, item=None, color='black', zorder=3):
BaseInteractor.__init__(self, base, axes, color=color)
@@ -115,7 +117,7 @@ def update(self):
self.phi = self.radial_lines.phi
self.inner_arc.update(phi=self.phi)
self.outer_arc.update(phi=self.phi)
- if self.central_line.has_move:
+ if self.central_line.has_move:
self.central_line.update()
self.theta = self.central_line.theta
self.inner_arc.update(theta=self.theta)
@@ -201,7 +203,6 @@ def _post_data(self, wedge_type=None, nbins=None):
new_plot.xaxis("\\rm{Q}", 'A^{-1}')
new_plot.yaxis("\\rm{Intensity} ", "cm^{-1}")
-
new_plot.id = str(self.averager.__name__) + self.data.name
new_plot.group_id = new_plot.id
new_plot.is_data = True
@@ -322,18 +323,20 @@ def draw(self):
"""
self.base.draw()
+
class WedgeInteractorQ(WedgeInteractor):
"""
Average in Q direction. The data for all phi at a constant Q are
averaged together to provide a 1D array in Q (to be plotted as a function
of Q)
"""
+
def __init__(self, base, axes, item=None, color='black', zorder=3):
WedgeInteractor.__init__(self, base, axes, item=item, color=color)
self.base = base
- self._post_data()
+ super()._post_data()
- def _post_data(self):
+ def _post_data(self, new_sector=None, nbins=None):
from sasdata.data_util.new_manipulations import WedgeQ
super()._post_data(WedgeQ)
@@ -344,12 +347,13 @@ class WedgeInteractorPhi(WedgeInteractor):
averaged together to provide a 1D array in phi (to be plotted as a function
of phi)
"""
+
def __init__(self, base, axes, item=None, color='black', zorder=3):
WedgeInteractor.__init__(self, base, axes, item=item, color=color)
self.base = base
- self._post_data()
+ super()._post_data()
- def _post_data(self):
+ def _post_data(self, new_sector=None, nbins=None):
from sasdata.data_util.new_manipulations import WedgePhi
super()._post_data(WedgePhi)
diff --git a/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py b/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py
index 47494b92c6..cf3016ba6a 100644
--- a/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py
+++ b/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py
@@ -162,7 +162,7 @@ def testAddText(self, plotter, mocker):
test_text = "Smoke in cabin"
test_font = QtGui.QFont("Arial", 16, QtGui.QFont.Bold)
test_color = "#00FF00"
- plotter.addText.textEdit.setText(test_text)
+ plotter.addText.codeEditor.setText(test_text)
# Return the requested font parameters
mocker.patch.object(plotter.addText, 'font', return_value=test_font)
@@ -186,7 +186,7 @@ def testOnRemoveText(self, plotter, mocker):
# Add some text
plotter.plot(self.data)
test_text = "Safety instructions"
- plotter.addText.textEdit.setText(test_text)
+ plotter.addText.codeEditor.setText(test_text)
# Return OK from the dialog
mocker.patch.object(plotter.addText, 'exec_', return_value=QtWidgets.QDialog.Accepted)
# Add text to graph
diff --git a/src/sas/qtgui/Utilities/GuiUtils.py b/src/sas/qtgui/Utilities/GuiUtils.py
index 7166e15c3c..6e35a3842a 100644
--- a/src/sas/qtgui/Utilities/GuiUtils.py
+++ b/src/sas/qtgui/Utilities/GuiUtils.py
@@ -1136,7 +1136,7 @@ def jdefault(o):
objects that can't otherwise be serialized need to be converted
"""
# tuples and sets (TODO: default JSONEncoder converts tuples to lists, create custom Encoder that preserves tuples)
- if isinstance(o, (tuple, set, np.float)):
+ if isinstance(o, (tuple, set, float)):
content = { 'data': list(o) }
return add_type(content, type(o))
diff --git a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py
index 36114a85fc..54437ca983 100644
--- a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py
+++ b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py
@@ -28,16 +28,17 @@ class OrientationViewer(QtWidgets.QWidget):
# Dimensions of scattering cuboid
- a = 0.1
- b = 0.4
- c = 1.0
+ a = 10
+ b = 40
+ c = 100
+
+ screen_scale = 0.01 # Angstroms to screen size
arrow_size = 0.2
arrow_color = uniform_coloring(0.9, 0.9, 0.9)
ghost_color = uniform_coloring(0.0, 0.6, 0.2)
cube_color = uniform_coloring(0.0, 0.8, 0.0)
- cuboid_scaling = [a, b, c]
n_ghosts_per_perameter = 8
n_q_samples = 128
@@ -53,9 +54,9 @@ class OrientationViewer(QtWidgets.QWidget):
@staticmethod
def create_ghost():
""" Helper function: Create a ghost cube"""
- return Scaling(OrientationViewer.a,
- OrientationViewer.b,
- OrientationViewer.c,
+ return Scaling(OrientationViewer.a*OrientationViewer.screen_scale,
+ OrientationViewer.b*OrientationViewer.screen_scale,
+ OrientationViewer.c*OrientationViewer.screen_scale,
Cube(edge_colors=OrientationViewer.ghost_color))
def __init__(self, parent=None):
@@ -130,9 +131,9 @@ def __init__(self, parent=None):
self.first_rotation = Rotation(0,0,0,1,
- Scaling(OrientationViewer.a,
- OrientationViewer.b,
- OrientationViewer.c,
+ Scaling(OrientationViewer.a*OrientationViewer.screen_scale,
+ OrientationViewer.b*OrientationViewer.screen_scale,
+ OrientationViewer.c*OrientationViewer.screen_scale,
Cube(
edge_colors=OrientationViewer.ghost_color,
colors=OrientationViewer.cube_color)),
@@ -170,7 +171,7 @@ def colormap(self, colormap_name: str):
def _set_image_data(self, orientation: Orientation):
""" Set the data on the plot"""
- data = self.scatering_data(orientation)
+ data = self.scattering_data(orientation)
scaled_data = (np.log(data) - OrientationViewer.log_I_min) / OrientationViewer.log_I_range
self.image_plane_data = np.clip(scaled_data, 0, 1)
@@ -252,7 +253,7 @@ def polydispersity_sample_count(self, orientation):
return (samples * x for x in is_polydisperse)
- def scatering_data(self, orientation: Orientation) -> np.ndarray:
+ def scattering_data(self, orientation: Orientation) -> np.ndarray:
# add the orientation parameters to the model parameters
@@ -271,9 +272,9 @@ def scatering_data(self, orientation: Orientation) -> np.ndarray:
psi_pd=orientation.dpsi,
psi_pd_type=OrientationViewer.polydispersity_distribution,
psi_pd_n=psi_pd_n,
- a=OrientationViewer.a,
- b=OrientationViewer.b,
- c=OrientationViewer.c,
+ length_a=OrientationViewer.a,
+ length_b=OrientationViewer.b,
+ length_c=OrientationViewer.c,
background=np.exp(OrientationViewer.log_I_min))
return np.reshape(data, (OrientationViewer.n_q_samples, OrientationViewer.n_q_samples))
diff --git a/src/sas/qtgui/convertUI.py b/src/sas/qtgui/convertUI.py
index bb402af8f7..b15fd593f3 100644
--- a/src/sas/qtgui/convertUI.py
+++ b/src/sas/qtgui/convertUI.py
@@ -86,6 +86,7 @@ def rebuild_new_ui(force=False):
rc_file = 'images.qrc'
out_file = 'images_rc.py'
+
in_file = os.path.join(images_root, rc_file)
out_file = os.path.join(ui_root, out_file)
@@ -94,6 +95,20 @@ def rebuild_new_ui(force=False):
pyrrc(in_file, out_file)
+ # Icons for Particle Editor
+ images_root = os.path.join(execute_root, 'Perspectives/ParticleEditor/UI/icons')
+ out_root = os.path.join(execute_root, 'Perspectives/ParticleEditor/UI')
+ rc_file = 'icons.qrc'
+ out_file = 'icons_rc.py'
+
+ in_file = os.path.join(images_root, rc_file)
+ out_file = os.path.join(out_root, out_file)
+
+ if force or file_in_newer(in_file, out_file):
+ print("Generating " + out_file + " ...")
+ pyrrc(in_file, out_file)
+
+
def main():
""" Entry point for running as a script """
diff --git a/src/sas/sascalc/pr/p_invertor.py b/src/sas/sascalc/pr/p_invertor.py
index 78e380be4d..8eb23c322a 100644
--- a/src/sas/sascalc/pr/p_invertor.py
+++ b/src/sas/sascalc/pr/p_invertor.py
@@ -14,11 +14,11 @@
class Pinvertor(object):
#q data
- x = np.empty(0, dtype=np.float)
+ x = np.empty(0, dtype=np.float64)
#I(q) data
- y = np.empty(0, dtype=np.float)
+ y = np.empty(0, dtype=np.float64)
#dI(q) data
- err = np.empty(0, dtype=np.float)
+ err = np.empty(0, dtype=np.float64)
#Number of q points
npoints = 0
#Number of I(q) points