From 6103644261998008e4f4fa84d31e40d13c25d608 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Tue, 9 May 2023 17:57:08 +0100 Subject: [PATCH 01/38] first steps for the editor --- setup.py | 4 + .../ParticleEditor/ParticleEditor.py | 0 .../ParticleEditor/computation.py | 4 + .../ParticleEditor/editor/syntax_highlight.py | 188 ++++++++++++++++++ .../editor/syntax_highlight_test.py | 17 ++ .../Perspectives/ParticleEditor/particle.py | 17 ++ 6 files changed, 230 insertions(+) create mode 100644 src/sas/sasview/Perspectives/ParticleEditor/ParticleEditor.py create mode 100644 src/sas/sasview/Perspectives/ParticleEditor/computation.py create mode 100644 src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight.py create mode 100644 src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight_test.py create mode 100644 src/sas/sasview/Perspectives/ParticleEditor/particle.py diff --git a/setup.py b/setup.py index 1195979830..9749757db6 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 "" diff --git a/src/sas/sasview/Perspectives/ParticleEditor/ParticleEditor.py b/src/sas/sasview/Perspectives/ParticleEditor/ParticleEditor.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/sasview/Perspectives/ParticleEditor/computation.py b/src/sas/sasview/Perspectives/ParticleEditor/computation.py new file mode 100644 index 0000000000..74d23adc8d --- /dev/null +++ b/src/sas/sasview/Perspectives/ParticleEditor/computation.py @@ -0,0 +1,4 @@ +import numpy as np + +def cross_section(particle: Particle, plane_origin: np.ndarray, plane_normal: np.ndarray): + pass \ No newline at end of file diff --git a/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight.py b/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight.py new file mode 100644 index 0000000000..b3dcd676d3 --- /dev/null +++ b/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight.py @@ -0,0 +1,188 @@ +""" + + +Modified from: art1415926535/PyQt5-syntax-highlighting on github + + +""" + +import sys +from PySide6.QtCore import QRegularExpression +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]), +} + + +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', + ] + + # 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'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']), + # Single-quoted string, possibly containing escape sequences + (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), + + # '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']), + + # 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']), + ] + + # 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. + """ + # Do other syntax formatting + for expression, nth, format in self.rules: + index = expression.indexIn(text, 0) + + while index >= 0: + # We actually want the index of the nth match + index = expression.pos(nth) + length = len(expression.cap(nth)) + self.setFormat(index, length, format) + index = expression.indexIn(text, index + length) + + self.setCurrentBlockState(0) + + # Do multi-line strings + in_multiline = self.match_multiline(text, *self.tri_single) + if not in_multiline: + in_multiline = self.match_multiline(text, *self.tri_double) + + def match_multiline(self, text, delimiter, in_state, style): + """Do highlighting of multi-line strings. ``delimiter`` should be a + ``QRegExp`` for triple-single-quotes or triple-double-quotes, and + ``in_state`` should be a unique integer to represent the corresponding + state changes when inside those strings. Returns True if we're still + inside a multi-line string when this function is finished. + """ + # If inside triple-single quotes, start at 0 + if self.previousBlockState() == in_state: + start = 0 + add = 0 + # Otherwise, look for the delimiter on this line + else: + start = delimiter.indexIn(text) + # Move past this match + add = delimiter.matchedLength() + + # As long as there's a delimiter match on this line... + while start >= 0: + # Look for the ending delimiter + end = delimiter.indexIn(text, start + add) + # Ending delimiter on this line? + if end >= add: + length = end - start + add + delimiter.matchedLength() + self.setCurrentBlockState(0) + # No; multi-line string + else: + self.setCurrentBlockState(in_state) + length = len(text) - start + add + # Apply formatting + self.setFormat(start, length, style) + # Look for the next match + start = delimiter.indexIn(text, start + length) + + # Return True if still inside a multi-line string, False otherwise + if self.currentBlockState() == in_state: + return True + else: + return False \ No newline at end of file diff --git a/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight_test.py b/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight_test.py new file mode 100644 index 0000000000..65505caa32 --- /dev/null +++ b/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight_test.py @@ -0,0 +1,17 @@ +from PySide6 import QtWidgets +import syntax_highlight + +app = QtWidgets.QApplication([]) +editor = QtWidgets.QPlainTextEdit() +editor.setStyleSheet("""QPlainTextEdit{ + font-family:'Consolas'; + color: #ccc; + background-color: #2b2b2b;}""") +highlight = syntax_highlight.PythonHighlighter(editor.document()) +editor.show() + +# Load syntax.py into the editor for demo purposes +infile = open('syntax_highlight.py', 'r') +editor.setPlainText(infile.read()) + +app.exec_() \ No newline at end of file diff --git a/src/sas/sasview/Perspectives/ParticleEditor/particle.py b/src/sas/sasview/Perspectives/ParticleEditor/particle.py new file mode 100644 index 0000000000..83b34bb0e9 --- /dev/null +++ b/src/sas/sasview/Perspectives/ParticleEditor/particle.py @@ -0,0 +1,17 @@ +from typing import Dict +from abc import ABC, abstractmethod + +class Particle(ABC): + def __init__(self, max_radius): + self.max_radius = max_radius + + @abstractmethod + def xyz_evaluate(self, x, y, z, parameters: Dict[str, float]): + pass + +class CartesianParticle(Particle): + def __init__(self, max_radius): + super().__init__(max_radius) + + def xyz_evaluate(self, x, y, z, parameters: Dict[str, float]): + pass \ No newline at end of file From 36f417255e664cb9bbf15ad3257b0764835e9529 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Wed, 10 May 2023 17:21:09 +0100 Subject: [PATCH 02/38] Started real space view and added basic interpreter --- .../ParticleEditor/FunctionViewer.py | 63 ++++++++ .../ParticleEditor/ParticleEditor.py | 0 .../Perspectives/ParticleEditor/__init__.py | 0 .../ParticleEditor/editor/__init__.py | 0 .../ParticleEditor/editor/syntax_highlight.py | 0 .../editor/syntax_highlight_test.py | 0 .../ParticleEditor/function_processor.py | 142 ++++++++++++++++++ .../Perspectives/ParticleEditor/particle.py | 0 .../ParticleEditor/computation.py | 4 - 9 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py rename src/sas/{sasview => qtgui}/Perspectives/ParticleEditor/ParticleEditor.py (100%) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/__init__.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/editor/__init__.py rename src/sas/{sasview => qtgui}/Perspectives/ParticleEditor/editor/syntax_highlight.py (100%) rename src/sas/{sasview => qtgui}/Perspectives/ParticleEditor/editor/syntax_highlight_test.py (100%) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py rename src/sas/{sasview => qtgui}/Perspectives/ParticleEditor/particle.py (100%) delete mode 100644 src/sas/sasview/Perspectives/ParticleEditor/computation.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py new file mode 100644 index 0000000000..08dc7dd33b --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -0,0 +1,63 @@ +import numpy as np + +from PySide6 import QtCore, QtGui, QtWidgets +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFontMetrics + +def cross_section(particle: Particle, plane_origin: np.ndarray, plane_normal: np.ndarray): + pass + +class FunctionViewer(QtWidgets.QGraphicsView): + def __init__(self, parent=None): + super().__init__(parent) + + self.radius = 1 + self.sizePx = 500 + self.function = lambda x,y,z: np.ones_like(x) + + self.theta = 0.0 + self.phi = 0.0 + self.normal_offset = 0.0 + + self.scene = QtWidgets.QGraphicsScene() + self.setScene(self.scene) + + self.pixmap = QtGui.QPixmap(self.sizePx, self.sizePx) + self.pixmap_item = self.scene.addPixmap(self.pixmap) + + + def setRadius(self): + pass + def setSizePx(self, size): + pass + def setFunction(self, fun): + + # Set the function here + + self.updateImage() + + def updateImage(self): + + # Draw image + + self.drawScale() + self.drawAxes() + + + def drawScale(self): + pass + + def drawAxes(self): + pass + + +def main(): + """ Show a demo of the function viewer""" + app = QtWidgets.QApplication([]) + viewer = FunctionViewer() + viewer.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/sasview/Perspectives/ParticleEditor/ParticleEditor.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParticleEditor.py similarity index 100% rename from src/sas/sasview/Perspectives/ParticleEditor/ParticleEditor.py rename to src/sas/qtgui/Perspectives/ParticleEditor/ParticleEditor.py 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/editor/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/editor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight.py b/src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight.py similarity index 100% rename from src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight.py rename to src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight.py diff --git a/src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight_test.py b/src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight_test.py similarity index 100% rename from src/sas/sasview/Perspectives/ParticleEditor/editor/syntax_highlight_test.py rename to src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight_test.py 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..87abf54795 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py @@ -0,0 +1,142 @@ +import inspect +from typing import Callable +import numpy as np + +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): + return x,y,z + + +def spherical_converter(x,y,z): + 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] + +# +# Main processor +# + +def process_text(input_text: str): + new_locals = {} + new_globals = {} + + exec(input_text, new_globals, new_locals) + # 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 + +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_text(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) + diff --git a/src/sas/sasview/Perspectives/ParticleEditor/particle.py b/src/sas/qtgui/Perspectives/ParticleEditor/particle.py similarity index 100% rename from src/sas/sasview/Perspectives/ParticleEditor/particle.py rename to src/sas/qtgui/Perspectives/ParticleEditor/particle.py diff --git a/src/sas/sasview/Perspectives/ParticleEditor/computation.py b/src/sas/sasview/Perspectives/ParticleEditor/computation.py deleted file mode 100644 index 74d23adc8d..0000000000 --- a/src/sas/sasview/Perspectives/ParticleEditor/computation.py +++ /dev/null @@ -1,4 +0,0 @@ -import numpy as np - -def cross_section(particle: Particle, plane_origin: np.ndarray, plane_normal: np.ndarray): - pass \ No newline at end of file From ed2ec27410a216ef02f0d2eecedf261b4fa730b0 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Fri, 12 May 2023 16:02:51 +0100 Subject: [PATCH 03/38] Basic viewer functioning --- .../ParticleEditor/FunctionViewer.py | 201 ++++++++++++++++-- .../ParticleEditor/function_processor.py | 2 +- 2 files changed, 188 insertions(+), 15 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index 08dc7dd33b..1b47b9bf52 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -4,57 +4,230 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QFontMetrics -def cross_section(particle: Particle, plane_origin: np.ndarray, plane_normal: np.ndarray): - pass +def rotation_matrix(theta: float, phi: float): -class FunctionViewer(QtWidgets.QGraphicsView): + st = np.sin(theta) + ct = np.cos(theta) + sp = np.sin(phi) + cp = np.cos(phi) + + r = np.array([ + [st*cp, ct*cp, -sp], + [st*sp, ct*sp, cp], + [ct, -st, 0]]).T + + return r +def cross_section_coordinates(radius: float, theta: float, phi: 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, z, y)) + + r = rotation_matrix(theta, phi) + + return np.dot(r, xyz).T + + +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.sizePx = 500 + self.upscale = 2 + self._size_px = self.layer_size*self.upscale self.function = lambda x,y,z: np.ones_like(x) + self.coordinate_mapping = lambda x,y,z: (x,y,z) self.theta = 0.0 self.phi = 0.0 self.normal_offset = 0.0 - self.scene = QtWidgets.QGraphicsScene() - self.setScene(self.scene) + 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.sliceViewer = QtWidgets.QGraphicsView() + + self.sliceScene = QtWidgets.QGraphicsScene() + self.sliceViewer.setScene(self.sliceScene) - self.pixmap = QtGui.QPixmap(self.sizePx, self.sizePx) - self.pixmap_item = self.scene.addPixmap(self.pixmap) + pixmap = QtGui.QPixmap(self._size_px, self._size_px) + self.slicePixmapItem = self.sliceScene.addPixmap(pixmap) + + self.theta_slider = QtWidgets.QSlider(Qt.Horizontal) + self.theta_slider.setRange(0, 180) + self.theta_slider.setTickInterval(15) + self.theta_slider.valueChanged.connect(self.onThetaChanged) + + self.phi_slider = QtWidgets.QSlider(Qt.Horizontal) + self.phi_slider.setRange(-180, 180) + self.phi_slider.setTickInterval(15) + self.phi_slider.valueChanged.connect(self.onPhiChanged) + + self.depth_slider = QtWidgets.QSlider(Qt.Horizontal) + self.depth_slider.setRange(-100, 100) + self.depth_slider.setTickInterval(10) + self.depth_slider.valueChanged.connect(self.onDepthChanged) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.densityViewer) + layout.addWidget(self.theta_slider) + layout.addWidget(self.phi_slider) + layout.addWidget(self.sliceViewer) + layout.addWidget(self.depth_slider) + + self.setLayout(layout) def setRadius(self): pass + def setSizePx(self, size): pass + def setFunction(self, fun): - # Set the function here + self.function = fun self.updateImage() + def setCoordinateMapping(self, fun): + self.coordinate_mapping = fun + + 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 onDepthChanged(self): + self.normal_offset = self.radius * float(self.depth_slider.value()) / 100 + self.updateImage() + def updateImage(self): # Draw image - self.drawScale() - self.drawAxes() + 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.theta, self.phi, 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 - def drawScale(self): - pass + if bg_values is None: + bg_values = values + else: + bg_values += values + + min_value = np.min(bg_values) + max_value = np.max(bg_values) - def drawAxes(self): + 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) + + 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 + + sampling = cross_section_coordinates(self.radius, self.theta, self.phi, 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): pass + def drawAxes(self, im): + # in top 100 pixels + pass 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 + app = QtWidgets.QApplication([]) viewer = FunctionViewer() + viewer.setCoordinateMapping(spherical_converter) + viewer.setFunction(micelle) + viewer.show() app.exec_() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py index 87abf54795..196b164573 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py @@ -36,7 +36,7 @@ def process_text(input_text: str): new_locals = {} new_globals = {} - exec(input_text, new_globals, new_locals) + exec(input_text, new_globals, new_locals) # TODO: provide access to solvent SLD somehow # print(ev) # print(new_globals) # print(new_locals) From 9cb1ed77e83807ff54d37d760dea4dea305ac420 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sat, 13 May 2023 16:53:46 +0100 Subject: [PATCH 04/38] Updated editor --- .../ParticleEditor/FunctionViewer.py | 5 +- .../ParticleEditor/OutputViewer.py | 36 +++++ .../ParticleEditor/PythonViewer.py | 41 +++++ .../ParticleEditor/editor/__init__.py | 0 .../editor/syntax_highlight_test.py | 17 -- .../{editor => }/syntax_highlight.py | 146 +++++++++++------- .../qtgui/Plotting/UnitTesting/PlotterTest.py | 4 +- 7 files changed, 172 insertions(+), 77 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/editor/__init__.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight_test.py rename src/sas/qtgui/Perspectives/ParticleEditor/{editor => }/syntax_highlight.py (56%) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index 1b47b9bf52..2a1bdb0269 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -223,10 +223,13 @@ def cube_function(x, y, z): 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.setCoordinateMapping(spherical_converter) - viewer.setFunction(micelle) + viewer.setFunction(pseudo_orbital) viewer.show() app.exec_() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py new file mode 100644 index 0000000000..62e107aa61 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py @@ -0,0 +1,36 @@ + +from PySide6 import QtWidgets +from PySide6.QtGui import QFont + +from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter + +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) + self.setFont(f) + + self.code_highlighter = PythonHighlighter(self.document()) + + def keyPressEvent(self, e): + """ Itercepted key press event""" + + # Do nothing + + return + +def main(): + app = QtWidgets.QApplication([]) + viewer = OutputViewer() + + viewer.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py new file mode 100644 index 0000000000..a0bd91c9a2 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py @@ -0,0 +1,41 @@ + +from PySide6 import QtWidgets +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + + + +from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter + +class PythonViewer(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) + self.setFont(f) + + self.code_highlighter = PythonHighlighter(self.document()) + + def keyPressEvent(self, e): + """ Itercepted key press event""" + if e.key() == Qt.Key_Tab: + # Swap out tabs for four spaces + self.textCursor().insertText(" ") + return + else: + super().keyPressEvent(e) + +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/editor/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/editor/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight_test.py b/src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight_test.py deleted file mode 100644 index 65505caa32..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight_test.py +++ /dev/null @@ -1,17 +0,0 @@ -from PySide6 import QtWidgets -import syntax_highlight - -app = QtWidgets.QApplication([]) -editor = QtWidgets.QPlainTextEdit() -editor.setStyleSheet("""QPlainTextEdit{ - font-family:'Consolas'; - color: #ccc; - background-color: #2b2b2b;}""") -highlight = syntax_highlight.PythonHighlighter(editor.document()) -editor.show() - -# Load syntax.py into the editor for demo purposes -infile = open('syntax_highlight.py', 'r') -editor.setPlainText(infile.read()) - -app.exec_() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight.py b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py similarity index 56% rename from src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight.py rename to src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py index b3dcd676d3..06f2b80935 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/editor/syntax_highlight.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py @@ -3,11 +3,12 @@ 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 +from PySide6.QtCore import QRegularExpression, QRegularExpressionMatchIterator from PySide6.QtGui import QColor, QTextCharFormat, QFont, QSyntaxHighlighter @@ -43,6 +44,7 @@ def format(color, style=''): 'comment': format([128, 128, 128]), 'self': format([150, 85, 140], 'italic'), 'numbers': format([100, 150, 190]), + 'special': format([90, 80, 200], 'bold') } @@ -61,6 +63,10 @@ class PythonHighlighter(QSyntaxHighlighter): 'None', 'True', 'False', ] + special = [ + 'sld', 'solvent_sld', 'magnetism' + ] + # Python operators operators = [ '=', @@ -108,11 +114,6 @@ def __init__(self, document): # Single-quoted string, possibly containing escape sequences (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), - # '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']), - # From '#' until a newline (r'#[^\n]*', 0, STYLES['comment']), @@ -122,67 +123,98 @@ def __init__(self, document): (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. """ - # Do other syntax formatting - for expression, nth, format in self.rules: - index = expression.indexIn(text, 0) - while index >= 0: - # We actually want the index of the nth match - index = expression.pos(nth) - length = len(expression.cap(nth)) + # 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) - index = expression.indexIn(text, index + length) - self.setCurrentBlockState(0) + # 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']), - # Do multi-line strings - in_multiline = self.match_multiline(text, *self.tri_single) - if not in_multiline: - in_multiline = self.match_multiline(text, *self.tri_double) + # Multiblock comments - def match_multiline(self, text, delimiter, in_state, style): - """Do highlighting of multi-line strings. ``delimiter`` should be a - ``QRegExp`` for triple-single-quotes or triple-double-quotes, and - ``in_state`` should be a unique integer to represent the corresponding - state changes when inside those strings. Returns True if we're still - inside a multi-line string when this function is finished. - """ - # If inside triple-single quotes, start at 0 - if self.previousBlockState() == in_state: - start = 0 - add = 0 - # Otherwise, look for the delimiter on this line - else: - start = delimiter.indexIn(text) - # Move past this match - add = delimiter.matchedLength() - - # As long as there's a delimiter match on this line... - while start >= 0: - # Look for the ending delimiter - end = delimiter.indexIn(text, start + add) - # Ending delimiter on this line? - if end >= add: - length = end - start + add + delimiter.matchedLength() - self.setCurrentBlockState(0) - # No; multi-line string + # 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 + print("Close") + + 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 + print("Open") + + 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: - self.setCurrentBlockState(in_state) - length = len(text) - start + add - # Apply formatting - self.setFormat(start, length, style) - # Look for the next match - start = delimiter.indexIn(text, start + length) - - # Return True if still inside a multi-line string, False otherwise - if self.currentBlockState() == in_state: - return True - else: - return False \ No newline at end of file + 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/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 From 127b911b1f332f34fac7de30fbd21f46c17dcdf6 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sat, 13 May 2023 21:29:36 +0100 Subject: [PATCH 05/38] Basic layout --- .../ParticleEditor/DesignWindow.py | 45 ++++ .../ParticleEditor/FunctionViewer.py | 19 ++ .../ParticleEditor/ParticleEditor.py | 0 .../ParticleEditor/UI/DesignWindowUI.ui | 249 ++++++++++++++++++ .../Perspectives/ParticleEditor/particle.py | 17 -- .../ParticleEditor/syntax_highlight.py | 2 - 6 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParticleEditor.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/particle.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py new file mode 100644 index 0000000000..1e94aa5c86 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -0,0 +1,45 @@ +from PySide6 import QtWidgets + +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.UI.DesignWindowUI import Ui_DesignWindow +class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow): + def __init__(self, parent=None): + super().__init__() + + self.setupUi(self) + + definitionLayout = QtWidgets.QVBoxLayout() + self.definitionTab.setLayout(definitionLayout) + + self.pythonViewer = PythonViewer() + self.outputViewer = OutputViewer() + + definitionLayout.addWidget(self.pythonViewer) + definitionLayout.addWidget(self.outputViewer) + + self.functionViewer = FunctionViewer() + self.densityViewerContainer.layout().addWidget(self.functionViewer) + + self.setWindowTitle("Placeholder title") + + self.parent = parent + + +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 index 2a1bdb0269..d70ec648ad 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -50,6 +50,8 @@ def __init__(self, parent=None): self.phi = 0.0 self.normal_offset = 0.0 + self._graphics_viewer_offset = 5 + self.densityViewer = QtWidgets.QGraphicsView() self.densityScene = QtWidgets.QGraphicsScene() @@ -57,6 +59,8 @@ def __init__(self, parent=None): 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.sliceViewer = QtWidgets.QGraphicsView() @@ -65,6 +69,8 @@ def __init__(self, parent=None): 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.theta_slider = QtWidgets.QSlider(Qt.Horizontal) self.theta_slider.setRange(0, 180) @@ -81,15 +87,28 @@ def __init__(self, parent=None): self.depth_slider.setTickInterval(10) self.depth_slider.valueChanged.connect(self.onDepthChanged) + projection_label = QtWidgets.QLabel("Projection") + projection_label.setAlignment(Qt.AlignCenter) + + slice_label = QtWidgets.QLabel("Slice") + slice_label.setAlignment(Qt.AlignCenter) + + spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(projection_label) layout.addWidget(self.densityViewer) layout.addWidget(self.theta_slider) layout.addWidget(self.phi_slider) + layout.addWidget(slice_label) layout.addWidget(self.sliceViewer) layout.addWidget(self.depth_slider) + layout.addItem(spacer) self.setLayout(layout) + self.setFixedWidth(self._size_px + 20) # Perhaps a better way of keeping the viewer width small? + def setRadius(self): pass diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParticleEditor.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParticleEditor.py deleted file mode 100644 index e69de29bb2..0000000000 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..77602bf3fa --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -0,0 +1,249 @@ + + + DesignWindow + + + + 0 + 0 + 868 + 543 + + + + Form + + + + + + + + + 2 + + + + Definition + + + + + Calculation + + + + + + Particle + + + + + + + + Orientational Distribution + + + + + + + + + + Structure Factor + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + Function Parameters + + + Qt::AlignCenter + + + + + + + + + + + + + Structure Factor Parameters + + + Qt::AlignCenter + + + + + + + + + + + + + + + + Calculation Method + + + + + + Output Type + + + + + + 1D + + + true + + + + + + + 2D + + + + + + + + + + + + + + + + Sample Method + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Sample Points + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 10000 + + + + + + + Q Range + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + + Logaritmic + + + true + + + + + + + + + + Q Samples + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Ang + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + Output + + + + + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/particle.py b/src/sas/qtgui/Perspectives/ParticleEditor/particle.py deleted file mode 100644 index 83b34bb0e9..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/particle.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Dict -from abc import ABC, abstractmethod - -class Particle(ABC): - def __init__(self, max_radius): - self.max_radius = max_radius - - @abstractmethod - def xyz_evaluate(self, x, y, z, parameters: Dict[str, float]): - pass - -class CartesianParticle(Particle): - def __init__(self, max_radius): - super().__init__(max_radius) - - def xyz_evaluate(self, x, y, z, parameters: Dict[str, float]): - pass \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py index 06f2b80935..d87324bc9b 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py @@ -185,7 +185,6 @@ def highlightBlock(self, text): if state == in_state: # Comment end - print("Close") state = -1 index = match.capturedStart() @@ -198,7 +197,6 @@ def highlightBlock(self, text): elif state == -1: # Comment start - print("Open") state = in_state start_index = match.capturedStart() From ac4af2ccac60cf88b15d9ccb6b434f3a9b063089 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sun, 14 May 2023 13:51:07 +0100 Subject: [PATCH 06/38] Viewer set up --- .../ParticleEditor/DesignWindow.py | 51 +- .../ParticleEditor/FunctionViewer.py | 242 +++++++-- .../ParticleEditor/LabelledSlider.py | 41 ++ .../ParticleEditor/OutputViewer.py | 16 +- .../ParticleEditor/ParameterTable.py | 4 + .../ParticleEditor/PythonViewer.py | 21 + .../ParticleEditor/SLDMagnetismOption.py | 8 + .../ParticleEditor/UI/AxisButtonsUI.ui | 72 +++ .../ParticleEditor/UI/DesignWindowUI.ui | 508 ++++++++++++------ .../ParticleEditor/UI/PlaneButtonsUI.ui | 72 +++ .../ParticleEditor/UI/SLDMagOptionUI.ui | 50 ++ .../ParticleEditor/ViewerButtons.py | 39 ++ .../ParticleEditor/syntax_highlight.py | 4 +- 13 files changed, 906 insertions(+), 222 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/LabelledSlider.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterTable.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/SLDMagnetismOption.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/AxisButtonsUI.ui create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/PlaneButtonsUI.ui create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/SLDMagOptionUI.ui create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ViewerButtons.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 1e94aa5c86..3f712217d1 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -1,4 +1,5 @@ from PySide6 import QtWidgets +from PySide6.QtCore import Qt from sas.qtgui.Perspectives.ParticleEditor.FunctionViewer import FunctionViewer from sas.qtgui.Perspectives.ParticleEditor.PythonViewer import PythonViewer @@ -9,22 +10,58 @@ def __init__(self, parent=None): super().__init__() self.setupUi(self) + self.setWindowTitle("Placeholder title") + self.parent = parent + + # + # First Tab + # + + hbox = QtWidgets.QHBoxLayout(self) + + splitter = QtWidgets.QSplitter(Qt.Vertical) - definitionLayout = QtWidgets.QVBoxLayout() - self.definitionTab.setLayout(definitionLayout) self.pythonViewer = PythonViewer() self.outputViewer = OutputViewer() - definitionLayout.addWidget(self.pythonViewer) - definitionLayout.addWidget(self.outputViewer) + splitter.addWidget(self.pythonViewer) + splitter.addWidget(self.outputViewer) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + hbox.addWidget(splitter) self.functionViewer = FunctionViewer() - self.densityViewerContainer.layout().addWidget(self.functionViewer) + hbox.addWidget(self.functionViewer) + + self.definitionTab.setLayout(hbox) + + # + # Second Tab + # + + # Populate combo boxes + + self.orientationCombo.addItem("Unoriented") + self.orientationCombo.addItem("Fixed Orientation") + + self.structureFactorCombo.addItem("None") # TODO: Structure Factor Options + + self.methodCombo.addItem("Monte Carlo") + self.methodCombo.addItem("Grid") + + # Populate tables + + # Columns should be name, value, min, max, fit, [remove] + self.parametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) + self.structureFactorParametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) + + self.tabWidget.setAutoFillBackground(True) + + self.tabWidget.setStyleSheet("#tabWidget {background-color:red;}") + - self.setWindowTitle("Placeholder title") - self.parent = parent def main(): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index d70ec648ad..bf05aee4b0 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -1,8 +1,12 @@ import numpy as np -from PySide6 import QtCore, QtGui, QtWidgets -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFontMetrics +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 def rotation_matrix(theta: float, phi: float): @@ -11,12 +15,17 @@ def rotation_matrix(theta: float, phi: float): sp = np.sin(phi) cp = np.cos(phi) - r = np.array([ - [st*cp, ct*cp, -sp], - [st*sp, ct*sp, cp], - [ct, -st, 0]]).T + xz = np.array([ + [ ct, 0, -st], + [ 0, 1, 0], + [ st, 0, ct]]) - return r + yz = np.array([ + [1, 0, 0 ], + [0, cp, -sp], + [0, sp, cp]]) + + return np.dot(xz, yz) def cross_section_coordinates(radius: float, theta: float, phi: float, plane_distance: float, n_points: int): xy_values = np.linspace(-radius, radius, n_points) @@ -25,14 +34,42 @@ def cross_section_coordinates(radius: float, theta: float, phi: float, plane_dis x = x.reshape((-1, )) y = y.reshape((-1, )) - z = np.zeros_like(x) - plane_distance + z = np.zeros_like(x) + plane_distance - xyz = np.vstack((x, z, y)) + xyz = np.vstack((x, y, z)) r = rotation_matrix(theta, phi) 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 +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 + class FunctionViewer(QtWidgets.QWidget): def __init__(self, parent=None): @@ -43,15 +80,25 @@ def __init__(self, parent=None): self.radius = 1 self.upscale = 2 self._size_px = self.layer_size*self.upscale - self.function = lambda x,y,z: np.ones_like(x) + self.function = cube_function + # self.function = lambda x,y,z: x self.coordinate_mapping = lambda x,y,z: (x,y,z) self.theta = 0.0 - self.phi = 0.0 + self.phi = 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_label = QtWidgets.QLabel("Projection") + density_label.setAlignment(Qt.AlignCenter) + self.densityViewer = QtWidgets.QGraphicsView() self.densityScene = QtWidgets.QGraphicsScene() @@ -59,8 +106,13 @@ def __init__(self, parent=None): 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_label = QtWidgets.QLabel("Slice") + slice_label.setAlignment(Qt.AlignCenter) self.sliceViewer = QtWidgets.QGraphicsView() @@ -69,46 +121,117 @@ def __init__(self, parent=None): 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) + # + # self.theta_slider = LabelledSlider("θ", -180, 180, 0) + # self.theta_slider.valueChanged.connect(self.onThetaChanged) + # + # self.phi_slider = LabelledSlider("φ", 0, 180, 0) + # self.phi_slider.valueChanged.connect(self.onPhiChanged) + # + # self.psi_slider = LabelledSlider("ψ", 0, 180, 0) + # self.psi_slider.valueChanged.connect(self.onPsiChanged) - self.theta_slider = QtWidgets.QSlider(Qt.Horizontal) - self.theta_slider.setRange(0, 180) - self.theta_slider.setTickInterval(15) - self.theta_slider.valueChanged.connect(self.onThetaChanged) - - self.phi_slider = QtWidgets.QSlider(Qt.Horizontal) - self.phi_slider.setRange(-180, 180) - self.phi_slider.setTickInterval(15) - self.phi_slider.valueChanged.connect(self.onPhiChanged) + self.plane_buttons = PlaneButtons(self.setAngles) - self.depth_slider = QtWidgets.QSlider(Qt.Horizontal) - self.depth_slider.setRange(-100, 100) - self.depth_slider.setTickInterval(10) + self.depth_slider = LabelledSlider("Depth", -100, 100, 0, name_width=35, value_width=35, value_units="%") self.depth_slider.valueChanged.connect(self.onDepthChanged) - projection_label = QtWidgets.QLabel("Projection") - projection_label.setAlignment(Qt.AlignCenter) + 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) - slice_label = QtWidgets.QLabel("Slice") - slice_label.setAlignment(Qt.AlignCenter) spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + layout = QtWidgets.QVBoxLayout() - layout.addWidget(projection_label) + layout.addWidget(density_label) layout.addWidget(self.densityViewer) - layout.addWidget(self.theta_slider) - layout.addWidget(self.phi_slider) + # layout.addWidget(self.theta_slider) + # layout.addWidget(self.phi_slider) + # layout.addWidget(self.psi_slider) layout.addWidget(slice_label) layout.addWidget(self.sliceViewer) + layout.addWidget(self.plane_buttons) 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 + + # Show images + self.updateImage() + def eventFilter(self, source, event): + + + 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.theta += self.dThetadX * dx + self.phi += self.dPhidY * dy + + self.theta %= 2*np.pi + self.phi %= 2*np.pi + + self.lastMouseX = x + self.lastMouseY = y + + self.updateImage() + + return + + super().eventFilter(source, event) + def setRadius(self): pass @@ -125,17 +248,41 @@ def setFunction(self, fun): def setCoordinateMapping(self, fun): self.coordinate_mapping = fun - 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 + def onDisplayTypeSelected(self): + 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): + self.mag_theta = np.pi*float(self.mag_theta_slider.value())/180 + self.updateImage(mag_only=True) + def onMagPhiChanged(self): + self.mag_phi = np.pi * float(self.mag_phi_slider.value()) / 180 + self.updateImage(mag_only=True) + + def onPsiChanged(self): + self.psi = np.pi * float(self.psi_slider.value()) / 180 self.updateImage() def onDepthChanged(self): self.normal_offset = self.radius * float(self.depth_slider.value()) / 100 self.updateImage() - def updateImage(self): + def setAngles(self, theta_deg, phi_deg): + + self.theta = np.pi * theta_deg / 180 + self.phi = np.pi * (phi_deg + 180) / 180 + + self.updateImage() + + def updateImage(self, mag_only=True): # Draw image @@ -168,6 +315,11 @@ def updateImage(self): image = np.concatenate((bg_image_values, bg_image_values, bg_image_values), axis=2) + self.drawScale(image) + self.drawAxes(image) + + # image = np.ascontiguousarray(np.flip(image, 0)) # Y is upside down + height, width, channels = image.shape bytes_per_line = channels * width qimage = QtGui.QImage(image.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888) @@ -201,6 +353,7 @@ def updateImage(self): self.drawScale(image) self.drawAxes(image) + # image = np.ascontiguousarray(np.flip(image, 0)) # Y is upside down height, width, channels = image.shape bytes_per_line = channels * width @@ -214,8 +367,19 @@ def drawScale(self, im): pass def drawAxes(self, im): - # in top 100 pixels - pass + vectors = 20*rotation_matrix(self.theta, self.phi) + + 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""" 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 index 62e107aa61..5d722ae7cc 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py @@ -3,6 +3,9 @@ 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 Designer Log - SasView {version}

" class OutputViewer(QtWidgets.QTextEdit): """ Python text editor window""" @@ -13,9 +16,12 @@ def __init__(self, parent=None): # System independent monospace font f = QFont("unexistent") f.setStyleHint(QFont.Monospace) + f.setPointSize(9) + f.setWeight(QFont.Weight(500)) + self.setFont(f) - self.code_highlighter = PythonHighlighter(self.document()) + self.setText(initial_text) def keyPressEvent(self, e): """ Itercepted key press event""" @@ -24,6 +30,14 @@ def keyPressEvent(self, e): return + def addError(self, text): + pass + + def addText(self, text): + pass + + + def main(): app = QtWidgets.QApplication([]) viewer = OutputViewer() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterTable.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterTable.py new file mode 100644 index 0000000000..303f213802 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterTable.py @@ -0,0 +1,4 @@ +from PySide6 import QtWidgets + +class ParameterTable(QtWidgets.QTableWidget): + pass \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py index a0bd91c9a2..06dbc3c709 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py @@ -7,6 +7,23 @@ from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter +default_text = '''""" Default text goes here... + +should probably define a simple function +""" + +def sld(x,y,z): + """ A cube with side length 1 """ + + inside = (np.abs(x) < 0.5) & (np.abs(y) < 0.5) & (np.abs(z) < 0.5) + + out = np.zeros_like(x) + + out[inside] = 1 + + return out + +''' class PythonViewer(QtWidgets.QTextEdit): """ Python text editor window""" def __init__(self, parent=None): @@ -15,10 +32,14 @@ def __init__(self, parent=None): # 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: 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/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 77602bf3fa..5c1ea667f2 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -6,21 +6,21 @@ 0 0 - 868 - 543 + 1009 + 370 Form - + - 2 + 0 @@ -29,129 +29,57 @@ - Calculation + Particles - + - - - Particle + + + 0 - - - - - - - Orientational Distribution - - - - - - - - - - Structure Factor - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - - - Function Parameters - - - Qt::AlignCenter - - - - - - - - - - - - - Structure Factor Parameters - - - Qt::AlignCenter - - - - - - - - - - - - - - - - Calculation Method - - - - - - Output Type - - - - - - 1D - - - true - - - - - - - 2D - - - - - - - - - - - - + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 10 + + + + + 10 + + + 10 + - + - Sample Method + Orientational Distribution Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + - + - Sample Points + Structure Factor Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter @@ -159,70 +87,307 @@ - - - 10000 - - - - - - - Q Range - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - - - - Logaritmic - - - true - - - - - - - - - - Q Samples - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Ang - - + - + + + + + Function Parameters + + + Qt::AlignCenter + + + + + + + 1 + + + 6 + + + false + + + + + + + + + + + + + + Structure Factor Parameters + + + Qt::AlignCenter + + + + + + + 1 + + + 6 + + + false + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Calculation + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + - + + + 0 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + Sample Points + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 10000 + + + + + + + Q Min + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 0.001 + + + + + + + + + + Sample Method + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Q Max + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 1.0 + + + + + + + Ang + + + + + + + Ang + + + + + + + Q Samples + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 100 + + + + + + + Logaritmic + + + true + + + + + + + Estimated Time: 7 Units + + + Qt::AlignCenter + + + + + + + 0 + + + + + 1D + + + true + + + + + + + 2D + + + + + + + + + Output Type + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + - Qt::Vertical + Qt::Horizontal - 20 - 40 + 40 + 20 @@ -239,9 +404,6 @@ - - - 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/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/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/syntax_highlight.py b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py index d87324bc9b..84e15f3b46 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py @@ -110,9 +110,9 @@ def __init__(self, document): (r'\bself\b', 0, STYLES['self']), # Double-quoted string, possibly containing escape sequences - (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']), + (r'[rf]?"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']), # Single-quoted string, possibly containing escape sequences - (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), + (r"[rf]?'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), # From '#' until a newline (r'#[^\n]*', 0, STYLES['comment']), From 32ab1118d7fdaef4151dd9993b7dedbe711d1aee Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sun, 14 May 2023 15:29:00 +0100 Subject: [PATCH 07/38] More small changes --- .../ParticleEditor/CodeToolBar.py | 9 ++ .../ParticleEditor/FunctionViewer.py | 100 ++++++------- .../ParticleEditor/RadiusSelection.py | 18 +++ .../ParticleEditor/UI/CodeToolBarUI.ui | 81 ++++++++++ .../ParticleEditor/UI/DesignWindowUI.ui | 141 +++++++++++------- .../ParticleEditor/UI/RadiusSelectionUI.ui | 86 +++++++++++ .../ParticleEditor/UI/icons/download-icon.png | Bin 0 -> 2970 bytes .../ParticleEditor/UI/icons/hammer-icon.png | Bin 0 -> 3871 bytes .../ParticleEditor/UI/icons/icons.qrc | 8 + .../ParticleEditor/UI/icons/save-icon.png | Bin 0 -> 3426 bytes .../ParticleEditor/UI/icons/upload-icon.png | Bin 0 -> 2980 bytes .../qtgui/Plotting/Slicers/SectorSlicer.py | 4 +- 12 files changed, 336 insertions(+), 111 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/RadiusSelection.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/download-icon.png create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/hammer-icon.png create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/save-icon.png create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/upload-icon.png diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py new file mode 100644 index 0000000000..549d2f1707 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py @@ -0,0 +1,9 @@ +from PySide6 import QtWidgets + +from sas.qtgui.Perspectives.ParticleEditor.UI.CodeToolBarUI import Ui_CodeToolBar + +class RadiusSelection(QtWidgets.QWidget, Ui_CodeToolBar): + def __init__(self, parent=None): + super().__init__() + + self.setupUi(self) \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index bf05aee4b0..b8b0609516 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -7,26 +7,27 @@ 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 -def rotation_matrix(theta: float, phi: float): +def rotation_matrix(alpha: float, beta: float): - st = np.sin(theta) - ct = np.cos(theta) - sp = np.sin(phi) - cp = np.cos(phi) + sa = np.sin(alpha) + ca = np.cos(alpha) + sb = np.sin(beta) + cb = np.cos(beta) xz = np.array([ - [ ct, 0, -st], + [ ca, 0, -sa], [ 0, 1, 0], - [ st, 0, ct]]) + [ sa, 0, ca]]) yz = np.array([ [1, 0, 0 ], - [0, cp, -sp], - [0, sp, cp]]) + [0, cb, -sb], + [0, sb, cb]]) return np.dot(xz, yz) -def cross_section_coordinates(radius: float, theta: float, phi: float, plane_distance: float, n_points: int): +def cross_section_coordinates(radius: float, alpha: float, beta: float, plane_distance: float, n_points: int): xy_values = np.linspace(-radius, radius, n_points) @@ -38,7 +39,7 @@ def cross_section_coordinates(radius: float, theta: float, phi: float, plane_dis xyz = np.vstack((x, y, z)) - r = rotation_matrix(theta, phi) + r = rotation_matrix(alpha, beta) return np.dot(r, xyz).T @@ -57,10 +58,10 @@ def draw_line_in_place(im, x0, y0, dx, dy, channel): im[y, x, channel] = 255 def cube_function(x, y, z): - inside = np.logical_and(np.abs(x) <= 0.5, + inside = np.logical_and(np.abs(x) <= 5, np.logical_and( - np.abs(y) <= 0.5, - np.abs(z) <= 0.5 )) + np.abs(y) <= 5, + np.abs(z) <= 5 )) # # print(cube_function) # print(np.any(inside), np.any(np.logical_not(inside))) @@ -84,8 +85,8 @@ def __init__(self, parent=None): # self.function = lambda x,y,z: x self.coordinate_mapping = lambda x,y,z: (x,y,z) - self.theta = 0.0 - self.phi = np.pi + self.alpha = 0.0 + self.beta = np.pi self.normal_offset = 0.0 self.mag_theta = 0.0 self.mag_phi = 0.0 @@ -96,8 +97,7 @@ def __init__(self, parent=None): # Qt Setup # - density_label = QtWidgets.QLabel("Projection") - density_label.setAlignment(Qt.AlignCenter) + # Density self.densityViewer = QtWidgets.QGraphicsView() @@ -111,8 +111,7 @@ def __init__(self, parent=None): self.densityViewer.setFixedHeight(self._size_px + self._graphics_viewer_offset) self.densityViewer.setCursor(Qt.OpenHandCursor) - slice_label = QtWidgets.QLabel("Slice") - slice_label.setAlignment(Qt.AlignCenter) + # Slice self.sliceViewer = QtWidgets.QGraphicsView() @@ -125,26 +124,23 @@ def __init__(self, parent=None): 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) - # - # self.theta_slider = LabelledSlider("θ", -180, 180, 0) - # self.theta_slider.valueChanged.connect(self.onThetaChanged) - # - # self.phi_slider = LabelledSlider("φ", 0, 180, 0) - # self.phi_slider.valueChanged.connect(self.onPhiChanged) - # - # self.psi_slider = LabelledSlider("ψ", 0, 180, 0) - # self.psi_slider.valueChanged.connect(self.onPsiChanged) + + # General control + + self.radius_control = RadiusSelection("View Size") + 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) @@ -162,19 +158,15 @@ def __init__(self, parent=None): 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(density_label) + layout.addWidget(self.densityViewer) - # layout.addWidget(self.theta_slider) - # layout.addWidget(self.phi_slider) - # layout.addWidget(self.psi_slider) - layout.addWidget(slice_label) - layout.addWidget(self.sliceViewer) 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) @@ -191,7 +183,9 @@ def __init__(self, parent=None): self.lastMouseX = 0 self.lastMouseY = 0 self.dThetadX = 0.01 - self.dPhidY = 0.01 + self.dPhidY = -0.01 + + self.radius = self.radius_control.radius() # Show images self.updateImage() @@ -217,11 +211,11 @@ def eventFilter(self, source, event): dx = x - self.lastMouseX dy = y - self.lastMouseY - self.theta += self.dThetadX * dx - self.phi += self.dPhidY * dy + self.alpha += self.dThetadX * dx + self.beta += self.dPhidY * dy - self.theta %= 2*np.pi - self.phi %= 2*np.pi + self.alpha %= 2 * np.pi + self.beta %= 2 * np.pi self.lastMouseX = x self.lastMouseY = y @@ -233,11 +227,9 @@ def eventFilter(self, source, event): super().eventFilter(source, event) - def setRadius(self): - pass - - def setSizePx(self, size): - pass + def onRadiusChanged(self): + self.radius = self.radius_control.radius() + self.updateImage() def setFunction(self, fun): @@ -276,9 +268,9 @@ def onDepthChanged(self): self.updateImage() def setAngles(self, theta_deg, phi_deg): - - self.theta = np.pi * theta_deg / 180 - self.phi = np.pi * (phi_deg + 180) / 180 + + self.alpha = np.pi * theta_deg / 180 + self.beta = np.pi * (phi_deg + 180) / 180 self.updateImage() @@ -288,7 +280,7 @@ def updateImage(self, mag_only=True): 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.theta, self.phi, depth, self.layer_size) + 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 @@ -329,7 +321,7 @@ def updateImage(self, mag_only=True): # Cross section - sampling = cross_section_coordinates(self.radius, self.theta, self.phi, self.normal_offset, self._size_px) + 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 @@ -367,7 +359,7 @@ def drawScale(self, im): pass def drawAxes(self, im): - vectors = 20*rotation_matrix(self.theta, self.phi) + vectors = 20*rotation_matrix(self.alpha, self.beta) y = self._size_px - 30 x = 30 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/UI/CodeToolBarUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui new file mode 100644 index 0000000000..da006c2006 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui @@ -0,0 +1,81 @@ + + + CodeToolBar + + + + 0 + 0 + 280 + 30 + + + + Form + + + + 5 + + + 5 + + + 5 + + + 5 + + + + + Load + + + + :/particle_editor/upload-icon.png:/particle_editor/upload-icon.png + + + + + + + Save + + + + :/particle_editor/download-icon.png:/particle_editor/download-icon.png + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Build + + + + :/particle_editor/hammer-icon.png:/particle_editor/hammer-icon.png + + + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 5c1ea667f2..878e44e092 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -212,7 +212,61 @@ - + + + + 0 + + + + + 1D + + + true + + + + + + + 2D + + + + + + + + + Estimated Time: 7 Units + + + Qt::AlignCenter + + + + + + + Logaritmic + + + true + + + + + + + Output Type + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + Sample Points @@ -222,14 +276,14 @@ - + 10000 - + Q Min @@ -239,17 +293,14 @@ - + 0.001 - - - - + Sample Method @@ -259,7 +310,7 @@ - + Q Max @@ -269,28 +320,24 @@ - + 1.0 - - - - Ang - - + + - - + + Ang - + Q Samples @@ -300,67 +347,51 @@ - - + + - 100 + Ang - + - Logaritmic - - - true + 100 - - + + - Estimated Time: 7 Units - - - Qt::AlignCenter + Sample Radius - - - - 0 - + + - - - 1D + + + false - - true + + 10 - + - 2D + Get From 'Definition' Tab + + + true - - - - Output Type - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - 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..997c122f02 --- /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 + + + 10.000000000000000 + + + + + + + Å + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 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 0000000000000000000000000000000000000000..8e83a48250fa98962fa868beb70027c728626546 GIT binary patch literal 2970 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 XbbEyxXDgroH`f~BaI*5mh1>rFJ1S(% literal 0 HcmV?d00001 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..3a1f710e5e --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc @@ -0,0 +1,8 @@ + + + download-icon.png + upload-icon.png + save-icon.png + hammer-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 0000000000000000000000000000000000000000..d84dd60913d762bd58a964bacc31352500018d5f GIT binary patch literal 3426 zcmd52wd++FsasRmAA9MbGzi+O&=9**7?;9)5 z+G-5ApxkU*wynE-xj;ytSx^s7yJL)HI-obSX`Vzkd2isl&o=9 zu|C_wl+~KCH1%}b(68o4sgZ9+-*(-!pBk)8iKfSEv<1G(KNYrY49oG>4_?AO+Oiyg zX1005x}WxU4|hk}XR{W>JUO{ld;WTdbuBG6Keh2M&&`hcA)(u@{l0~Vp5jkB-{2b? z4DxAKr880GvSEU_maBo9?Sbr~cl!?hqUy9e>(bznOW$i>>cwS!Bm*k2V>LHT>pWGS zN_dW=S@ZP|Wgj;^Usv>-#|hhEW3>;DE>0 z_mj>@lxhr6;3i4DbepzEW{MJZ{0~-7@W)F|tGbp=59g$4z1OOeZ)+Mr=2la^HIIw> zBLfbZ5|h1{{qh*kM*V7SBF}vw#4Jre@%2Wt zv@|{v6n)u~uv608m(GxDv!_nICl#-_b<1_r?(haPrTFP@Spk&AW=a<9Z@l?8Y7!eq z-KgGtHiXSsg2k)WgloHo^pfTo^%i@l1wL8W^=jO?E24=;jtV_od?ot%;AODWVnItkScH?FG7+ zlk(P=D?nDk^Wi!dgecpz8@7^QZJriZMwh2$<^y|M2Rb$S^e)H|Sk4;(d7y_qC&V_c zB(lxuC`ShhFG#Zo){CJY7HtMU2NLw49E5giEP^>L^9~$uLb+Knh@le8<^Z^8yTev; z=>-!^-MRtNXn2tc=9}jR31sedRUuyw$T%zjlnpct5ff3m%W4J!05Wgqf{h8inLwa2 zu&*||{#Nv7!FbA7zJ)d1{mQ1FB@^?+^`0!zg|OJigGBIw$lAGZL0qT*h(m$!R?Jw0 zuG>(4+EB*FZ~0<%(7%wrQV19?ynzV!Lqq}B7gXrNI~N3eglmY!*;T5-35qb zfbE+!G9ShW%)uWrp8}JFUCu7R_7kwvAR4u;zp&%|9fUg%vi4Ms$;ZFmuVUW1$0+_O zbMx@r=ujgfT7%^bgJGYF&+Mo#e{&X4jHXJNtqT9pfyQJndug@%3(HQ#~BR z#;hG@6S9A(3#8x_kbws%w!aC6tk%eoxq}12ZeXevAc4MMPUq@jF{gEpAUlWNc*f~t zad%VF^|9o;UB8g^f~JSsAf(tebQzs-HRLrC)cPhwOWp;(6(J@3+6fIoMnWqS~ zp&FI++qcM=dUxChdQ}1q)&_sKBvW1)4(0RqNuG-^fcKs5LrMu!WNfYdp3XxP<*&S8 z%Bl5_ROutr3?q17Fy)^%om%0OG)TuL(^jr(pGd8d$(?>$d-@C_%vq9QoI` ze48K`uz(?M1Qm$?k>*k0H89kmLEJ2mk%oSRWdgBZDOQ&Z_cU8MZkLO3E1)iY&=Ql) z$+eDMo0}99F*n&6c2YR4^!$Nm@Z7w4>kyeOd8Nd= z12oOU9e2=$)tqg{aPb;2jNt~#F!^sr=-pm$wNDb1Ua5Dus$;yyjpeJz!T?I0&EC=z z|8}H5&2OIRXJgEu;~PH{J|jNTpV7a_)ZD7V!v|CQlA=~Ao4976EvvD3>0IMx`#yxD zfJabfXYW401(&J9uOop0 zFhN^uGF(R3j#b_A;0Fs*iYJcNTyxo?Os&_emeXF^8T-uwB4r(}d!b|U{r#e;`t95c zZol|vxQxzF_3y2`r#Yh|Xa;LCOK5=w?}PnNYWPSE@WTM5WWeVlBwwU)s!7Asp;#Y? z2#giYRpKBdG1&6r*@f9I!<8ZQXCIdnsnj=AJlEYIvSt^b7)BDh z`bV#!(9TL|axn_I23Oq(=#YZ)5b(;~P0&Ax6oTCbYYhmHGzT+s&!Z}`-NE!|zOqq9CqRG_; zFoh)QT3$33dp)CO9Vc-^G6S+rQJjLM4oC>gw_kx1yo&g%Hw zIMfe>Nrf%sIAKgk=}on-_j+g$d6pQ9a!23x1PwjDhn|&~UCdI$)9{YyhDH(B&{Sg9 z8P`G(*qK6iEDt?%i>uEmFqDBvc`2|!ggtA}QaSK1L3T=DAIR+QU?J$a#!-P~3eLXS z8UW+s2MgthkZA;?g^L&;g+y7v!r=C&AP@158t4avOB|{z=ck2s57>k5>ZPAE(iOXzv#?y=>u+GPF1zCIC z`#SHt^gP=&C;MaS46lBmr;vypBlQ>w%#FW$E6h2ky05u5qPIW)!~MoPA36?n70v?z zLAz5)3OOvu75qxP0mrjf#a>Tan3Hlw(bsFCylmZM9dn&bgSx+P&Cm3We&*!7k=syy zie^2t2z@Z&fpse@W*TXse4|9f3sual&_o|-dSDKHV!Fr!dvt`R=fonPi4fDX(KJ25 zdU9;A*r%J3V@DWh!#f3iR$+#isA`}MOK(Hjc`>Gw^l@K123kFe`;C}Q*I8+}%R zixG>wsiVU{L6QB|sb+5yvby(Oy0RkM=6(fb)B|JnPW0*>s!>p5qVA{esh3E~i~phk NHk(^*C^lpN<4+W;y-xrD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5de23c53c45bc23c1b4b6e438a638fb523e42f2a GIT binary patch literal 2980 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 literal 0 HcmV?d00001 diff --git a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py index cb47b09285..631e56a5c3 100644 --- a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py @@ -365,13 +365,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: From 918bac1df262d13cbb2075c67d2452aff7be2a2f Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sun, 14 May 2023 16:32:20 +0100 Subject: [PATCH 08/38] Icons and buttons --- .../ParticleEditor/CodeToolBar.py | 24 +++++++++++-- .../ParticleEditor/DesignWindow.py | 33 ++++++++++++++++-- .../ParticleEditor/UI/CodeToolBarUI.ui | 33 +++++++----------- .../ParticleEditor/UI/icons/icons.qrc | 1 + .../ParticleEditor/UI/icons/scatter-icon.png | Bin 0 -> 16843 bytes src/sas/qtgui/convertUI.py | 15 ++++++++ 6 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/scatter-icon.png diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py index 549d2f1707..2f0061ac17 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py @@ -1,9 +1,27 @@ -from PySide6 import QtWidgets +from PySide6 import QtWidgets, QtGui from sas.qtgui.Perspectives.ParticleEditor.UI.CodeToolBarUI import Ui_CodeToolBar -class RadiusSelection(QtWidgets.QWidget, 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) \ No newline at end of file + 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 index 3f712217d1..57e6f69b28 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -1,10 +1,14 @@ -from PySide6 import QtWidgets +from PySide6 import QtWidgets, QtGui from PySide6.QtCore import Qt 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.UI.DesignWindowUI import Ui_DesignWindow + +import sas.qtgui.Perspectives.ParticleEditor.UI.icons_rc class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow): def __init__(self, parent=None): super().__init__() @@ -21,11 +25,22 @@ def __init__(self, parent=None): splitter = QtWidgets.QSplitter(Qt.Vertical) - self.pythonViewer = PythonViewer() self.outputViewer = OutputViewer() + self.codeToolBar = CodeToolBar() + + - splitter.addWidget(self.pythonViewer) + 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) @@ -61,8 +76,20 @@ def __init__(self, parent=None): self.tabWidget.setStyleSheet("#tabWidget {background-color:red;}") + def onLoad(self): + pass + + def onSave(self): + pass + + def onBuild(self): + pass + def onScatter(self): + pass + def onFit(self): + pass def main(): """ Demo/testing window""" diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui index da006c2006..7872b5c6ba 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui @@ -6,8 +6,8 @@ 0 0 - 280 - 30 + 460 + 20 @@ -15,26 +15,22 @@ - 5 + 0 - 5 + 0 - 5 + 0 - 5 + 0 Load - - - :/particle_editor/upload-icon.png:/particle_editor/upload-icon.png - @@ -42,10 +38,6 @@ Save - - - :/particle_editor/download-icon.png:/particle_editor/download-icon.png - @@ -66,16 +58,17 @@ Build - - - :/particle_editor/hammer-icon.png:/particle_editor/hammer-icon.png + + + + + + Scatter - - - + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc index 3a1f710e5e..624ba0a01a 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc @@ -4,5 +4,6 @@ upload-icon.png save-icon.png hammer-icon.png + scatter-icon.png 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 0000000000000000000000000000000000000000..1034d66ae3374876014b4cc554fbadfb3f9af56f GIT binary patch literal 16843 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 literal 0 HcmV?d00001 diff --git a/src/sas/qtgui/convertUI.py b/src/sas/qtgui/convertUI.py index bc1e06f4fc..afb49fcf4a 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 """ From 1cece8bd5b20c615319ee67f7464647664149ed2 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sun, 14 May 2023 18:28:48 +0100 Subject: [PATCH 09/38] Vectorisation check --- .../ParticleEditor/DesignWindow.py | 39 +++- .../ParticleEditor/FunctionViewer.py | 1 + .../ParticleEditor/UI/DesignWindowUI.ui | 216 +++++++++++------- .../ParticleEditor/function_processor.py | 149 +++++++----- .../ParticleEditor/parallelise.py | 88 +++++++ 5 files changed, 352 insertions(+), 141 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/parallelise.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 57e6f69b28..603fd24266 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -29,6 +29,12 @@ def __init__(self, parent=None): self.outputViewer = OutputViewer() self.codeToolBar = CodeToolBar() + self.codeToolBar.saveButton.clicked.connect(self.onSave) + self.codeToolBar.loadButton.clicked.connect(self.onLoad) + self.codeToolBar.buildButton.clicked.connect(self.onBuild) + self.codeToolBar.scatterButton.clicked.connect(self.onScatter) + + self.solvent_sld = 0.0 topSection = QtWidgets.QVBoxLayout() @@ -46,13 +52,17 @@ def __init__(self, parent=None): splitter.setStretchFactor(1, 1) hbox.addWidget(splitter) + # Function viewer self.functionViewer = FunctionViewer() - hbox.addWidget(self.functionViewer) + self.functionViewer.radius_control.radiusField.valueChanged.connect(self.onRadiusChanged) + # A components + hbox.addWidget(self.functionViewer) self.definitionTab.setLayout(hbox) + # - # Second Tab + # Ensemble tab # # Populate combo boxes @@ -62,25 +72,46 @@ def __init__(self, parent=None): self.structureFactorCombo.addItem("None") # TODO: Structure Factor Options + self.solventSLDBox.valueChanged.connect(self.onSolventSLDBoxChanged) + + + # + # Calculation Tab + # + self.methodCombo.addItem("Monte Carlo") self.methodCombo.addItem("Grid") + + # Populate tables # Columns should be name, value, min, max, fit, [remove] self.parametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) + self.parametersTable.horizontalHeader().setStretchLastSection(True) + self.structureFactorParametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) + self.structureFactorParametersTable.horizontalHeader().setStretchLastSection(True) self.tabWidget.setAutoFillBackground(True) self.tabWidget.setStyleSheet("#tabWidget {background-color:red;}") + def onRadiusChanged(self): + if self.radiusFromParticleTab.isChecked(): + self.sampleRadius.setText("%.4g"%self.functionViewer.radius_control.radius()) + + def onSolventSLDBoxChanged(self): + sld = float(self.solventSLDBox.value()) + self.solvent_sld = sld + # self.functionViewer.solvent_sld = sld # TODO: Think more about where to put this variable + self.functionViewer.updateImage() def onLoad(self): - pass + print("Load clicked") def onSave(self): - pass + print("Save clicked") def onBuild(self): pass diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index b8b0609516..2730215972 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -93,6 +93,7 @@ def __init__(self, parent=None): self._graphics_viewer_offset = 5 + # # Qt Setup # diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 878e44e092..79cfe692f8 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -13,114 +13,172 @@ Form - + - 0 + 2 Definition + + + Parameters + + + + + + + + 1 + + + 6 + + + false + + + + + + + + + + + + + + - Particles + Ensemble - + 0 - - - - Qt::Horizontal - - - - 40 - 20 - - - - 10 - - - 10 - + 10 - - - - Orientational Distribution + + + + Qt::Horizontal - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 40 + 20 + - - - - + - - - - Structure Factor + + + + 10 - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 10 - + + + + Solvent SLD + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Orientational Distribution + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Structure Factor + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + + + 4 + + + -100.000000000000000 + + + 100.000000000000000 + + + 0.100000000000000 + + + + + + + 10<sup>-6</sup>Å<sup>-2</sup> + + + + + + - - + + + + Qt::Horizontal + + + + 40 + 20 + + + - - - - Function Parameters - - - Qt::AlignCenter - - - - - - - 1 - - - 6 - - - false - - - - - - - - - - @@ -153,19 +211,6 @@ - - - - Qt::Horizontal - - - - 40 - 20 - - - - @@ -428,6 +473,11 @@ + + + Fitting + + Output diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py index 196b164573..118e93eafd 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py @@ -1,5 +1,11 @@ -import inspect from typing import Callable + +from io import StringIO + +import inspect +from contextlib import redirect_stdout +import traceback + import numpy as np class FunctionDefinitionFailed(Exception): @@ -16,10 +22,18 @@ def __init__(self, *args): 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)) @@ -28,15 +42,38 @@ def spherical_converter(x,y,z): parameter_converters = [cartesian_converter, spherical_converter] +def default_callback(string: str): + """ Just for default""" + print(string) + + + # # Main processor # -def process_text(input_text: str): - new_locals = {} +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 = {"np": np, "solvent_sld": solvent_sld} new_globals = {} - exec(input_text, new_globals, new_locals) # TODO: provide access to solvent SLD somehow + stdout_output = StringIO() + with redirect_stdout(stdout_output): + try: + exec(input_text, new_globals, new_locals) # TODO: provide access to solvent SLD somehow + except Exception: + error_callback(traceback.format_exc()) + + text_callback(stdout_output.getvalue()) + # print(ev) # print(new_globals) # print(new_locals) @@ -83,60 +120,64 @@ def process_text(input_text: str): return sld_function, converter, remaining_parameter_names -test_text_valid_sld_xyz = """ - -print("test print string") - -def sld(x,y,z,p1,p2,p3): - print(x,y,z) +def main(): -""" - -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): + test_text_valid_sld_xyz = """ + + print("test print string") + + def sld(x,y,z,p1,p2,p3): print(x,y,z) + + """ -sld = SLD() - -""" - -test_bad_class = """ - -class SLD: - def __call__(self,x,y,q): + test_text_valid_sld_radial = """ + def sld(r,theta,phi,p1,p2,p3): print(x,y,z) + + """ -sld = SLD() - -""" + test_text_invalid_start_sld = """ + def sld(theta,phi,p1,p2,p3): + print(x,y,z) + + """ -x = process_text(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) - + 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/parallelise.py b/src/sas/qtgui/Perspectives/ParticleEditor/parallelise.py new file mode 100644 index 0000000000..b60d3d4612 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/parallelise.py @@ -0,0 +1,88 @@ +import traceback +from typing import Callable +import numpy as np + +test_n = 7 + +def vectorise_sld(fun: Callable, + error_callback: Callable[[str], None], + warning_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): + error_callback("SLD function does not return array for array inputs") + return None + + elif output.shape != (test_n,): + error_callback("SLD function returns wrong shape array") + 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(traceback.format_exc()) + + else: + error_callback(traceback.format_exc()) + + except Exception: + error_callback(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 From 4632447d47b0c3084c7339cb80c444bd3ba04f45 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sun, 14 May 2023 20:20:32 +0100 Subject: [PATCH 10/38] First bit of pipeline working --- .../ParticleEditor/DesignWindow.py | 45 ++++++++++++++++++- .../ParticleEditor/FunctionViewer.py | 9 ++-- .../ParticleEditor/OutputViewer.py | 20 +++++++-- .../ParticleEditor/UI/DesignWindowUI.ui | 2 +- .../ParticleEditor/function_processor.py | 14 +++--- .../{parallelise.py => vectorise.py} | 10 ++--- 6 files changed, 77 insertions(+), 23 deletions(-) rename src/sas/qtgui/Perspectives/ParticleEditor/{parallelise.py => vectorise.py} (86%) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 603fd24266..0d7d707ce7 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -1,3 +1,5 @@ +from typing import Optional, Callable + from PySide6 import QtWidgets, QtGui from PySide6.QtCore import Qt @@ -8,6 +10,9 @@ from sas.qtgui.Perspectives.ParticleEditor.UI.DesignWindowUI import Ui_DesignWindow +from sas.qtgui.Perspectives.ParticleEditor.function_processor import process_code, FunctionDefinitionFailed +from sas.qtgui.Perspectives.ParticleEditor.vectorise import vectorise_sld + import sas.qtgui.Perspectives.ParticleEditor.UI.icons_rc class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow): def __init__(self, parent=None): @@ -17,6 +22,12 @@ def __init__(self, parent=None): self.setWindowTitle("Placeholder title") self.parent = parent + # Variables + + self.currentFunction: Optional[Callable] = None + self.currentCoordinateMapping: Optional[Callable] = None + + # # First Tab # @@ -114,7 +125,30 @@ def onSave(self): print("Save clicked") def onBuild(self): - pass + # Get the text from the window + code = self.pythonViewer.toPlainText() + + self.outputViewer.reset() + + try: + 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 + + self.functionViewer.setFunction(function, xyz_converter) + + self.currentFunction = function + self.currentCoordinateMapping = xyz_converter + + self.codeText("Success!") + + except FunctionDefinitionFailed as e: + self.codeError(e.args[0]) def onScatter(self): pass @@ -122,6 +156,15 @@ def onScatter(self): def onFit(self): pass + def codeError(self, text): + self.outputViewer.addError(text) + + def codeText(self, text): + self.outputViewer.addText(text) + + def codeWarning(self, text): + self.outputViewer.addWarning(text) + def main(): """ Demo/testing window""" diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index 2730215972..a22a224cd3 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -232,15 +232,13 @@ def onRadiusChanged(self): self.radius = self.radius_control.radius() self.updateImage() - def setFunction(self, fun): + def setFunction(self, fun, coordinate_mapping): self.function = fun + self.coordinate_mapping = coordinate_mapping self.updateImage() - def setCoordinateMapping(self, fun): - self.coordinate_mapping = fun - def onDisplayTypeSelected(self): if self.sld_magnetism_option.magnetismOption.isChecked(): print("Magnetic view selected") @@ -404,8 +402,7 @@ def pseudo_orbital(r, theta, phi): app = QtWidgets.QApplication([]) viewer = FunctionViewer() - viewer.setCoordinateMapping(spherical_converter) - viewer.setFunction(pseudo_orbital) + viewer.setFunction(pseudo_orbital, spherical_converter) viewer.show() app.exec_() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py index 5d722ae7cc..7560e62d80 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py @@ -5,7 +5,7 @@ from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter from sas.system.version import __version__ as version -initial_text = f"

Particle Designer Log - SasView {version}

" +initial_text = f"

Particle Editor Log - SasView {version}

" class OutputViewer(QtWidgets.QTextEdit): """ Python text editor window""" @@ -30,11 +30,25 @@ def keyPressEvent(self, e): 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): - pass + self.appendAndMove(f'{self._htmlise(text)}') def addText(self, text): - pass + self.appendAndMove(f'{self._htmlise(text)}') + + def addWarning(self, text): + self.appendAndMove(f'{self._htmlise(text)}') diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 79cfe692f8..c5211e139d 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -20,7 +20,7 @@ - 2 + 0 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py index 118e93eafd..1c4afbfe80 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py @@ -62,18 +62,22 @@ def process_code(input_text: str, """ - new_locals = {"np": np, "solvent_sld": solvent_sld} - new_globals = {} + new_locals = {} + new_globals = {"np": np, "solvent_sld": solvent_sld} 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: - error_callback(traceback.format_exc()) - text_callback(stdout_output.getvalue()) + text_callback(stdout_output.getvalue()) + error_callback(traceback.format_exc()) + return None, None, None, None # print(ev) # print(new_globals) # print(new_locals) @@ -118,7 +122,7 @@ def process_code(input_text: str, remaining_parameter_names = [x[0] for x in params[3:]] - return sld_function, converter, remaining_parameter_names + return sld_function, converter, remaining_parameter_names, params[3:] def main(): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/parallelise.py b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py similarity index 86% rename from src/sas/qtgui/Perspectives/ParticleEditor/parallelise.py rename to src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py index b60d3d4612..1d8e76576c 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/parallelise.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py @@ -5,7 +5,6 @@ test_n = 7 def vectorise_sld(fun: Callable, - error_callback: Callable[[str], None], warning_callback: Callable[[str], None], *args, **kwargs): """ Check whether an SLD function can handle numpy arrays properly, @@ -19,13 +18,10 @@ def vectorise_sld(fun: Callable, try: output = fun(input_values, input_values, input_values, *args, **kwargs) - if not isinstance(output, np.ndarray): - error_callback("SLD function does not return array for array inputs") return None elif output.shape != (test_n,): - error_callback("SLD function returns wrong shape array") return None else: @@ -52,13 +48,13 @@ def vectorised(x,y,z,*args,**kwargs): return vectorised except: - error_callback(traceback.format_exc()) + pass else: - error_callback(traceback.format_exc()) + pass except Exception: - error_callback(traceback.format_exc()) + pass def vectorise_magnetism(fun: Callable, warning_callback: Callable[[str], None], *args, **kwargs): """ Check whether a magnetism function can handle numpy arrays properly, From 61b85a1826f59acd68a43c05ccf0aa1603781423 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sun, 14 May 2023 22:02:35 +0100 Subject: [PATCH 11/38] Improvements to output and vectorisation --- .../ParticleEditor/DesignWindow.py | 15 +++++-- .../ParticleEditor/FunctionViewer.py | 40 ++++++++++--------- .../ParticleEditor/PythonViewer.py | 4 +- .../ParticleEditor/UI/RadiusSelectionUI.ui | 2 +- .../Perspectives/ParticleEditor/vectorise.py | 5 ++- 5 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 0d7d707ce7..b36c59b9a9 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -1,5 +1,7 @@ from typing import Optional, Callable +from datetime import datetime + from PySide6 import QtWidgets, QtGui from PySide6.QtCore import Qt @@ -137,15 +139,20 @@ def onBuild(self): warning_callback=self.codeError, error_callback=self.codeError) - if function is None: + maybe_vectorised = vectorise_sld(function, warning_callback=self.codeWarning) # TODO: Deal with args + + if maybe_vectorised is None: return - self.functionViewer.setFunction(function, xyz_converter) + self.functionViewer.setSLDFunction(maybe_vectorised, xyz_converter) + - self.currentFunction = function + self.currentFunction = maybe_vectorised self.currentCoordinateMapping = xyz_converter - self.codeText("Success!") + now = datetime.now() + current_time = now.strftime("%Y-%m-%d %H:%M:%S") + self.codeText(f"Built Successfully at {current_time}") except FunctionDefinitionFailed as e: self.codeError(e.args[0]) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index a22a224cd3..d09f33c280 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -128,7 +128,7 @@ def __init__(self, parent=None): # General control - self.radius_control = RadiusSelection("View Size") + self.radius_control = RadiusSelection("View Radius") self.radius_control.radiusField.valueChanged.connect(self.onRadiusChanged) self.plane_buttons = PlaneButtons(self.setAngles) @@ -191,7 +191,7 @@ def __init__(self, parent=None): # Show images self.updateImage() def eventFilter(self, source, event): - + """ Event filter intercept, grabs mouse drags on the images""" if event.type() == QtCore.QEvent.MouseButtonPress: @@ -229,17 +229,20 @@ def eventFilter(self, source, event): def onRadiusChanged(self): + """ Draw radius changed """ self.radius = self.radius_control.radius() self.updateImage() - def setFunction(self, fun, coordinate_mapping): - + 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(): @@ -253,29 +256,32 @@ def onDisplayTypeSelected(self): # 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 onPsiChanged(self): - self.psi = np.pi * float(self.psi_slider.value()) / 180 - self.updateImage() 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, theta_deg, phi_deg): - - self.alpha = np.pi * theta_deg / 180 - self.beta = np.pi * (phi_deg + 180) / 180 + 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): - # Draw image + """ 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]: @@ -309,8 +315,6 @@ def updateImage(self, mag_only=True): self.drawScale(image) self.drawAxes(image) - # image = np.ascontiguousarray(np.flip(image, 0)) # Y is upside down - height, width, channels = image.shape bytes_per_line = channels * width qimage = QtGui.QImage(image.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888) @@ -318,7 +322,7 @@ def updateImage(self, mag_only=True): self.densityPixmapItem.setPixmap(pixmap) - # Cross section + # 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]) @@ -344,8 +348,6 @@ def updateImage(self, mag_only=True): self.drawScale(image) self.drawAxes(image) - # image = np.ascontiguousarray(np.flip(image, 0)) # Y is upside down - height, width, channels = image.shape bytes_per_line = channels * width qimage = QtGui.QImage(image.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888) @@ -355,9 +357,11 @@ def updateImage(self, mag_only=True): 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 @@ -402,7 +406,7 @@ def pseudo_orbital(r, theta, phi): app = QtWidgets.QApplication([]) viewer = FunctionViewer() - viewer.setFunction(pseudo_orbital, spherical_converter) + viewer.setSLDFunction(pseudo_orbital, spherical_converter) viewer.show() app.exec_() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py index 06dbc3c709..5d9b20398f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py @@ -13,9 +13,9 @@ """ def sld(x,y,z): - """ A cube with side length 1 """ + """ A cube with 100Ang side length""" - inside = (np.abs(x) < 0.5) & (np.abs(y) < 0.5) & (np.abs(z) < 0.5) + inside = (np.abs(x) < 50) & (np.abs(y) < 50) & (np.abs(z) < 50) out = np.zeros_like(x) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui index 997c122f02..dddf1e7151 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui @@ -55,7 +55,7 @@ 100000.000000000000000 - 10.000000000000000 + 100.000000000000000 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py index 1d8e76576c..3e14e19130 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py @@ -1,5 +1,5 @@ import traceback -from typing import Callable +from typing import List, Union, Callable import numpy as np test_n = 7 @@ -34,6 +34,7 @@ def vectorise_sld(fun: Callable, 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)): @@ -41,7 +42,7 @@ def vectorised(x,y,z,*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 " + "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.") From fc7970ee6f4bc8dd6ec2286fa158853e54c7a5d2 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 15 May 2023 15:55:53 +0100 Subject: [PATCH 12/38] Sampling and support functions --- .../ParticleEditor/DesignWindow.py | 7 +- .../ParticleEditor/FunctionViewer.py | 21 +--- .../ParticleEditor/PythonViewer.py | 42 +++---- .../ParticleEditor/UI/CodeToolBarUI.ui | 3 + .../Perspectives/ParticleEditor/defaults.py | 27 +++++ .../ParticleEditor/helper_functions.py | 21 ++++ .../Perspectives/ParticleEditor/sampling.py | 112 ++++++++++++++++++ .../Perspectives/ParticleEditor/scattering.py | 32 +++++ 8 files changed, 227 insertions(+), 38 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/defaults.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/scattering.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index b36c59b9a9..436a457bee 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -42,6 +42,8 @@ def __init__(self, parent=None): self.outputViewer = OutputViewer() self.codeToolBar = CodeToolBar() + self.pythonViewer.build_trigger.connect(self.onBuild) + self.codeToolBar.saveButton.clicked.connect(self.onSave) self.codeToolBar.loadButton.clicked.connect(self.onLoad) self.codeToolBar.buildButton.clicked.connect(self.onBuild) @@ -92,7 +94,8 @@ def __init__(self, parent=None): # Calculation Tab # - self.methodCombo.addItem("Monte Carlo") + self.methodCombo.addItem("Sphere Monte Carlo") + self.methodCombo.addItem("Cube Monte Carlo") self.methodCombo.addItem("Grid") @@ -172,6 +175,8 @@ def codeText(self, text): def codeWarning(self, text): self.outputViewer.addWarning(text) + + def main(): """ Demo/testing window""" diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py index d09f33c280..d6d4d9b038 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -9,6 +9,9 @@ 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) @@ -56,20 +59,6 @@ def draw_line_in_place(im, x0, y0, dx, dy, channel): x = int(x0 + i * dx / length) y = int(y0 + i * dy / length) im[y, x, channel] = 255 -def cube_function(x, y, z): - - inside = np.logical_and(np.abs(x) <= 5, - np.logical_and( - np.abs(y) <= 5, - np.abs(z) <= 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 class FunctionViewer(QtWidgets.QWidget): @@ -81,9 +70,9 @@ def __init__(self, parent=None): self.radius = 1 self.upscale = 2 self._size_px = self.layer_size*self.upscale - self.function = cube_function + self.function = default_sld # self.function = lambda x,y,z: x - self.coordinate_mapping = lambda x,y,z: (x,y,z) + self.coordinate_mapping = spherical_converter self.alpha = 0.0 self.beta = np.pi diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py index 5d9b20398f..a4562c5ef4 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py @@ -1,33 +1,19 @@ -from PySide6 import QtWidgets -from PySide6.QtCore import Qt +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 -default_text = '''""" Default text goes here... - -should probably define a simple function -""" - -def sld(x,y,z): - """ A cube with 100Ang side length""" - - inside = (np.abs(x) < 50) & (np.abs(y) < 50) & (np.abs(z) < 50) - - out = np.zeros_like(x) - - out[inside] = 1 - - return out - -''' +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__(parent) + super().__init__() # System independent monospace font f = QFont("unexistent") @@ -40,15 +26,29 @@ def __init__(self, parent=None): 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([]) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui index 7872b5c6ba..1a137e0686 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui @@ -55,6 +55,9 @@ + + <html><head/><body><p>Build the current code</p><p>Shortcut: shift-Enter</p></body></html> + Build diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py new file mode 100644 index 0000000000..c8668ef411 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py @@ -0,0 +1,27 @@ +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 = '''""" Default text goes here... + +should probably define a simple function +""" + +def sld(x,y,z): + """ A cube with 100Ang side length""" + + inside = (np.abs(x) < 50) & (np.abs(y) < 50) & (np.abs(z) < 50) + + out = np.zeros_like(x) + + out[inside] = 1 + + return out + +''' \ 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..992380dc82 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py @@ -0,0 +1,21 @@ +""" + +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 \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py new file mode 100644 index 0000000000..50d41165b6 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py @@ -0,0 +1,112 @@ +from typing import Optional +from abc import ABC, abstractmethod +import numpy as np + +class Sample(ABC): + def __init__(self, n_points_desired, radius): + self._n_points_desired = n_points_desired + self.radius = radius + + @abstractmethod + def _calculate_n_actual(self) -> int: + """ Calculate the actual number of sample points, based on the desired number of points""" + + @abstractmethod + def sampling_details(self) -> str: + """ A string describing the details of the sample points """ + + + @abstractmethod + def get_sample(self) -> (np.ndarray, np.ndarray, np.ndarray): + """ Get the sample points """ + + +class RandomSampleSphere(Sample): + """ Rejection Random Sampler for a sphere with a given radius """ + def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): + super().__init__(n_points_desired, radius) + self.seed = seed + + def _calculate_n_actual(self) -> int: + return self._n_points_desired + + def sampling_details(self) -> str: + return "" + + def get_sample(self): + # Sample within a sphere + + # A sphere will occupy pi/6 of a cube, which is 0.5236 ish + # With rejection sampling we need to oversample by about a factor of 2 + + + target_n = self._n_points_desired + + output_data = [] + while target_n > 0: + xyz = np.random.random((int(1.91 * target_n), 3)) - 0.5 + + indices = np.sum(xyz**2, axis=1) <= 0.25 + + print(indices.shape) + + xyz = xyz[indices, :] + + if xyz.shape[0] > target_n: + target_n = 0 + output_data.append(xyz[:target_n, :]) + else: + target_n -= xyz.shape[0] + output_data.append(xyz) + + xyz = np.concatenate(output_data, axis=0) * (2*self.radius) + + return xyz[:,0], xyz[:,1], xyz[:,2] + + +class RandomSampleCube(Sample): + """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" + def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): + super().__init__(n_points_desired, radius) + self.seed = seed + + def _calculate_n_actual(self) -> int: + return self._n_points_desired + + def sampling_details(self) -> str: + return "" + + def get_sample(self): + # Sample within a cube + + xyz = np.random.random((self._n_points_desired, 3))*2 - 1.0 + + return xyz[:,0], xyz[:,1], xyz[:,2] + + +class GridSample(Sample): + def _calculate_n_actual(self) -> int: + side_length = int(np.ceil(np.cbrt(self._n_points_desired))) + return side_length**3 + + def sampling_details(self) -> str: + side_length = int(np.ceil(np.cbrt(self._n_points_desired))) + return "%ix%ix%i = %i"%(side_length, side_length, side_length, side_length**3) + + def get_sample(self): + side_length = int(np.ceil(np.cbrt(self._n_points_desired))) + n = side_length**3 + + # We want the sampling to happen in the centre of each voxel + # get points at edges and centres, then skip ever other one not the edge + sample_values = np.linspace(-self.radius, self.radius, 2*side_length+1)[1::2] + + x, y, z = np.meshgrid(sample_values, sample_values, sample_values) + + return x.reshape((n, )), y.reshape((n, )), z.reshape((n, )) + + +if __name__ == "__main__": + sampler = RandomSampleSphere(n_points_desired=100, radius=1) + x,y,z = sampler.get_sample() + print(x**2 + y**2 + z**2) \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py new file mode 100644 index 0000000000..e5efa463df --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -0,0 +1,32 @@ +from typing import Dict, Callable +from enum import Enum + +import numpy as np + +CoordinateTransform = Callable[[np.ndarray, np.ndarray, np.ndarray], (np.ndarray, np.ndarray, np.ndarray)] + +class OutputType(Enum): + SLD_1D = "1D" + SLD_2D = "2D" + MAGNETIC_1D = "Magnetic 1D" + MAGNETIC_2D = "Magnetic 2D" + +class OrientationalDistribution(Enum): + FIXED = "Fixed" + UNORIENTED = "Unoriented" + + +class ScatteringCalculation: + def __init__(self, + radius: float, + solvent_sld: float, + output_type: OutputType, + sld_function: Callable, + sld_function_from_cartesian: CoordinateTransform, + sld_function_parameters: Dict[str, float], + magnetism_function: Callable, + magnetism_function_from_cartesian: CoordinateTransform, + magnetism_function_parameters: Dict[str, float], + + ): + pass \ No newline at end of file From 257d8ad41500851886b0cf973e6897d8bf57815c Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 15 May 2023 22:20:21 +0100 Subject: [PATCH 13/38] Basic functionality now implemented --- .../ParticleEditor/DesignWindow.py | 215 ++++++++++++-- .../ParticleEditor/OutputCanvas.py | 41 +++ .../ParticleEditor/UI/DesignWindowUI.ui | 268 ++++++++++++------ .../Perspectives/ParticleEditor/sampling.py | 65 +++-- .../Perspectives/ParticleEditor/scattering.py | 94 +++++- .../qtgui/Perspectives/ParticleEditor/util.py | 90 ++++++ .../Perspectives/ParticleEditor/vectorise.py | 17 +- 7 files changed, 641 insertions(+), 149 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/util.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 436a457bee..72d7fc3501 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -1,7 +1,9 @@ +import traceback from typing import Optional, Callable from datetime import datetime +import numpy as np from PySide6 import QtWidgets, QtGui from PySide6.QtCore import Qt @@ -9,12 +11,20 @@ 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.OutputCanvas import OutputCanvas from sas.qtgui.Perspectives.ParticleEditor.UI.DesignWindowUI import Ui_DesignWindow 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.sampling import ( + SpatialSample, QSample, RandomSampleSphere, RandomSampleCube, GridSample) + +from sas.qtgui.Perspectives.ParticleEditor.scattering import ( + OutputType, OrientationalDistribution, ScatteringCalculation, calculate_scattering) +from sas.qtgui.Perspectives.ParticleEditor.util import format_time_estimate + import sas.qtgui.Perspectives.ParticleEditor.UI.icons_rc class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow): def __init__(self, parent=None): @@ -24,11 +34,6 @@ def __init__(self, parent=None): self.setWindowTitle("Placeholder title") self.parent = parent - # Variables - - self.currentFunction: Optional[Callable] = None - self.currentCoordinateMapping: Optional[Callable] = None - # # First Tab @@ -42,12 +47,12 @@ def __init__(self, parent=None): self.outputViewer = OutputViewer() self.codeToolBar = CodeToolBar() - self.pythonViewer.build_trigger.connect(self.onBuild) + 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.onBuild) - self.codeToolBar.scatterButton.clicked.connect(self.onScatter) + self.codeToolBar.buildButton.clicked.connect(self.doBuild) + self.codeToolBar.scatterButton.clicked.connect(self.doScatter) self.solvent_sld = 0.0 @@ -93,12 +98,37 @@ def __init__(self, parent=None): # # Calculation Tab # + self.methodComboOptions = ["Sphere Monte Carlo", "Cube Monte Carlo", "Grid"] + for option in self.methodComboOptions: + self.methodCombo.addItem(option) + + # Spatial sampling changed + self.methodCombo.currentIndexChanged.connect(self.updateSpatialSampling) + self.sampleRadius.valueChanged.connect(self.updateSpatialSampling) + self.nSamplePoints.valueChanged.connect(self.updateSpatialSampling) + self.randomSeed.textChanged.connect(self.updateSpatialSampling) + self.fixRandomSeed.clicked.connect(self.updateSpatialSampling) + + # 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.methodCombo.addItem("Sphere Monte Carlo") - self.methodCombo.addItem("Cube Monte Carlo") - self.methodCombo.addItem("Grid") + # + # Output Tab + # + + self.outputCanvas = OutputCanvas() + outputLayout = QtWidgets.QVBoxLayout() + outputLayout.addWidget(self.outputCanvas) + self.outputTab.setLayout(outputLayout) + + # + # Misc + # # Populate tables @@ -109,13 +139,22 @@ def __init__(self, parent=None): self.structureFactorParametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) self.structureFactorParametersTable.horizontalHeader().setStretchLastSection(True) - self.tabWidget.setAutoFillBackground(True) + # Set up variables + + self.spatialSampling: SpatialSample = self._spatialSampling() + self.qSampling: QSample = self._qSampling() - self.tabWidget.setStyleSheet("#tabWidget {background-color:red;}") + self.last_calculation_time: Optional[float] = None + self.last_calculation_n: int = 0 + + self.sld_function: Optional[np.ndarray] = None + self.sld_coordinate_mapping: Optional[np.ndarray] = 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.setText("%.4g"%self.functionViewer.radius_control.radius()) + self.sampleRadius.setValue(self.functionViewer.radius_control.radius()) def onSolventSLDBoxChanged(self): sld = float(self.solventSLDBox.value()) @@ -123,59 +162,187 @@ def onSolventSLDBoxChanged(self): # self.functionViewer.solvent_sld = sld # TODO: Think more about where to put this variable self.functionViewer.updateImage() + def onSampleCountChanged(self): + """ Called when the number of samples changes """ + + + # 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 + + est_time = time_per_sample * int(self.nSamplePoints.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 onBuild(self): + def doBuild(self): + """ Build functionality requested""" + # Get the text from the window code = self.pythonViewer.toPlainText() self.outputViewer.reset() try: + # 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) - maybe_vectorised = vectorise_sld(function, warning_callback=self.codeWarning) # TODO: Deal with args + if function is None: + return False - if maybe_vectorised is None: - return - self.functionViewer.setSLDFunction(maybe_vectorised, xyz_converter) + # 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.currentFunction = maybe_vectorised - self.currentCoordinateMapping = xyz_converter + 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 onScatter(self): - pass + 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) + self.last_calculation_time = scattering_result.calculation_time + self.last_calculation_n = scattering_result.spatial_sampling_method._calculate_n_actual() + + self.codeText("Scattering calculation complete after %g seconds."%scattering_result.calculation_time) + + self.outputCanvas.data = scattering_result + self.tabWidget.setCurrentIndex(5) # Move to output tab if complete + + except Exception: + self.codeError(traceback.format_exc()) + + + else: + self.codeError("Build failed, scattering cancelled") 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 updateQSampling(self): + """ Update the spatial sampling object """ + self.qSampling = self._qSampling() + print(self.qSampling) # TODO: Remove + + 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 updateSpatialSampling(self): + """ Update the spatial sampling object """ + self.spatialSampling = self._spatialSampling() + self.sampleDetails.setText(self.spatialSampling.sampling_details()) + # print(self.spatialSampling) + + def _spatialSampling(self) -> SpatialSample: + """ 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_desired = int(self.nSamplePoints.value()) + seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None + + if sample_type == 0: + return RandomSampleSphere(radius=radius, n_points_desired=n_desired, seed=seed) + + elif sample_type == 1: + return RandomSampleCube(radius=radius, n_points_desired=n_desired, seed=seed) + + elif sample_type == 2: + return GridSample(radius=radius, n_points_desired=n_desired) + + else: + raise ValueError("Unknown index for spatial sampling method combo") + + def _scatteringCalculation(self): + orientation_index = self.orientationCombo.currentIndex() + + if orientation_index == 0: + orientation = OrientationalDistribution.UNORIENTED + elif orientation_index == 1: + orientation = OrientationalDistribution.FIXED + else: + raise ValueError("Unknown index for orientation combo") + + output_type = None + if self.output1D.isChecked(): + output_type = OutputType.SLD_1D + elif self.output2D.isChecked(): + output_type = OutputType.SLD_2D + + if output_type is None: + raise ValueError("Uknown index for output type combo") + + return ScatteringCalculation( + solvent_sld=self.solvent_sld, + orientation=orientation, + output_type=output_type, + spatial_sampling_method=self.spatialSampling, + q_sampling_method=self.qSampling, + sld_function=self.sld_function, + sld_function_from_cartesian=self.sld_coordinate_mapping, + sld_function_parameters={}, + magnetism_function=None, + magnetism_function_from_cartesian=None, + magnetism_function_parameters=None, + magnetism_vector=None) def main(): """ Demo/testing window""" diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py new file mode 100644 index 0000000000..e0a852bc5a --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Optional + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from scattering import ScatteringOutput + +class OutputCanvas(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 + + q_values = scattering_output.q_sampling_method() + i_values = scattering_output.intensity_data + + print(len(q_values)) + + self.axes.cla() + + if scattering_output.q_sampling_method.is_log: + self.axes.loglog(q_values, i_values) + else: + self.axes.semilogy(q_values, i_values) + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index c5211e139d..050a13ec52 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -6,7 +6,7 @@ 0 0 - 1009 + 994 370 @@ -257,91 +257,97 @@ - - - - 0 + + + + 10 - - - - 1D - - - true - - - - - - - 2D - - - - - - - - - Estimated Time: 7 Units + + 10000 - - Qt::AlignCenter + + 10 + + + 100 - - + + - Logaritmic - - - true + Ang - - + + - Output Type - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Ang - - + + - Sample Points + Q Samples Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + - 10000 + Sample Radius - - + + + + + + false + + + 0.100000000000000 + + + 100000.000000000000000 + + + 100.000000000000000 + + + + + + + Get From 'Definition' Tab + + + true + + + + + + + - Q Min + Q Max Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + - 0.001 + 1.0 @@ -355,83 +361,169 @@ + + + + 0.001 + + + - + - Q Max + Q Min Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + + + + - 1.0 + + + + Qt::AlignCenter - - - - - + + - Ang + Logaritmic + + + true - - + + + + 0 + + + + + 1D + + + true + + + + + + + 2D + + + + + + + - Q Samples + Output Type Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + - Ang + Sample Points + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + + + + + 100000000 + + + 1000 + + + 100000 + + + + + + + + + + + + + + + + + + 0 + + + + + + + Fix Seed + + + + + + + - 100 + Random Seed + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + - Sample Radius + Neutron Polarisation - - + + - - - false - + - 10 + 1 - + - Get From 'Definition' Tab + 0 - - true + + + + + + 0 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py index 50d41165b6..965dfbd1e1 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py @@ -2,7 +2,9 @@ from abc import ABC, abstractmethod import numpy as np -class Sample(ABC): + +class SpatialSample(ABC): + """ Base class for spatial sampling methods""" def __init__(self, n_points_desired, radius): self._n_points_desired = n_points_desired self.radius = radius @@ -15,25 +17,33 @@ def _calculate_n_actual(self) -> int: def sampling_details(self) -> str: """ A string describing the details of the sample points """ + def __repr__(self): + return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) @abstractmethod - def get_sample(self) -> (np.ndarray, np.ndarray, np.ndarray): + def __call__(self) -> (np.ndarray, np.ndarray, np.ndarray): """ Get the sample points """ -class RandomSampleSphere(Sample): - """ Rejection Random Sampler for a sphere with a given radius """ +class RandomSample(SpatialSample): def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): super().__init__(n_points_desired, radius) self.seed = seed + def __repr__(self): + return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self._n_points_desired, self.radius, str(self.seed)) + + +class RandomSampleSphere(RandomSample): + """ Rejection Random Sampler for a sphere with a given radius """ + def _calculate_n_actual(self) -> int: return self._n_points_desired def sampling_details(self) -> str: return "" - def get_sample(self): + def __call__(self): # Sample within a sphere # A sphere will occupy pi/6 of a cube, which is 0.5236 ish @@ -44,31 +54,26 @@ def get_sample(self): output_data = [] while target_n > 0: - xyz = np.random.random((int(1.91 * target_n), 3)) - 0.5 + xyz = np.random.random((int(1.91 * target_n)+1, 3)) - 0.5 indices = np.sum(xyz**2, axis=1) <= 0.25 - print(indices.shape) - xyz = xyz[indices, :] if xyz.shape[0] > target_n: - target_n = 0 output_data.append(xyz[:target_n, :]) + target_n = 0 else: - target_n -= xyz.shape[0] output_data.append(xyz) + target_n -= xyz.shape[0] xyz = np.concatenate(output_data, axis=0) * (2*self.radius) return xyz[:,0], xyz[:,1], xyz[:,2] -class RandomSampleCube(Sample): +class RandomSampleCube(RandomSample): """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" - def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): - super().__init__(n_points_desired, radius) - self.seed = seed def _calculate_n_actual(self) -> int: return self._n_points_desired @@ -76,7 +81,7 @@ def _calculate_n_actual(self) -> int: def sampling_details(self) -> str: return "" - def get_sample(self): + def __call__(self): # Sample within a cube xyz = np.random.random((self._n_points_desired, 3))*2 - 1.0 @@ -84,7 +89,7 @@ def get_sample(self): return xyz[:,0], xyz[:,1], xyz[:,2] -class GridSample(Sample): +class GridSample(SpatialSample): def _calculate_n_actual(self) -> int: side_length = int(np.ceil(np.cbrt(self._n_points_desired))) return side_length**3 @@ -93,7 +98,7 @@ def sampling_details(self) -> str: side_length = int(np.ceil(np.cbrt(self._n_points_desired))) return "%ix%ix%i = %i"%(side_length, side_length, side_length, side_length**3) - def get_sample(self): + def __call__(self): side_length = int(np.ceil(np.cbrt(self._n_points_desired))) n = side_length**3 @@ -106,7 +111,29 @@ def get_sample(self): return x.reshape((n, )), y.reshape((n, )), z.reshape((n, )) +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) + + if __name__ == "__main__": sampler = RandomSampleSphere(n_points_desired=100, radius=1) - x,y,z = sampler.get_sample() - print(x**2 + y**2 + z**2) \ No newline at end of file + for i in range(5): + x,y,z = sampler() + # print(x**2 + y**2 + z**2) + print(len(x)) \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py index e5efa463df..c7dab689ba 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -1,9 +1,14 @@ -from typing import Dict, Callable +from typing import Dict, DefaultDict, Callable, Optional, Any, Tuple, Union from enum import Enum +from dataclasses import dataclass + +import time import numpy as np -CoordinateTransform = Callable[[np.ndarray, np.ndarray, np.ndarray], (np.ndarray, np.ndarray, np.ndarray)] +from sampling import SpatialSample, QSample + +CoordinateTransform = Callable[[np.ndarray, np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray, np.ndarray]] class OutputType(Enum): SLD_1D = "1D" @@ -16,17 +21,76 @@ class OrientationalDistribution(Enum): UNORIENTED = "Unoriented" +@dataclass class ScatteringCalculation: - def __init__(self, - radius: float, - solvent_sld: float, - output_type: OutputType, - sld_function: Callable, - sld_function_from_cartesian: CoordinateTransform, - sld_function_parameters: Dict[str, float], - magnetism_function: Callable, - magnetism_function_from_cartesian: CoordinateTransform, - magnetism_function_parameters: Dict[str, float], - - ): - pass \ No newline at end of file + """ Specification for a scattering calculation """ + solvent_sld: float + output_type: OutputType + orientation: OrientationalDistribution + spatial_sampling_method: SpatialSample + q_sampling_method: QSample + sld_function: Any + sld_function_from_cartesian: CoordinateTransform + sld_function_parameters: Dict[str, float] + magnetism_function: Any + magnetism_function_from_cartesian: Optional[CoordinateTransform] + magnetism_function_parameters: Optional[Dict[str, float]] + magnetism_vector: Optional[np.ndarray] + + +@dataclass +class ScatteringOutput: + output_type: OutputType + q_sampling_method: QSample + spatial_sampling_method: SpatialSample + intensity_data: np.ndarray + calculation_time: float + +def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput: + """ Main function for calculating scattering""" + + start_time = time.time() # Track how long it takes + + # Calculate contribution of SLD + if calculation.orientation == OrientationalDistribution.UNORIENTED: + + if calculation.output_type == OutputType.SLD_2D: + raise NotImplementedError("2D scattering not implemented yet") + + # input samples + q = calculation.q_sampling_method() + x, y, z = calculation.spatial_sampling_method() + + # evaluate sld + input_coordinates = calculation.sld_function_from_cartesian(x,y,z) + sld = calculation.sld_function(*input_coordinates, **calculation.sld_function_parameters) + sld -= calculation.solvent_sld + + # Do the integration + r = np.sqrt(x**2 + y**2 + z**2) + qr = np.outer(q, r) + + intensity = np.sum(np.sin(qr) / qr, axis=1)**2 + + + + elif calculation.orientation == OrientationalDistribution.FIXED: + + raise NotImplementedError("Oriented particle not implemented yet") + + + + # Calculate magnet contribution + # TODO: implement magnetic scattering + + + # Wrap up + + calculation_time = time.time() - start_time + + return ScatteringOutput( + output_type=calculation.output_type, + q_sampling_method=calculation.q_sampling_method, + spatial_sampling_method=calculation.spatial_sampling_method, + intensity_data=intensity, + calculation_time=calculation_time) \ No newline at end of file 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 index 3e14e19130..b24c56c3aa 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py @@ -4,8 +4,19 @@ 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""" @@ -49,13 +60,13 @@ def vectorised(x,y,z,*args,**kwargs): return vectorised except: - pass + error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc())) else: - pass + error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc())) except Exception: - pass + 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, From 1835e83b3acd41e0cbc47c7b932c943d5fccf21b Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 15 May 2023 23:56:29 +0100 Subject: [PATCH 14/38] Sample points in chunks for memory --- .../ParticleEditor/DesignWindow.py | 23 ++++++-- .../ParticleEditor/OutputCanvas.py | 2 + .../ParticleEditor/UI/DesignWindowUI.ui | 6 +- .../Perspectives/ParticleEditor/defaults.py | 28 ++++++---- .../ParticleEditor/function_processor.py | 4 +- .../Perspectives/ParticleEditor/sampling.py | 55 ++++++++++++++----- .../Perspectives/ParticleEditor/scattering.py | 28 ++++++---- 7 files changed, 103 insertions(+), 43 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 72d7fc3501..44b6b5a36e 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -109,12 +109,16 @@ def __init__(self, parent=None): self.randomSeed.textChanged.connect(self.updateSpatialSampling) self.fixRandomSeed.clicked.connect(self.updateSpatialSampling) + 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 Tab # @@ -145,7 +149,8 @@ def __init__(self, parent=None): self.qSampling: QSample = self._qSampling() self.last_calculation_time: Optional[float] = None - self.last_calculation_n: int = 0 + self.last_calculation_n_r: int = 0 + self.last_calculation_n_q: int = 0 self.sld_function: Optional[np.ndarray] = None self.sld_coordinate_mapping: Optional[np.ndarray] = None @@ -162,7 +167,7 @@ def onSolventSLDBoxChanged(self): # self.functionViewer.solvent_sld = sld # TODO: Think more about where to put this variable self.functionViewer.updateImage() - def onSampleCountChanged(self): + def onTimeEstimateParametersChanged(self): """ Called when the number of samples changes """ @@ -171,9 +176,9 @@ def onSampleCountChanged(self): # 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 + time_per_sample = self.last_calculation_time / (self.last_calculation_n_r * self.last_calculation_n_q) - est_time = time_per_sample * int(self.nSamplePoints.value()) + est_time = time_per_sample * int(self.nSamplePoints.value()) * int(self.qSamplesBox.value()) self.timeEstimateLabel.setText(f"Estimated Time: {format_time_estimate(est_time)}") @@ -192,6 +197,8 @@ def doBuild(self): 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, @@ -239,11 +246,17 @@ def doScatter(self): calc = self._scatteringCalculation() try: scattering_result = calculate_scattering(calc) + + # Time estimates self.last_calculation_time = scattering_result.calculation_time - self.last_calculation_n = scattering_result.spatial_sampling_method._calculate_n_actual() + self.last_calculation_n_r = scattering_result.spatial_sampling_method._calculate_n_actual() + self.last_calculation_n_q = scattering_result.q_sampling_method.n_points + self.onTimeEstimateParametersChanged() + # Output info self.codeText("Scattering calculation complete after %g seconds."%scattering_result.calculation_time) + # Plot self.outputCanvas.data = scattering_result self.tabWidget.setCurrentIndex(5) # Move to output tab if complete diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py index e0a852bc5a..d3799bc0dd 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py @@ -25,6 +25,7 @@ def data(self): @data.setter def data(self, scattering_output: ScatteringOutput): + self._data = scattering_output q_values = scattering_output.q_sampling_method() @@ -39,3 +40,4 @@ def data(self, scattering_output: ScatteringOutput): else: self.axes.semilogy(q_values, i_values) + self.draw() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 050a13ec52..3dc62fe11f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -269,7 +269,7 @@ 10 - 100 + 200 @@ -347,7 +347,7 @@ - 1.0 + 0.5 @@ -364,7 +364,7 @@ - 0.001 + 0.0005 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py index c8668ef411..47bf6ce93d 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py @@ -8,20 +8,28 @@ def sld(r, theta, phi): return out -default_text = '''""" Default text goes here... +default_text = '''""" -should probably define a simple function -""" +Here's a new perspective. It calculates the scattering based on real-space description of a particle. -def sld(x,y,z): - """ A cube with 100Ang side length""" +Basically, define your SLD as a function of either cartesian or polar coordinates and click scatter - inside = (np.abs(x) < 50) & (np.abs(y) < 50) & (np.abs(z) < 50) + def sld(x,y,z) + def sld(r,theta,phi) - out = np.zeros_like(x) +The display on the right shows your particle, both as a total projected density (top) and as a slice (bottom). - out[inside] = 1 +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. - return out +Here's a simple example: """ + +def sld(x,y,z): + """ A cube with 100Ang side length""" + + return rect(0.02*x)*rect(0.02*y)*rect(0.02*z) + +''' -''' \ No newline at end of file +""" Press shift-return to build and update views + Click scatter to show the scattering profile""" \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py index 1c4afbfe80..d02ccc0b2c 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py @@ -8,6 +8,8 @@ import numpy as np +from sas.qtgui.Perspectives.ParticleEditor.helper_functions import rect, step + class FunctionDefinitionFailed(Exception): def __init__(self, *args): super() @@ -63,7 +65,7 @@ def process_code(input_text: str, """ new_locals = {} - new_globals = {"np": np, "solvent_sld": solvent_sld} + new_globals = {"np": np, "solvent_sld": solvent_sld, "step": step, "rect": rect} stdout_output = StringIO() with redirect_stdout(stdout_output): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py index 965dfbd1e1..ad9de0985f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py @@ -21,7 +21,7 @@ def __repr__(self): return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) @abstractmethod - def __call__(self) -> (np.ndarray, np.ndarray, np.ndarray): + def __call__(self, size_hint: int) -> (np.ndarray, np.ndarray, np.ndarray): """ Get the sample points """ @@ -30,6 +30,21 @@ def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = N super().__init__(n_points_desired, radius) self.seed = seed + def __call__(self, size_hint: int): + n_full = self._n_points_desired // size_hint + n_rest = self._n_points_desired % size_hint + + for i in range(n_full): + yield self.generate(size_hint) + + if n_rest > 0: + yield self.generate(n_rest) + + @abstractmethod + def generate(self, n): + """ Generate n random points""" + + def __repr__(self): return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self._n_points_desired, self.radius, str(self.seed)) @@ -43,14 +58,14 @@ def _calculate_n_actual(self) -> int: def sampling_details(self) -> str: return "" - def __call__(self): + def generate(self, n): # Sample within a sphere # A sphere will occupy pi/6 of a cube, which is 0.5236 ish # With rejection sampling we need to oversample by about a factor of 2 - target_n = self._n_points_desired + target_n = n output_data = [] while target_n > 0: @@ -81,10 +96,10 @@ def _calculate_n_actual(self) -> int: def sampling_details(self) -> str: return "" - def __call__(self): + def generate(self, n): # Sample within a cube - xyz = np.random.random((self._n_points_desired, 3))*2 - 1.0 + xyz = np.random.random((n, 3))*2 - 1.0 return xyz[:,0], xyz[:,1], xyz[:,2] @@ -98,17 +113,26 @@ def sampling_details(self) -> str: side_length = int(np.ceil(np.cbrt(self._n_points_desired))) return "%ix%ix%i = %i"%(side_length, side_length, side_length, side_length**3) - def __call__(self): + def __call__(self, size_hint: int): + side_length = int(np.ceil(np.cbrt(self._n_points_desired))) - n = side_length**3 + n = side_length ** 3 # We want the sampling to happen in the centre of each voxel # get points at edges and centres, then skip ever other one not the edge sample_values = np.linspace(-self.radius, self.radius, 2*side_length+1)[1::2] - x, y, z = np.meshgrid(sample_values, sample_values, sample_values) + # Calculate the number of slices per chunk, minimum of one slice + n_chunks = n / size_hint + n_slices_per_chunk = int(np.ceil(side_length/n_chunks)) + + print(n_slices_per_chunk) + + for i in range(0, side_length, n_slices_per_chunk): - return x.reshape((n, )), y.reshape((n, )), z.reshape((n, )) + x, y, z = np.meshgrid(sample_values, sample_values, sample_values[i:i+n_slices_per_chunk]) + + yield x.reshape((-1, )), y.reshape((-1, )), z.reshape((-1, )) class QSample: @@ -132,8 +156,13 @@ def __call__(self): if __name__ == "__main__": + print("Random Sphere Sampler") sampler = RandomSampleSphere(n_points_desired=100, radius=1) - for i in range(5): - x,y,z = sampler() - # print(x**2 + y**2 + z**2) - print(len(x)) \ No newline at end of file + for x,y,z in sampler(45): + print(len(x)) + + print("Grid Sampler") + sampler = GridSample(n_points_desired=1000, radius=1) + for x, y, z in sampler(250): + print(len(x)) + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py index c7dab689ba..87046acc22 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -36,6 +36,7 @@ class ScatteringCalculation: magnetism_function_from_cartesian: Optional[CoordinateTransform] magnetism_function_parameters: Optional[Dict[str, float]] magnetism_vector: Optional[np.ndarray] + sample_chunk_size_hint: int = 100_000 @dataclass @@ -57,22 +58,27 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput if calculation.output_type == OutputType.SLD_2D: raise NotImplementedError("2D scattering not implemented yet") - # input samples - q = calculation.q_sampling_method() - x, y, z = calculation.spatial_sampling_method() + f = None + for x, y, z in calculation.spatial_sampling_method(calculation.sample_chunk_size_hint): - # evaluate sld - input_coordinates = calculation.sld_function_from_cartesian(x,y,z) - sld = calculation.sld_function(*input_coordinates, **calculation.sld_function_parameters) - sld -= calculation.solvent_sld + # input samples + q = calculation.q_sampling_method() - # Do the integration - r = np.sqrt(x**2 + y**2 + z**2) - qr = np.outer(q, r) + # evaluate sld + input_coordinates = calculation.sld_function_from_cartesian(x,y,z) + sld = calculation.sld_function(*input_coordinates, **calculation.sld_function_parameters) + sld -= calculation.solvent_sld - intensity = np.sum(np.sin(qr) / qr, axis=1)**2 + # Do the integration + r = np.sqrt(x**2 + y**2 + z**2) + qr = np.outer(q, r) + if f is None: + f = np.sum(sld * np.sin(qr) / qr, axis=1) + else: + f += np.sum(sld * np.sin(qr) / qr, axis=1) + intensity = f*f elif calculation.orientation == OrientationalDistribution.FIXED: From 54d8c64fc294a3049546ce906911501bef341819 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Tue, 16 May 2023 02:37:02 +0100 Subject: [PATCH 15/38] Some testing --- src/sas/qtgui/Perspectives/ParticleEditor/sampling.py | 8 +++++--- .../qtgui/Perspectives/ParticleEditor/scattering.py | 10 +++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py index ad9de0985f..8237afa947 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py @@ -99,7 +99,7 @@ def sampling_details(self) -> str: def generate(self, n): # Sample within a cube - xyz = np.random.random((n, 3))*2 - 1.0 + xyz = (np.random.random((n, 3)) - 0.5)*(2*self.radius) return xyz[:,0], xyz[:,1], xyz[:,2] @@ -157,8 +157,10 @@ def __call__(self): if __name__ == "__main__": print("Random Sphere Sampler") - sampler = RandomSampleSphere(n_points_desired=100, radius=1) - for x,y,z in sampler(45): + sampler = RandomSampleSphere(n_points_desired=10000, radius=1) + for x,y,z in sampler(7000): + r = np.sqrt(x**2+y**2+z**2) + print(np.max(r)) print(len(x)) print("Grid Sampler") diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py index 87046acc22..701524e529 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -69,14 +69,18 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput sld = calculation.sld_function(*input_coordinates, **calculation.sld_function_parameters) sld -= calculation.solvent_sld + inds = sld != 0 # faster when there are not many points, TODO: make into a simulation option + # Do the integration - r = np.sqrt(x**2 + y**2 + z**2) + r = np.sqrt(x[inds]**2 + y[inds]**2 + z[inds]**2) qr = np.outer(q, r) + f_chunk = np.sum(sld[inds] * np.sin(qr) / qr, axis=1) + if f is None: - f = np.sum(sld * np.sin(qr) / qr, axis=1) + f = f_chunk else: - f += np.sum(sld * np.sin(qr) / qr, axis=1) + f += f_chunk intensity = f*f From 5e7bf64a7b235f6dbc17afb2f9b5fa107684762d Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Tue, 23 May 2023 17:10:57 +0100 Subject: [PATCH 16/38] Updated UI and algorithm --- .../ParticleEditor/DesignWindow.py | 31 ++++-- .../{OutputCanvas.py => Plots/QCanvas.py} | 6 +- .../ParticleEditor/Plots/RCanvas.py | 37 +++++++ .../ParticleEditor/Plots/__init__.py | 0 .../ParticleEditor/UI/DesignWindowUI.ui | 9 +- .../Perspectives/ParticleEditor/sampling.py | 104 ++++++++++-------- .../Perspectives/ParticleEditor/scattering.py | 93 +++++++++++----- 7 files changed, 187 insertions(+), 93 deletions(-) rename src/sas/qtgui/Perspectives/ParticleEditor/{OutputCanvas.py => Plots/QCanvas.py} (90%) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/Plots/__init__.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 44b6b5a36e..7fd8d123b0 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -1,17 +1,18 @@ import traceback -from typing import Optional, Callable +from typing import Optional from datetime import datetime import numpy as np -from PySide6 import QtWidgets, QtGui +from PySide6 import QtWidgets from PySide6.QtCore import Qt 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.OutputCanvas import OutputCanvas +from sas.qtgui.Perspectives.ParticleEditor.Plots.QCanvas import QCanvas +from sas.qtgui.Perspectives.ParticleEditor.Plots.RCanvas import RCanvas from sas.qtgui.Perspectives.ParticleEditor.UI.DesignWindowUI import Ui_DesignWindow @@ -19,13 +20,13 @@ from sas.qtgui.Perspectives.ParticleEditor.vectorise import vectorise_sld from sas.qtgui.Perspectives.ParticleEditor.sampling import ( - SpatialSample, QSample, RandomSampleSphere, RandomSampleCube, GridSample) + SpatialSample, QSample, RandomSampleSphere, RandomSampleCube) from sas.qtgui.Perspectives.ParticleEditor.scattering import ( OutputType, OrientationalDistribution, ScatteringCalculation, calculate_scattering) from sas.qtgui.Perspectives.ParticleEditor.util import format_time_estimate -import sas.qtgui.Perspectives.ParticleEditor.UI.icons_rc + class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow): def __init__(self, parent=None): super().__init__() @@ -98,7 +99,7 @@ def __init__(self, parent=None): # # Calculation Tab # - self.methodComboOptions = ["Sphere Monte Carlo", "Cube Monte Carlo", "Grid"] + self.methodComboOptions = ["Sphere Monte Carlo", "Cube Monte Carlo"] for option in self.methodComboOptions: self.methodCombo.addItem(option) @@ -120,15 +121,23 @@ def __init__(self, parent=None): self.qSamplesBox.valueChanged.connect(self.onTimeEstimateParametersChanged) # - # Output Tab + # Output Tabs # - self.outputCanvas = OutputCanvas() + self.realCanvas = RCanvas() + + outputLayout = QtWidgets.QVBoxLayout() + outputLayout.addWidget(self.realCanvas) + + self.realSpaceTab.setLayout(outputLayout) + + + self.outputCanvas = QCanvas() outputLayout = QtWidgets.QVBoxLayout() outputLayout.addWidget(self.outputCanvas) - self.outputTab.setLayout(outputLayout) + self.qSpaceTab.setLayout(outputLayout) # # Misc @@ -257,6 +266,7 @@ def doScatter(self): self.codeText("Scattering calculation complete after %g seconds."%scattering_result.calculation_time) # Plot + self.realCanvas.data = scattering_result self.outputCanvas.data = scattering_result self.tabWidget.setCurrentIndex(5) # Move to output tab if complete @@ -318,9 +328,6 @@ def _spatialSampling(self) -> SpatialSample: elif sample_type == 1: return RandomSampleCube(radius=radius, n_points_desired=n_desired, seed=seed) - elif sample_type == 2: - return GridSample(radius=radius, n_points_desired=n_desired) - else: raise ValueError("Unknown index for spatial sampling method combo") diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py similarity index 90% rename from src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py rename to src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py index d3799bc0dd..122fb2765f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/OutputCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py @@ -5,9 +5,9 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure -from scattering import ScatteringOutput +from sas.qtgui.Perspectives.ParticleEditor.scattering import ScatteringOutput -class OutputCanvas(FigureCanvas): +class QCanvas(FigureCanvas): """ Plot window for output from scattering calculations""" def __init__(self, parent=None, width=5, height=4, dpi=100): @@ -31,8 +31,6 @@ def data(self, scattering_output: ScatteringOutput): q_values = scattering_output.q_sampling_method() i_values = scattering_output.intensity_data - print(len(q_values)) - self.axes.cla() if scattering_output.q_sampling_method.is_log: diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py new file mode 100644 index 0000000000..8a31318629 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py @@ -0,0 +1,37 @@ +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.scattering import ScatteringOutput + + +class RCanvas(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() + + + self.axes.plot(scattering_output.r_values, scattering_output.realspace_intensity) + + 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/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 3dc62fe11f..b1a420dd43 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -570,9 +570,14 @@ Fitting
- + - Output + Real Space + + + + + Q Space diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py index 8237afa947..82612d9995 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Tuple from abc import ABC, abstractmethod import numpy as np @@ -17,20 +17,29 @@ def _calculate_n_actual(self) -> int: def sampling_details(self) -> str: """ A string describing the details of the sample points """ + @abstractmethod + def pairs(self, int) -> Tuple[Tuple[np.ndarray, np.ndarray, np.ndarray],Tuple[np.ndarray, np.ndarray, np.ndarray]]: + """ Pairs of points """ + + # TODO: Implement this to allow better sampling of distances + # + # @abstractmethod + # def deltas(self): + # """ Changes in point position """ + def __repr__(self): return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) @abstractmethod - def __call__(self, size_hint: int) -> (np.ndarray, np.ndarray, np.ndarray): + def start_location(self, size_hint: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Get the sample points """ - class RandomSample(SpatialSample): def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): super().__init__(n_points_desired, radius) self.seed = seed - def __call__(self, size_hint: int): + def start_location(self, size_hint: int): n_full = self._n_points_desired // size_hint n_rest = self._n_points_desired % size_hint @@ -40,6 +49,16 @@ def __call__(self, size_hint: int): if n_rest > 0: yield self.generate(n_rest) + def pairs(self, size_hint): + n_full = self._n_points_desired // size_hint + n_rest = self._n_points_desired % size_hint + + for i in range(n_full): + yield self.generate(size_hint), self.generate(size_hint) + + if n_rest > 0: + yield self.generate(n_rest), self.generate(n_rest) + @abstractmethod def generate(self, n): """ Generate n random points""" @@ -103,36 +122,39 @@ def generate(self, n): return xyz[:,0], xyz[:,1], xyz[:,2] - -class GridSample(SpatialSample): - def _calculate_n_actual(self) -> int: - side_length = int(np.ceil(np.cbrt(self._n_points_desired))) - return side_length**3 - - def sampling_details(self) -> str: - side_length = int(np.ceil(np.cbrt(self._n_points_desired))) - return "%ix%ix%i = %i"%(side_length, side_length, side_length, side_length**3) - - def __call__(self, size_hint: int): - - side_length = int(np.ceil(np.cbrt(self._n_points_desired))) - n = side_length ** 3 - - # We want the sampling to happen in the centre of each voxel - # get points at edges and centres, then skip ever other one not the edge - sample_values = np.linspace(-self.radius, self.radius, 2*side_length+1)[1::2] - - # Calculate the number of slices per chunk, minimum of one slice - n_chunks = n / size_hint - n_slices_per_chunk = int(np.ceil(side_length/n_chunks)) - - print(n_slices_per_chunk) - - for i in range(0, side_length, n_slices_per_chunk): - - x, y, z = np.meshgrid(sample_values, sample_values, sample_values[i:i+n_slices_per_chunk]) - - yield x.reshape((-1, )), y.reshape((-1, )), z.reshape((-1, )) +# +# class GridSample(SpatialSample): +# def _calculate_n_actual(self) -> int: +# side_length = int(np.ceil(np.cbrt(self._n_points_desired))) +# return side_length**3 +# +# def sampling_details(self) -> str: +# side_length = int(np.ceil(np.cbrt(self._n_points_desired))) +# return "%ix%ix%i = %i"%(side_length, side_length, side_length, side_length**3) +# +# def pairs(self, size_hint: int): +# raise NotImplementedError("Pair function not implemented for grid function") +# +# def start_location(self, size_hint: int): +# +# side_length = int(np.ceil(np.cbrt(self._n_points_desired))) +# n = side_length ** 3 +# +# # We want the sampling to happen in the centre of each voxel +# # get points at edges and centres, then skip ever other one not the edge +# sample_values = np.linspace(-self.radius, self.radius, 2*side_length+1)[1::2] +# +# # Calculate the number of slices per chunk, minimum of one slice +# n_chunks = n / size_hint +# n_slices_per_chunk = int(np.ceil(side_length/n_chunks)) +# +# print(n_slices_per_chunk) +# +# for i in range(0, side_length, n_slices_per_chunk): +# +# x, y, z = np.meshgrid(sample_values, sample_values, sample_values[i:i+n_slices_per_chunk]) +# +# yield x.reshape((-1, )), y.reshape((-1, )), z.reshape((-1, )) class QSample: @@ -154,17 +176,3 @@ def __call__(self): else: return np.linspace(self.start, self.end, self.n_points) - -if __name__ == "__main__": - print("Random Sphere Sampler") - sampler = RandomSampleSphere(n_points_desired=10000, radius=1) - for x,y,z in sampler(7000): - r = np.sqrt(x**2+y**2+z**2) - print(np.max(r)) - print(len(x)) - - print("Grid Sampler") - sampler = GridSample(n_points_desired=1000, radius=1) - for x, y, z in sampler(250): - print(len(x)) - diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py index 701524e529..b2f72e5d95 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -45,6 +45,8 @@ class ScatteringOutput: q_sampling_method: QSample spatial_sampling_method: SpatialSample intensity_data: np.ndarray + r_values: np.ndarray + realspace_intensity: np.ndarray calculation_time: float def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput: @@ -58,49 +60,86 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput if calculation.output_type == OutputType.SLD_2D: raise NotImplementedError("2D scattering not implemented yet") - f = None - for x, y, z in calculation.spatial_sampling_method(calculation.sample_chunk_size_hint): + # Try a different method, estimate the radial distribution + n_r = 1000 + n_r_upscale = 10000 + bin_edges = np.linspace(0,2*calculation.spatial_sampling_method.radius, n_r+1) + bin_size = 2*calculation.spatial_sampling_method.radius / n_r - # input samples - q = calculation.q_sampling_method() + sld_total = None + counts = None + + for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method.pairs(calculation.sample_chunk_size_hint): # evaluate sld - input_coordinates = calculation.sld_function_from_cartesian(x,y,z) - sld = calculation.sld_function(*input_coordinates, **calculation.sld_function_parameters) - sld -= calculation.solvent_sld + input_coordinates1 = calculation.sld_function_from_cartesian(x0, y0, z0) + input_coordinates2 = calculation.sld_function_from_cartesian(x1, y1, z1) - inds = sld != 0 # faster when there are not many points, TODO: make into a simulation option + sld1 = calculation.sld_function(*input_coordinates1, **calculation.sld_function_parameters) + sld1 -= calculation.solvent_sld - # Do the integration - r = np.sqrt(x[inds]**2 + y[inds]**2 + z[inds]**2) - qr = np.outer(q, r) + sld2 = calculation.sld_function(*input_coordinates2, **calculation.sld_function_parameters) + sld2 -= calculation.solvent_sld - f_chunk = np.sum(sld[inds] * np.sin(qr) / qr, axis=1) + rho = sld1*sld2 - if f is None: - f = f_chunk + # Do the integration + sample_rs = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) + + if sld_total is None: + sld_total = np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] + counts = np.histogram(sample_rs, bins=bin_edges)[0] else: - f += f_chunk + sld_total += np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] + counts += np.histogram(sample_rs, bins=bin_edges)[0] + + if counts is None or sld_total is None: + raise ValueError("No sample points") + + + # Remove all zero count bins + non_empty_bins = counts > 0 + + # print(np.count_nonzero(non_empty_bins)) + + r_small = (bin_edges[:-1] + 0.5 * bin_size)[non_empty_bins] + averages = sld_total[non_empty_bins] / counts[non_empty_bins] + + r_large = np.arange(1, n_r_upscale + 1) * (2*calculation.spatial_sampling_method.radius / n_r_upscale) + + + # Upscale, and weight by 1/r^2 + new_averages = np.interp(r_large, r_small, averages) + + # Do transform + q = calculation.q_sampling_method() + qr = np.outer(q, r_large) + + f = np.sum((new_averages*r_large*r_large) * np.sin(qr) / qr, axis=1) + # f = np.sum((new_averages * r_large) * np.sin(qr) / qr, axis=1) + # f = np.sum(new_averages * np.sin(qr) / qr, axis=1) intensity = f*f - elif calculation.orientation == OrientationalDistribution.FIXED: + # Calculate magnet contribution + # TODO: implement magnetic scattering - raise NotImplementedError("Oriented particle not implemented yet") + # Wrap up + calculation_time = time.time() - start_time + return ScatteringOutput( + output_type=calculation.output_type, + q_sampling_method=calculation.q_sampling_method, + spatial_sampling_method=calculation.spatial_sampling_method, + intensity_data=intensity, + calculation_time=calculation_time, + r_values=r_large, + realspace_intensity=new_averages) - # Calculate magnet contribution - # TODO: implement magnetic scattering + elif calculation.orientation == OrientationalDistribution.FIXED: - # Wrap up + raise NotImplementedError("Oriented particle not implemented yet") - calculation_time = time.time() - start_time - return ScatteringOutput( - output_type=calculation.output_type, - q_sampling_method=calculation.q_sampling_method, - spatial_sampling_method=calculation.spatial_sampling_method, - intensity_data=intensity, - calculation_time=calculation_time) \ No newline at end of file From bb4a5a038eb51ad38e4cccdc3e6d4c0853ea0353 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Thu, 25 May 2023 15:35:46 +0100 Subject: [PATCH 17/38] Some debugging work --- .../ParticleEditor/Plots/QCanvas.py | 11 +++++ .../ParticleEditor/datamodel/__init__.py | 0 .../ParticleEditor/datamodel/calculation.py | 41 +++++++++++++++++++ .../ParticleEditor/datamodel/ensemble.py | 0 .../ParticleEditor/datamodel/particle.py | 0 .../Perspectives/ParticleEditor/defaults.py | 9 +++- .../Perspectives/ParticleEditor/scattering.py | 17 ++++++-- 7 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/__init__.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/ensemble.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/particle.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py index 122fb2765f..eaec8a6f85 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py @@ -7,6 +7,13 @@ from sas.qtgui.Perspectives.ParticleEditor.scattering import ScatteringOutput +import numpy as np +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""" @@ -35,7 +42,11 @@ def data(self, scattering_output: ScatteringOutput): if scattering_output.q_sampling_method.is_log: self.axes.loglog(q_values, i_values) + + self.axes.loglog(q_values, spherical_form_factor(q_values, 50)) else: self.axes.semilogy(q_values, i_values) + + self.draw() \ No newline at end of file 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..5bcd6c674e --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -0,0 +1,41 @@ +""" Data structures and types for describing particles """ + +from typing import Protocol, Tuple, Optional +from dataclasses import dataclass + +import numpy as np + +# 3D vector output as lists of x,y and z components +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, a: np.ndarray, b: np.ndarray, c: np.ndarray) -> VectorComponents3: ... + +@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 Particle: + """ Object containing the functions that define a particle""" + sld: SLDDefinition + magnetism: Optional[MagnetismDefinition] diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/ensemble.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/ensemble.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/particle.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/particle.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py index 47bf6ce93d..35b6fc7a8e 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py @@ -32,4 +32,11 @@ def sld(x,y,z): ''' """ Press shift-return to build and update views - Click scatter to show the scattering profile""" \ No newline at end of file + Click scatter to show the scattering profile""" + +# TODO: REMOVE +default_text = """ + +def sld(r,theta,phi): + return rect(r/50) +""" diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py index b2f72e5d95..b8bed2ce13 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -77,14 +77,16 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput sld1 = calculation.sld_function(*input_coordinates1, **calculation.sld_function_parameters) sld1 -= calculation.solvent_sld - + # sld2 = calculation.sld_function(*input_coordinates2, **calculation.sld_function_parameters) sld2 -= calculation.solvent_sld rho = sld1*sld2 + # rho = sld1 # Do the integration sample_rs = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) + # sample_rs = np.sqrt(x0**2 + y0**2 + z0**2) if sld_total is None: sld_total = np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] @@ -115,11 +117,20 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput q = calculation.q_sampling_method() qr = np.outer(q, r_large) - f = np.sum((new_averages*r_large*r_large) * np.sin(qr) / qr, axis=1) + # Power of q must be -1 for correct slope at low q + + # f = np.sum((new_averages * (r_large * r_large)) * np.sin(qr) / qr, axis=1) # Correct for sphere with COM sampling + f = np.sum((new_averages * (r_large ** 3)) * np.sin(qr) / qr, axis=1) # Correct for sphere with COM sampling + # f = np.sum((new_averages * (r_large ** 4)) * np.sin(qr) / qr, axis=1) # Correct for sphere with COM sampling + # f = np.sum((new_averages*r_large*r_large) * np.sin(qr) / (qr**3), axis=1) # f = np.sum((new_averages * r_large) * np.sin(qr) / qr, axis=1) # f = np.sum(new_averages * np.sin(qr) / qr, axis=1) + # f = np.sum(new_averages / r_large * np.sin(qr) / qr, axis=1) + # f = np.sum(new_averages / (r_large**2) * np.sin(qr) / qr, axis=1) - intensity = f*f + intensity = f*f # Correct for sphere with COM sampling + # intensity = np.abs(f) + # intensity = f # Calculate magnet contribution # TODO: implement magnetic scattering From 9ed60d83be13db88617178be1a686cb617de3b4d Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Thu, 8 Jun 2023 22:59:59 +0100 Subject: [PATCH 18/38] Some refactoring prior to proper implementation --- .../ParticleEditor/DesignWindow.py | 7 +- .../ParticleEditor/Plots/QCanvas.py | 10 +- .../ParticleEditor/Plots/RCanvas.py | 3 +- .../ParticleEditor/datamodel/calculation.py | 85 +++++-- .../ParticleEditor/datamodel/ensemble.py | 0 .../ParticleEditor/datamodel/parameters.py | 32 +++ .../ParticleEditor/datamodel/particle.py | 0 .../ParticleEditor/datamodel/sampling.py | 43 ++++ .../ParticleEditor/datamodel/types.py | 22 ++ .../Perspectives/ParticleEditor/defaults.py | 22 +- .../ParticleEditor/helper_functions.py | 5 +- .../Perspectives/ParticleEditor/sampling.py | 178 -------------- .../ParticleEditor/sampling_methods.py | 111 +++++++++ .../Perspectives/ParticleEditor/scattering.py | 222 ++++++++++++------ 14 files changed, 463 insertions(+), 277 deletions(-) delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/ensemble.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/particle.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 7fd8d123b0..2af0bb3806 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -19,8 +19,10 @@ 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.sampling import ( - SpatialSample, QSample, RandomSampleSphere, RandomSampleCube) +from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( + SpatialSample, RandomSampleSphere, RandomSampleCube) + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import QSample from sas.qtgui.Perspectives.ParticleEditor.scattering import ( OutputType, OrientationalDistribution, ScatteringCalculation, calculate_scattering) @@ -90,6 +92,7 @@ def __init__(self, parent=None): self.orientationCombo.addItem("Unoriented") self.orientationCombo.addItem("Fixed Orientation") + self.orientationCombo.setCurrentIndex(0) self.structureFactorCombo.addItem("None") # TODO: Structure Factor Options diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py index eaec8a6f85..195b03cfb3 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py @@ -13,7 +13,6 @@ def spherical_form_factor(q, r): f = (np.sin(rq) - rq * np.cos(rq)) / (rq ** 3) return f * f - class QCanvas(FigureCanvas): """ Plot window for output from scattering calculations""" @@ -43,7 +42,14 @@ def data(self, scattering_output: ScatteringOutput): if scattering_output.q_sampling_method.is_log: self.axes.loglog(q_values, i_values) - self.axes.loglog(q_values, spherical_form_factor(q_values, 50)) + 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 + # self.axes.loglog(q_values, spherical_form_factor(q_values, 50)) else: self.axes.semilogy(q_values, i_values) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py index 8a31318629..1dd2893a42 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py @@ -31,7 +31,8 @@ def data(self, scattering_output: ScatteringOutput): self.axes.cla() + if scattering_output.r_values is not None and scattering_output.realspace_intensity is not None: - self.axes.plot(scattering_output.r_values, scattering_output.realspace_intensity) + self.axes.plot(scattering_output.r_values, scattering_output.realspace_intensity) self.draw() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index 5bcd6c674e..f6650a3eee 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -1,26 +1,63 @@ -""" Data structures and types for describing particles """ - -from typing import Protocol, Tuple, Optional +from typing import Optional, Callable, Tuple, Protocol +import numpy as np +from enum import Enum from dataclasses import dataclass -import numpy as np +from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import SLDFunction, MagnetismFunction, CoordinateSystemTransform + +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 ZSample: + """ Sample of correlation space """ + + def __init__(self, start, end, n_points): + self.start = start + self.end = end + self.n_points = n_points + + def __repr__(self): + return f"QSampling({self.start}, {self.end}, n={self.n_points})" -# 3D vector output as lists of x,y and z components -VectorComponents3 = Tuple[np.ndarray, np.ndarray, np.ndarray] + def __call__(self): + return np.linspace(self.start, self.end, self.n_points) -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""" +@dataclass +class OutputOptions: + radial_distribution: Optional = None + radial_correlation: Optional = None + p_of_r: Optional = None + q_space: Optional[QSample] = None + q_space_2d: Optional[QSample] = None + sesans: Optional[ZSample] = None + sesans_2d: Optional[ZSample] = None + + +class OrientationalDistribution(Enum): + FIXED = "Fixed" + UNORIENTED = "Unoriented" - 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, a: np.ndarray, b: np.ndarray, c: np.ndarray) -> VectorComponents3: ... @dataclass class SLDDefinition: @@ -28,14 +65,30 @@ class SLDDefinition: 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 Particle: +class ParticleDefinition: """ Object containing the functions that define a particle""" sld: SLDDefinition magnetism: Optional[MagnetismDefinition] + + +@dataclass +class ScatteringCalculation: + """ Specification for a scattering calculation """ + solvent_sld: float + output_options: OutputOptions + orientation: OrientationalDistribution + spatial_sampling_method: SpatialSample + particle_definition: ParticleDefinition + magnetism_vector: Optional[np.ndarray] + sample_chunk_size_hint: int = 100_000 + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/ensemble.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/ensemble.py deleted file mode 100644 index e69de29bb2..0000000000 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..f492339a86 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py @@ -0,0 +1,32 @@ +class Parameter: + is_function_parameter = False + + def __init__(self, name: str, value: float): + self.name = name + self.value = value + + +class FunctionParameter(Parameter): + """ Representation of an input parameter to the sld""" + is_function_parameter = True + + +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) + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/particle.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/particle.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py new file mode 100644 index 0000000000..1c3c387f83 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod +import numpy as np +from typing import Tuple + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 +class SpatialSample(ABC): + """ Base class for spatial sampling methods""" + def __init__(self, n_points_desired, radius): + self._n_points_desired = n_points_desired + self.radius = radius + + @abstractmethod + def _calculate_n_actual(self) -> int: + """ Calculate the actual number of sample points, based on the desired number of points""" + + @property + def n_actual(self) -> int: + return self._calculate_n_actual() + + @abstractmethod + def sampling_details(self) -> str: + """ A string describing the details of the sample points """ + + @abstractmethod + def pairs(self, size_hint: int) -> Tuple[VectorComponents3, VectorComponents3]: + """ Pairs of sample points """ + + + @abstractmethod + def singles(self, size_hint: int) -> VectorComponents3: + """ Sample points """ + + def __repr__(self): + return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) + + @abstractmethod + def start_location(self, size_hint: int) -> VectorComponents3: + """ Get the sample points """ + + @abstractmethod + def sample_volume(self) -> float: + """ Volume of sample area """ + 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..d4ec63de53 --- /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, a: np.ndarray, b: np.ndarray, c: np.ndarray) -> VectorComponents3: ... \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py index 35b6fc7a8e..7e59f9fe68 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py @@ -33,10 +33,18 @@ def sld(x,y,z): """ 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) +# """ + +# +# # 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/helper_functions.py b/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py index 992380dc82..d32689edc6 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py @@ -14,8 +14,11 @@ def step(x: np.ndarray): 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 \ No newline at end of file + return out + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py deleted file mode 100644 index 82612d9995..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling.py +++ /dev/null @@ -1,178 +0,0 @@ -from typing import Optional, Tuple -from abc import ABC, abstractmethod -import numpy as np - - -class SpatialSample(ABC): - """ Base class for spatial sampling methods""" - def __init__(self, n_points_desired, radius): - self._n_points_desired = n_points_desired - self.radius = radius - - @abstractmethod - def _calculate_n_actual(self) -> int: - """ Calculate the actual number of sample points, based on the desired number of points""" - - @abstractmethod - def sampling_details(self) -> str: - """ A string describing the details of the sample points """ - - @abstractmethod - def pairs(self, int) -> Tuple[Tuple[np.ndarray, np.ndarray, np.ndarray],Tuple[np.ndarray, np.ndarray, np.ndarray]]: - """ Pairs of points """ - - # TODO: Implement this to allow better sampling of distances - # - # @abstractmethod - # def deltas(self): - # """ Changes in point position """ - - def __repr__(self): - return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) - - @abstractmethod - def start_location(self, size_hint: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - """ Get the sample points """ - -class RandomSample(SpatialSample): - def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): - super().__init__(n_points_desired, radius) - self.seed = seed - - def start_location(self, size_hint: int): - n_full = self._n_points_desired // size_hint - n_rest = self._n_points_desired % size_hint - - for i in range(n_full): - yield self.generate(size_hint) - - if n_rest > 0: - yield self.generate(n_rest) - - def pairs(self, size_hint): - n_full = self._n_points_desired // size_hint - n_rest = self._n_points_desired % size_hint - - for i in range(n_full): - yield self.generate(size_hint), self.generate(size_hint) - - if n_rest > 0: - yield self.generate(n_rest), self.generate(n_rest) - - @abstractmethod - def generate(self, n): - """ Generate n random points""" - - - def __repr__(self): - return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self._n_points_desired, self.radius, str(self.seed)) - - -class RandomSampleSphere(RandomSample): - """ Rejection Random Sampler for a sphere with a given radius """ - - def _calculate_n_actual(self) -> int: - return self._n_points_desired - - def sampling_details(self) -> str: - return "" - - def generate(self, n): - # Sample within a sphere - - # A sphere will occupy pi/6 of a cube, which is 0.5236 ish - # With rejection sampling we need to oversample by about a factor of 2 - - - target_n = n - - output_data = [] - while target_n > 0: - xyz = np.random.random((int(1.91 * target_n)+1, 3)) - 0.5 - - indices = np.sum(xyz**2, axis=1) <= 0.25 - - xyz = xyz[indices, :] - - if xyz.shape[0] > target_n: - output_data.append(xyz[:target_n, :]) - target_n = 0 - else: - output_data.append(xyz) - target_n -= xyz.shape[0] - - xyz = np.concatenate(output_data, axis=0) * (2*self.radius) - - return xyz[:,0], xyz[:,1], xyz[:,2] - - -class RandomSampleCube(RandomSample): - """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" - - def _calculate_n_actual(self) -> int: - return self._n_points_desired - - def sampling_details(self) -> str: - return "" - - def generate(self, n): - # Sample within a cube - - xyz = (np.random.random((n, 3)) - 0.5)*(2*self.radius) - - return xyz[:,0], xyz[:,1], xyz[:,2] - -# -# class GridSample(SpatialSample): -# def _calculate_n_actual(self) -> int: -# side_length = int(np.ceil(np.cbrt(self._n_points_desired))) -# return side_length**3 -# -# def sampling_details(self) -> str: -# side_length = int(np.ceil(np.cbrt(self._n_points_desired))) -# return "%ix%ix%i = %i"%(side_length, side_length, side_length, side_length**3) -# -# def pairs(self, size_hint: int): -# raise NotImplementedError("Pair function not implemented for grid function") -# -# def start_location(self, size_hint: int): -# -# side_length = int(np.ceil(np.cbrt(self._n_points_desired))) -# n = side_length ** 3 -# -# # We want the sampling to happen in the centre of each voxel -# # get points at edges and centres, then skip ever other one not the edge -# sample_values = np.linspace(-self.radius, self.radius, 2*side_length+1)[1::2] -# -# # Calculate the number of slices per chunk, minimum of one slice -# n_chunks = n / size_hint -# n_slices_per_chunk = int(np.ceil(side_length/n_chunks)) -# -# print(n_slices_per_chunk) -# -# for i in range(0, side_length, n_slices_per_chunk): -# -# x, y, z = np.meshgrid(sample_values, sample_values, sample_values[i:i+n_slices_per_chunk]) -# -# yield x.reshape((-1, )), y.reshape((-1, )), z.reshape((-1, )) - - -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) - diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py new file mode 100644 index 0000000000..909910ee81 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py @@ -0,0 +1,111 @@ +from typing import Optional, Tuple +from abc import ABC, abstractmethod +import numpy as np + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample + + +class RandomSample(SpatialSample): + def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): + super().__init__(n_points_desired, radius) + self.seed = seed + + def start_location(self, size_hint: int): + n_full = self._n_points_desired // size_hint + n_rest = self._n_points_desired % size_hint + + for i in range(n_full): + yield self.generate(size_hint) + + if n_rest > 0: + yield self.generate(n_rest) + + def pairs(self, size_hint): + n_full = self._n_points_desired // size_hint + n_rest = self._n_points_desired % size_hint + + for i in range(n_full): + yield self.generate(size_hint), self.generate(size_hint) + + if n_rest > 0: + yield self.generate(n_rest), self.generate(n_rest) + + def singles(self, size_hint: int): + n_full = self._n_points_desired // size_hint + n_rest = self._n_points_desired % size_hint + + for i in range(n_full): + yield self.generate(size_hint) + + if n_rest > 0: + yield self.generate(n_rest) + + + @abstractmethod + def generate(self, n): + """ Generate n random points""" + + + def __repr__(self): + return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self._n_points_desired, self.radius, str(self.seed)) + + +class RandomSampleSphere(RandomSample): + """ Rejection Random Sampler for a sphere with a given radius """ + + def _calculate_n_actual(self) -> int: + return self._n_points_desired + + def sampling_details(self) -> str: + return "" + + def sample_volume(self) -> float: + return (4*np.pi/3)*(self.radius**3) + + def generate(self, n): + # Sample within a sphere + + # A sphere will occupy pi/6 of a cube, which is 0.5236 ish + # With rejection sampling we need to oversample by about a factor of 2 + + + target_n = n + + output_data = [] + while target_n > 0: + xyz = np.random.random((int(1.91 * target_n)+1, 3)) - 0.5 + + indices = np.sum(xyz**2, axis=1) <= 0.25 + + xyz = xyz[indices, :] + + if xyz.shape[0] > target_n: + output_data.append(xyz[:target_n, :]) + target_n = 0 + else: + output_data.append(xyz) + target_n -= xyz.shape[0] + + xyz = np.concatenate(output_data, axis=0) * (2*self.radius) + + return xyz[:,0], xyz[:,1], xyz[:,2] + + +class RandomSampleCube(RandomSample): + """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" + + def _calculate_n_actual(self) -> int: + return self._n_points_desired + + def sampling_details(self) -> str: + return "" + + def sample_volume(self) -> float: + return 8*(self.radius**3) + + def generate(self, n): + # Sample within a cube + + xyz = (np.random.random((n, 3)) - 0.5)*(2*self.radius) + + return xyz[:,0], xyz[:,1], xyz[:,2] diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py index b8bed2ce13..850e35c66f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -5,39 +5,12 @@ import time import numpy as np +from scipy.special import jv as besselJ -from sampling import SpatialSample, QSample - -CoordinateTransform = Callable[[np.ndarray, np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray, np.ndarray]] - -class OutputType(Enum): - SLD_1D = "1D" - SLD_2D = "2D" - MAGNETIC_1D = "Magnetic 1D" - MAGNETIC_2D = "Magnetic 2D" - -class OrientationalDistribution(Enum): - FIXED = "Fixed" - UNORIENTED = "Unoriented" - - -@dataclass -class ScatteringCalculation: - """ Specification for a scattering calculation """ - solvent_sld: float - output_type: OutputType - orientation: OrientationalDistribution - spatial_sampling_method: SpatialSample - q_sampling_method: QSample - sld_function: Any - sld_function_from_cartesian: CoordinateTransform - sld_function_parameters: Dict[str, float] - magnetism_function: Any - magnetism_function_from_cartesian: Optional[CoordinateTransform] - magnetism_function_parameters: Optional[Dict[str, float]] - magnetism_vector: Optional[np.ndarray] - sample_chunk_size_hint: int = 100_000 +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + QSample, ZSample, OutputType, OrientationalDistribution, ScatteringCalculation) +from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample @dataclass class ScatteringOutput: @@ -45,8 +18,8 @@ class ScatteringOutput: q_sampling_method: QSample spatial_sampling_method: SpatialSample intensity_data: np.ndarray - r_values: np.ndarray - realspace_intensity: np.ndarray + r_values: Optional[np.ndarray] + realspace_intensity: Optional[np.ndarray] calculation_time: float def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput: @@ -57,45 +30,51 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput # Calculate contribution of SLD if calculation.orientation == OrientationalDistribution.UNORIENTED: + print("Unoriented") + if calculation.output_type == OutputType.SLD_2D: raise NotImplementedError("2D scattering not implemented yet") # Try a different method, estimate the radial distribution n_r = 1000 n_r_upscale = 10000 - bin_edges = np.linspace(0,2*calculation.spatial_sampling_method.radius, n_r+1) - bin_size = 2*calculation.spatial_sampling_method.radius / n_r + bin_edges = np.linspace(0, calculation.spatial_sampling_method.radius, n_r+1) + bin_size = calculation.spatial_sampling_method.radius / n_r - sld_total = None + sld = None counts = None + sld_total = 0 - for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method.pairs(calculation.sample_chunk_size_hint): + for x0, y0, z0 in calculation.spatial_sampling_method.singles(calculation.sample_chunk_size_hint): # evaluate sld input_coordinates1 = calculation.sld_function_from_cartesian(x0, y0, z0) - input_coordinates2 = calculation.sld_function_from_cartesian(x1, y1, z1) + # input_coordinates2 = calculation.sld_function_from_cartesian(x1, y1, z1) sld1 = calculation.sld_function(*input_coordinates1, **calculation.sld_function_parameters) sld1 -= calculation.solvent_sld # - sld2 = calculation.sld_function(*input_coordinates2, **calculation.sld_function_parameters) - sld2 -= calculation.solvent_sld - - rho = sld1*sld2 - # rho = sld1 + # sld2 = calculation.sld_function(*input_coordinates2, **calculation.sld_function_parameters) + # sld2 -= calculation.solvent_sld + # + # rho = sld1*sld2 + rho = sld1 # Do the integration - sample_rs = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) - # sample_rs = np.sqrt(x0**2 + y0**2 + z0**2) + # sample_rs = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) + sample_rs = np.sqrt(x0**2 + y0**2 + z0**2) + # sample_rs = np.abs(np.sqrt(x0**2 + y0**2 + z0**2) - np.sqrt(x1**2 + y1**2 + z1**2)) - if sld_total is None: - sld_total = np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] + if sld is None: + sld = np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] counts = np.histogram(sample_rs, bins=bin_edges)[0] else: - sld_total += np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] + sld += np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] counts += np.histogram(sample_rs, bins=bin_edges)[0] - if counts is None or sld_total is None: + sld_total += np.sum(sld1) #+ np.sum(sld2) + + if counts is None or sld is None: raise ValueError("No sample points") @@ -104,33 +83,61 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput # print(np.count_nonzero(non_empty_bins)) + # Calculate the mean sld at each radius r_small = (bin_edges[:-1] + 0.5 * bin_size)[non_empty_bins] - averages = sld_total[non_empty_bins] / counts[non_empty_bins] + sld_average = sld[non_empty_bins] / counts[non_empty_bins] + - r_large = np.arange(1, n_r_upscale + 1) * (2*calculation.spatial_sampling_method.radius / n_r_upscale) + # Upscale + r_upscaled = np.arange(0, n_r_upscale+1) * (calculation.spatial_sampling_method.radius / n_r_upscale) + # bin_centres = 0.5*(r_upscaled[1:] + r_upscaled[:-1]) + upscaled_sld_average = np.interp(r_upscaled, r_small, sld_average) - # Upscale, and weight by 1/r^2 - new_averages = np.interp(r_large, r_small, averages) - + # # Do transform + # + q = calculation.q_sampling_method() - qr = np.outer(q, r_large) + qr = np.outer(q, r_upscaled[1:]) + # + # # Power of q must be -1 for correct slope at low q + # f = np.sum((new_averages * (r_large * r_large)) * np.sinc(qr/np.pi), axis=1) # Correct for sphere with COM sampling - # Power of q must be -1 for correct slope at low q + # Change in sld for each bin + deltas = np.diff(upscaled_sld_average) - # f = np.sum((new_averages * (r_large * r_large)) * np.sin(qr) / qr, axis=1) # Correct for sphere with COM sampling - f = np.sum((new_averages * (r_large ** 3)) * np.sin(qr) / qr, axis=1) # Correct for sphere with COM sampling - # f = np.sum((new_averages * (r_large ** 4)) * np.sin(qr) / qr, axis=1) # Correct for sphere with COM sampling - # f = np.sum((new_averages*r_large*r_large) * np.sin(qr) / (qr**3), axis=1) - # f = np.sum((new_averages * r_large) * np.sin(qr) / qr, axis=1) - # f = np.sum(new_averages * np.sin(qr) / qr, axis=1) - # f = np.sum(new_averages / r_large * np.sin(qr) / qr, axis=1) - # f = np.sum(new_averages / (r_large**2) * np.sin(qr) / qr, axis=1) + # r_large is the right hand bin entry - intensity = f*f # Correct for sphere with COM sampling - # intensity = np.abs(f) - # intensity = f + factors = (np.sin(qr) - qr * np.cos(qr)) / (qr ** 3) + # + # import matplotlib.pyplot as plt + # start_count = 10 + # for i, delta in enumerate(deltas): + # if i%10 == 0: + # if delta != 0: + # f = np.sum(deltas[:i] * (r_upscaled[1:i+1]**2) * factors[:, :i], axis=1) + # f /= f[0] + # if start_count > 0: + # plt.loglog(q, f*f, color='r') + # else: + # plt.loglog(q, f*f, color='k') + # start_count -= 1 + # plt.show() + + f = np.sum(deltas * factors, axis=1) + # f = np.sum((deltas * (r_upscaled[1:]**2)) * factors, axis=1) + + # Value at qr=0 + # mean_density = sld_total / (2*calculation.spatial_sampling_method.n_actual) # 2 because we sampled two points above + # f0 = mean_density * calculation.spatial_sampling_method.sample_volume() + + + intensity = f**2 # Correct for sphere with COM sampling + + intensity /= calculation.spatial_sampling_method.n_actual + + # intensity += 1e-12 # Calculate magnet contribution # TODO: implement magnetic scattering @@ -145,12 +152,87 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput spatial_sampling_method=calculation.spatial_sampling_method, intensity_data=intensity, calculation_time=calculation_time, - r_values=r_large, - realspace_intensity=new_averages) + r_values=r_upscaled, + realspace_intensity=upscaled_sld_average) elif calculation.orientation == OrientationalDistribution.FIXED: - raise NotImplementedError("Oriented particle not implemented yet") + print("Oriented") + + if calculation.output_type == OutputType.SLD_2D: + raise NotImplementedError("2D scattering not implemented yet") + + # Try a different method, estimate the radial distribution + + + sld = None + counts = None + sld_total = 0 + q = calculation.q_sampling_method() + + n_r = 1000 + n_r_upscale = 10000 + bin_edges = np.linspace(0, calculation.spatial_sampling_method.radius, n_r + 1) + bin_size = calculation.spatial_sampling_method.radius / n_r + + for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method.pairs(calculation.sample_chunk_size_hint): + + # evaluate sld + input_coordinates1 = calculation.sld_function_from_cartesian(x0, y0, z0) + input_coordinates2 = calculation.sld_function_from_cartesian(x1, y1, z1) + + sld1 = calculation.sld_function(*input_coordinates1, **calculation.sld_function_parameters) + sld1 -= calculation.solvent_sld + + sld2 = calculation.sld_function(*input_coordinates2, **calculation.sld_function_parameters) + sld2 -= calculation.solvent_sld + + rho = sld1*sld2 + + r_xy = np.sqrt((x1-x0)**2 + (y1-y0)**2) + + if sld is None: + sld = np.histogram(r_xy, bins=bin_edges, weights=rho)[0] + counts = np.histogram(r_xy, bins=bin_edges)[0] + else: + sld += np.histogram(r_xy, bins=bin_edges, weights=rho)[0] + counts += np.histogram(r_xy, bins=bin_edges)[0] + + sld_total += np.sum(sld1) + np.sum(sld2) + + if counts is None or sld is None: + raise ValueError("No sample points") + + # Value at qr=zero + f /= calculation.spatial_sampling_method.n_actual + mean_density = sld_total / calculation.spatial_sampling_method.n_actual + f0 = mean_density / calculation.spatial_sampling_method.sample_volume() + + + # intensity = f*f # Correct for sphere with COM sampling + # intensity = np.real(f) + intensity = f + f0 + + # intensity = np.real(fft) + + # intensity = (f + f0)**2 + + + # Calculate magnet contribution + # TODO: implement magnetic scattering + + # Wrap up + + calculation_time = time.time() - start_time + + return ScatteringOutput( + output_type=calculation.output_type, + q_sampling_method=calculation.q_sampling_method, + spatial_sampling_method=calculation.spatial_sampling_method, + intensity_data=intensity, + calculation_time=calculation_time, + r_values=None, + realspace_intensity=None) From 1e4b957e8f740fa1e246cff147d9b39db9c6086a Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sat, 10 Jun 2023 21:01:16 +0100 Subject: [PATCH 19/38] Parameter handling --- .../ParticleEditor/DesignWindow.py | 59 +++-- .../ParameterEntries.py | 91 +++++++ .../ParameterFunctionality/ParameterTable.py | 94 +++++++ .../ParameterTableButtons.py | 9 + .../ParameterTableModel.py | 250 ++++++++++++++++++ .../UI/ParameterTableButtonsUI.ui | 61 +++++ .../ParameterFunctionality/__init__.py | 0 .../ParticleEditor/ParameterTable.py | 4 - .../ParticleEditor/UI/DesignWindowUI.ui | 25 +- .../ParticleEditor/datamodel/calculation.py | 21 +- .../ParticleEditor/datamodel/parameters.py | 64 ++++- .../ParticleEditor/datamodel/sampling.py | 3 +- .../Perspectives/ParticleEditor/scattering.py | 19 +- 13 files changed, 625 insertions(+), 75 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterEntries.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTable.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableModel.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/UI/ParameterTableButtonsUI.ui create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/__init__.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/ParameterTable.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 2af0bb3806..b9ed3fe5f4 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -24,8 +24,11 @@ from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import QSample +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableModel import ParameterTableModel +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTable import ParameterTable + from sas.qtgui.Perspectives.ParticleEditor.scattering import ( - OutputType, OrientationalDistribution, ScatteringCalculation, calculate_scattering) + OrientationalDistribution, ScatteringCalculation, calculate_scattering) from sas.qtgui.Perspectives.ParticleEditor.util import format_time_estimate @@ -83,6 +86,14 @@ def __init__(self, parent=None): hbox.addWidget(self.functionViewer) self.definitionTab.setLayout(hbox) + # + # Parameters Tab + # + + self._parameterTableModel = ParameterTableModel() + self.parametersTable = ParameterTable(self._parameterTableModel) + + # # Ensemble tab @@ -343,29 +354,29 @@ def _scatteringCalculation(self): orientation = OrientationalDistribution.FIXED else: raise ValueError("Unknown index for orientation combo") - - output_type = None - if self.output1D.isChecked(): - output_type = OutputType.SLD_1D - elif self.output2D.isChecked(): - output_type = OutputType.SLD_2D - - if output_type is None: - raise ValueError("Uknown index for output type combo") - - return ScatteringCalculation( - solvent_sld=self.solvent_sld, - orientation=orientation, - output_type=output_type, - spatial_sampling_method=self.spatialSampling, - q_sampling_method=self.qSampling, - sld_function=self.sld_function, - sld_function_from_cartesian=self.sld_coordinate_mapping, - sld_function_parameters={}, - magnetism_function=None, - magnetism_function_from_cartesian=None, - magnetism_function_parameters=None, - magnetism_vector=None) + # + # output_type = None + # if self.output1D.isChecked(): + # output_type = OutputType.SLD_1D + # elif self.output2D.isChecked(): + # output_type = OutputType.SLD_2D + # + # if output_type is None: + # raise ValueError("Uknown index for output type combo") + # + # return ScatteringCalculation( + # solvent_sld=self.solvent_sld, + # orientation=orientation, + # output_type=output_type, + # spatial_sampling_method=self.spatialSampling, + # q_sampling_method=self.qSampling, + # sld_function=self.sld_function, + # sld_function_from_cartesian=self.sld_coordinate_mapping, + # sld_function_parameters={}, + # magnetism_function=None, + # magnetism_function_from_cartesian=None, + # magnetism_function_parameters=None, + # magnetism_vector=None) def main(): """ Demo/testing window""" 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..b28cb61b3c --- /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() \ 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/ParameterTable.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterTable.py deleted file mode 100644 index 303f213802..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterTable.py +++ /dev/null @@ -1,4 +0,0 @@ -from PySide6 import QtWidgets - -class ParameterTable(QtWidgets.QTableWidget): - pass \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index b1a420dd43..dd86b7dccc 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -20,7 +20,7 @@ - 0 + 1 @@ -33,28 +33,7 @@ - - - - - 1 - - - 6 - - - false - - - - - - - - - - - + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index f6650a3eee..cc8822fd0a 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -4,7 +4,9 @@ from dataclasses import dataclass from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample -from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import SLDFunction, MagnetismFunction, CoordinateSystemTransform +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import ( + SLDFunction, MagnetismFunction, CoordinateSystemTransform) + class QSample: """ Representation of Q Space sampling """ @@ -57,8 +59,6 @@ class OrientationalDistribution(Enum): UNORIENTED = "Unoriented" - - @dataclass class SLDDefinition: """ Definition of the SLD scalar field""" @@ -83,12 +83,25 @@ class ParticleDefinition: @dataclass class ScatteringCalculation: """ Specification for a scattering calculation """ - solvent_sld: float output_options: OutputOptions orientation: OrientationalDistribution spatial_sampling_method: SpatialSample particle_definition: ParticleDefinition magnetism_vector: Optional[np.ndarray] + seed: Optional[int] + bounding_surface_sld_check: bool sample_chunk_size_hint: int = 100_000 +@dataclass +class ScatteringOutput: + output_type: OutputOptions + q_sampling_method: QSample + spatial_sampling_method: SpatialSample + intensity_data: np.ndarray + r_values: Optional[np.ndarray] + realspace_intensity: Optional[np.ndarray] + calculation_time: float + seed_used: int + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py index f492339a86..b926e8b357 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py @@ -1,15 +1,63 @@ +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): - """ Representation of an input parameter to the sld""" 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""" @@ -29,4 +77,18 @@ 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/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py index 1c3c387f83..f845984e61 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py @@ -1,8 +1,9 @@ from abc import ABC, abstractmethod -import numpy as np from typing import Tuple from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 + + class SpatialSample(ABC): """ Base class for spatial sampling methods""" def __init__(self, n_points_desired, radius): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py index 850e35c66f..6b0b205cb7 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py @@ -8,19 +8,8 @@ from scipy.special import jv as besselJ from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - QSample, ZSample, OutputType, OrientationalDistribution, ScatteringCalculation) + QSample, OrientationalDistribution, ScatteringCalculation, ScatteringOutput) -from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample - -@dataclass -class ScatteringOutput: - output_type: OutputType - q_sampling_method: QSample - spatial_sampling_method: SpatialSample - intensity_data: np.ndarray - r_values: Optional[np.ndarray] - realspace_intensity: Optional[np.ndarray] - calculation_time: float def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput: """ Main function for calculating scattering""" @@ -32,9 +21,6 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput print("Unoriented") - if calculation.output_type == OutputType.SLD_2D: - raise NotImplementedError("2D scattering not implemented yet") - # Try a different method, estimate the radial distribution n_r = 1000 n_r_upscale = 10000 @@ -160,9 +146,6 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput print("Oriented") - if calculation.output_type == OutputType.SLD_2D: - raise NotImplementedError("2D scattering not implemented yet") - # Try a different method, estimate the radial distribution From 746f1e63c9b52fbdced9c4637e19e1c81e93686e Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sun, 11 Jun 2023 00:21:06 +0100 Subject: [PATCH 20/38] Refactoring of calculation definition into objects almost done --- .../ParticleEditor/DesignWindow.py | 160 ++++++++++-------- .../ParameterTableButtons.py | 2 +- .../ParticleEditor/UI/DesignWindowUI.ui | 136 ++++++++------- .../ParticleEditor/datamodel/calculation.py | 4 +- .../ParticleEditor/datamodel/sampling.py | 6 + 5 files changed, 176 insertions(+), 132 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index b9ed3fe5f4..8f70daaa14 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -5,6 +5,7 @@ import numpy as np from PySide6 import QtWidgets +from PySide6.QtWidgets import QSpacerItem, QSizePolicy from PySide6.QtCore import Qt from sas.qtgui.Perspectives.ParticleEditor.FunctionViewer import FunctionViewer @@ -22,10 +23,12 @@ from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( SpatialSample, RandomSampleSphere, RandomSampleCube) -from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import QSample +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + QSample, ZSample, ScatteringCalculation, OutputOptions, CalculationParameters, ParticleDefinition) 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.scattering import ( OrientationalDistribution, ScatteringCalculation, calculate_scattering) @@ -33,6 +36,7 @@ class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow): + """ Main window for the particle editor""" def __init__(self, parent=None): super().__init__() @@ -92,8 +96,11 @@ def __init__(self, parent=None): 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 @@ -121,7 +128,6 @@ def __init__(self, parent=None): self.methodCombo.currentIndexChanged.connect(self.updateSpatialSampling) self.sampleRadius.valueChanged.connect(self.updateSpatialSampling) self.nSamplePoints.valueChanged.connect(self.updateSpatialSampling) - self.randomSeed.textChanged.connect(self.updateSpatialSampling) self.fixRandomSeed.clicked.connect(self.updateSpatialSampling) self.nSamplePoints.valueChanged.connect(self.onTimeEstimateParametersChanged) @@ -159,18 +165,11 @@ def __init__(self, parent=None): # Populate tables - # Columns should be name, value, min, max, fit, [remove] - self.parametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) - self.parametersTable.horizontalHeader().setStretchLastSection(True) - self.structureFactorParametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) self.structureFactorParametersTable.horizontalHeader().setStretchLastSection(True) # Set up variables - self.spatialSampling: SpatialSample = self._spatialSampling() - self.qSampling: QSample = self._qSampling() - self.last_calculation_time: Optional[float] = None self.last_calculation_n_r: int = 0 self.last_calculation_n_q: int = 0 @@ -232,6 +231,9 @@ def doBuild(self): if function is None: return False + # TODO: Magnetism + self.parametersTable.update_contents(function, None) + # Vectorise if needed maybe_vectorised = vectorise_sld( @@ -246,6 +248,7 @@ def doBuild(self): 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}") @@ -255,6 +258,85 @@ def doBuild(self): self.codeError(e.args[0]) return False + def outputOptions(self) -> OutputOptions: + """ Get the OutputOptions object representing the desired outputs from the calculation """ + pass + + + def orientationalDistribution(self) -> OrientationalDistribution: + """ Get the OrientationalDistribution object that represents the GUI selected orientational distribution""" + orientation_index = self.orientationCombo.currentIndex() + + if orientation_index == 0: + orientation = OrientationalDistribution.UNORIENTED + elif orientation_index == 1: + orientation = OrientationalDistribution.FIXED + else: + raise ValueError("Unknown index for orientation combo") + + return orientation + + def updateSpatialSampling(self): + """ Update the spatial sampling object """ + self.spatialSampling = self._spatialSampling() + self.sampleDetails.setText(self.spatialSampling.sampling_details()) + # print(self.spatialSampling) + + def spatialSampling(self) -> SpatialSample: + """ 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_desired = int(self.nSamplePoints.value()) + seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None + + if sample_type == 0: + return RandomSampleSphere(radius=radius, n_points_desired=n_desired, seed=seed) + + elif sample_type == 1: + return RandomSampleCube(radius=radius, n_points_desired=n_desired, seed=seed) + + else: + raise ValueError("Unknown index for spatial sampling method combo") + + def particleDefinition(self) -> ParticleDefinition: + """ Get the ParticleDefinition object that contains the SLD and magnetism functions """ + + def parametersForCalculation(self) -> CalculationParameters: + pass + + def polarisationVector(self) -> np.ndarray: + """ Get a numpy vector representing the GUI specified polarisation vector""" + pass + + 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 to """ + output_options = self.outputOptions() + orientation = self.orientationalDistribution() + spatial_sampling = self.spatialSampling() + particle_definition = self.particleDefinition() + parameter_definition = self.parametersForCalculation() + polarisation_vector = self.polarisationVector() + seed = self.currentSeed() + bounding_surface_check = self.continuityCheck.isChecked() + + return ScatteringCalculation( + output_options=output_options, + orientation=orientation, + 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""" @@ -312,7 +394,7 @@ def updateQSampling(self): self.qSampling = self._qSampling() print(self.qSampling) # TODO: Remove - def _qSampling(self) -> QSample: + 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()) @@ -321,62 +403,6 @@ def _qSampling(self) -> QSample: return QSample(min_q, max_q, n_samples, is_log) - def updateSpatialSampling(self): - """ Update the spatial sampling object """ - self.spatialSampling = self._spatialSampling() - self.sampleDetails.setText(self.spatialSampling.sampling_details()) - # print(self.spatialSampling) - - def _spatialSampling(self) -> SpatialSample: - """ 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_desired = int(self.nSamplePoints.value()) - seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None - - if sample_type == 0: - return RandomSampleSphere(radius=radius, n_points_desired=n_desired, seed=seed) - - elif sample_type == 1: - return RandomSampleCube(radius=radius, n_points_desired=n_desired, seed=seed) - - else: - raise ValueError("Unknown index for spatial sampling method combo") - - def _scatteringCalculation(self): - orientation_index = self.orientationCombo.currentIndex() - - if orientation_index == 0: - orientation = OrientationalDistribution.UNORIENTED - elif orientation_index == 1: - orientation = OrientationalDistribution.FIXED - else: - raise ValueError("Unknown index for orientation combo") - # - # output_type = None - # if self.output1D.isChecked(): - # output_type = OutputType.SLD_1D - # elif self.output2D.isChecked(): - # output_type = OutputType.SLD_2D - # - # if output_type is None: - # raise ValueError("Uknown index for output type combo") - # - # return ScatteringCalculation( - # solvent_sld=self.solvent_sld, - # orientation=orientation, - # output_type=output_type, - # spatial_sampling_method=self.spatialSampling, - # q_sampling_method=self.qSampling, - # sld_function=self.sld_function, - # sld_function_from_cartesian=self.sld_coordinate_mapping, - # sld_function_parameters={}, - # magnetism_function=None, - # magnetism_function_from_cartesian=None, - # magnetism_function_parameters=None, - # magnetism_vector=None) def main(): """ Demo/testing window""" diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py index b28cb61b3c..3fb0c2db5d 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py @@ -6,4 +6,4 @@ class ParameterTableButtons(QWidget, Ui_ParameterTableButtons): def __init__(self): super().__init__() - self.setupUi() \ No newline at end of file + self.setupUi(self) \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index dd86b7dccc..1b053155d5 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -7,7 +7,7 @@ 0 0 994 - 370 + 484 @@ -20,7 +20,7 @@ - 1 + 0 @@ -33,7 +33,7 @@ - + @@ -236,7 +236,17 @@ - + + + + Logaritmic + + + true + + + + 10 @@ -252,14 +262,14 @@ - + Ang - + Ang @@ -276,14 +286,7 @@ - - - - Sample Radius - - - - + @@ -313,20 +316,10 @@ - - - - Q Max - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - + + - 0.5 + Sample Radius @@ -340,7 +333,7 @@ - + 0.0005 @@ -357,30 +350,24 @@ - - - - - + + - - - - Qt::AlignCenter + 0.5 - - + + - Logaritmic + Q Max - - true + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - + 0 @@ -404,6 +391,9 @@ + + + @@ -414,17 +404,7 @@ - - - - Sample Points - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - + @@ -448,7 +428,17 @@ - + + + + Sample Points + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + @@ -466,6 +456,13 @@ + + + + Neutron Polarisation + + + @@ -476,14 +473,7 @@ - - - - Neutron Polarisation - - - - + @@ -508,6 +498,26 @@ + + + + + + + Qt::AlignCenter + + + + + + + Check sampling boundaru for SLD continuity + + + true + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index cc8822fd0a..617325f939 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -6,6 +6,7 @@ from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import ( SLDFunction, MagnetismFunction, CoordinateSystemTransform) +from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import CalculationParameters class QSample: @@ -87,7 +88,8 @@ class ScatteringCalculation: orientation: OrientationalDistribution spatial_sampling_method: SpatialSample particle_definition: ParticleDefinition - magnetism_vector: Optional[np.ndarray] + parameter_settings: CalculationParameters + polarisation_vector: Optional[np.ndarray] seed: Optional[int] bounding_surface_sld_check: bool sample_chunk_size_hint: int = 100_000 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py index f845984e61..dde3a1504e 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py @@ -16,6 +16,7 @@ def _calculate_n_actual(self) -> int: @property def n_actual(self) -> int: + """ Actual number of sample points (this might differ from the input number of points)""" return self._calculate_n_actual() @abstractmethod @@ -31,6 +32,11 @@ def pairs(self, size_hint: int) -> Tuple[VectorComponents3, VectorComponents3]: def singles(self, size_hint: int) -> VectorComponents3: """ Sample points """ + @abstractmethod + def boundingSurfaceCheckPoints(self) -> VectorComponents3: + """ Points that are used to check that the SLD is consistent + with the solvent SLD at the edge of the sampling space""" + def __repr__(self): return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) From 2c127d6544e8f826aa97d7001f1e67a312c1b9a4 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 12 Jun 2023 04:03:32 +0100 Subject: [PATCH 21/38] More work on data structures --- .../ParticleEditor/UI/DesignWindowUI.ui | 4 +- .../ParticleEditor/boundary_check.py | 40 ++++++++++ .../ParticleEditor/calculations.py | 74 +++++++++++++++++++ .../ParticleEditor/datamodel/calculation.py | 18 ++--- .../ParticleEditor/datamodel/sampling.py | 38 +++++++++- 5 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/boundary_check.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 1b053155d5..b599b08ecc 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -20,7 +20,7 @@ - 0 + 3 @@ -511,7 +511,7 @@ - Check sampling boundaru for SLD continuity + Check sampling boundary for SLD continuity true diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/boundary_check.py b/src/sas/qtgui/Perspectives/ParticleEditor/boundary_check.py new file mode 100644 index 0000000000..02c60e81bb --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/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.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py new file mode 100644 index 0000000000..3db4f2851d --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py @@ -0,0 +1,74 @@ +from typing import Optional, Tuple +import numpy as np +import time + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + ScatteringCalculation, ScatteringOutput, OrientationalDistribution) + + +def calculate_scattering(calculation: ScatteringCalculation): + """ Main scattering calculation """ + + start_time = time.time() # Track how long it takes + + # What things do we need to calculate + options = calculation.output_options + fixed_orientation = calculation.orientation == OrientationalDistribution.FIXED + no_orientation = calculation.orientation == OrientationalDistribution.UNORIENTED + + # Radial SLD distribution - a special plot - doesn't relate to the other things + do_radial_distribution = options.radial_distribution + + # Radial correlation based on distance in x, y and z - this is the quantity that matters for + # unoriented particles + do_r_xyz_correlation = no_orientation and (options.q_space or options.q_space_2d or options.sesans) + + # Radial correlation based on distance in x, y - this is the quantity that matters for 2D + do_r_xy_correlation = fixed_orientation and options.q_space + + # XY correlation - this is what we need for standard 2D SANS + do_xy_correlations = fixed_orientation and options.q_space_2d + + # Z correlation - this is what we need for SESANS when the particles are oriented + do_z_correlations = fixed_orientation and options.sesans + + # + # Set up output variables + # + + # Define every output as None initially and update if the calculation is required + radial_distribution: Optional[Tuple[np.ndarray, np.ndarray]] = None + r_xyz_correlation: Optional[Tuple[np.ndarray, np.ndarray]] = None + q_space: Optional[Tuple[np.ndarray, np.ndarray]] = None + q_space_2d: Optional[Tuple[np.ndarray, np.ndarray]] = None + sesans: Optional[Tuple[np.ndarray, np.ndarray]] = None + + + + if calculation.seed is None: + seed = None + else: + seed = calculation.seed + + if calculation.orientation == OrientationalDistribution.UNORIENTED: + # Unoriented System + pass + + elif calculation.orientation == OrientationalDistribution.FIXED: + # Oriented System + pass + + else: + raise ValueError("Unknown orientational distribution:", calculation.orientation) + + return ScatteringOutput( + radial_distribution=radial_distribution, + radial_correlation=radial_correlation, + p_of_r=p_of_r, + q_space=q_space, + q_space_2d=q_space_2d, + sesans=sesans, + sesans_2d=sesans_2d, + calculation_time=time.time() - start_time, + seed_used=seed) + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index 617325f939..547bdb952c 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -46,16 +46,16 @@ def __call__(self): @dataclass class OutputOptions: - radial_distribution: Optional = None - radial_correlation: Optional = None - p_of_r: Optional = None + """ Options """ + radial_distribution: bool # Create a radial distribution function from the origin + realspace: bool # Return realspace data q_space: Optional[QSample] = None q_space_2d: Optional[QSample] = None sesans: Optional[ZSample] = None - sesans_2d: Optional[ZSample] = None class OrientationalDistribution(Enum): + """ Types of orientation supported """ FIXED = "Fixed" UNORIENTED = "Unoriented" @@ -97,12 +97,10 @@ class ScatteringCalculation: @dataclass class ScatteringOutput: - output_type: OutputOptions - q_sampling_method: QSample - spatial_sampling_method: SpatialSample - intensity_data: np.ndarray - r_values: Optional[np.ndarray] - realspace_intensity: Optional[np.ndarray] + radial_distribution: Optional[Tuple[np.ndarray, np.ndarray]] + q_space: Optional[Tuple[np.ndarray, np.ndarray]] + q_space_2d: Optional[Tuple[np.ndarray, np.ndarray]] + sesans: Optional[Tuple[np.ndarray, np.ndarray]] calculation_time: float seed_used: int diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py index dde3a1504e..54c71d8953 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py @@ -27,16 +27,50 @@ def sampling_details(self) -> str: def pairs(self, size_hint: int) -> Tuple[VectorComponents3, VectorComponents3]: """ Pairs of sample points """ - @abstractmethod def singles(self, size_hint: int) -> VectorComponents3: """ Sample points """ @abstractmethod - def boundingSurfaceCheckPoints(self) -> VectorComponents3: + def bounding_surface_check_points(self) -> VectorComponents3: """ Points that are used to check that the SLD is consistent with the solvent SLD at the edge of the sampling space""" + @abstractmethod + def max_xyz(self): + """ maximum distance between in points in X, Y and Z + + For non-oriented scattering + """ + + @abstractmethod + def max_xy(self): + """ Maximum distance between points in X,Y projection + + For Oriented 1D SANS + """ + + @abstractmethod + def max_x(self): + """ Maximum distance between points in X projection + + For Oriented 2D SANS + """ + + @abstractmethod + def max_y(self): + """ Maximum distance between points in Y projection + + For Oriented 2D SANS + """ + + @abstractmethod + def max_z(self): + """ Maximum distance between points in Z projection + + For Oriented SESANS + """ + def __repr__(self): return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) From 9122b3040b18fdef191d09f8df88ce2df48d9e2c Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sat, 17 Jun 2023 22:09:20 +0100 Subject: [PATCH 22/38] New sampling methods --- .../ParticleEditor/DesignWindow.py | 6 +- .../ParticleEditor/UI/DesignWindowUI.ui | 2 +- .../ParticleEditor/calculations.py | 172 ++++++- .../ParticleEditor/datamodel/calculation.py | 7 +- .../ParticleEditor/datamodel/sampling.py | 42 +- .../ParticleEditor/datamodel/types.py | 2 +- .../ParticleEditor/sampling_method_tests.py | 112 +++++ .../ParticleEditor/sampling_methods.py | 476 +++++++++++++++--- 8 files changed, 690 insertions(+), 129 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 8f70daaa14..e1cf3d93ed 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -21,7 +21,7 @@ from sas.qtgui.Perspectives.ParticleEditor.vectorise import vectorise_sld from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( - SpatialSample, RandomSampleSphere, RandomSampleCube) + SpatialSample, BiasedSampleSphere, BiasedSampleCube) from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( QSample, ZSample, ScatteringCalculation, OutputOptions, CalculationParameters, ParticleDefinition) @@ -292,10 +292,10 @@ def spatialSampling(self) -> SpatialSample: seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None if sample_type == 0: - return RandomSampleSphere(radius=radius, n_points_desired=n_desired, seed=seed) + return BiasedSampleSphere(radius=radius, n_points_desired=n_desired, seed=seed) elif sample_type == 1: - return RandomSampleCube(radius=radius, n_points_desired=n_desired, seed=seed) + return BiasedSampleCube(radius=radius, n_points_desired=n_desired, seed=seed) else: raise ValueError("Unknown index for spatial sampling method combo") diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index b599b08ecc..2b980c3237 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -20,7 +20,7 @@ - 3 + 0 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py index 3db4f2851d..df75ea944f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py @@ -21,54 +21,174 @@ def calculate_scattering(calculation: ScatteringCalculation): # Radial correlation based on distance in x, y and z - this is the quantity that matters for # unoriented particles - do_r_xyz_correlation = no_orientation and (options.q_space or options.q_space_2d or options.sesans) + do_r_xyz_correlation = no_orientation and (options.q_space is not None or options.q_space_2d is not None or options.sesans is not None) # Radial correlation based on distance in x, y - this is the quantity that matters for 2D - do_r_xy_correlation = fixed_orientation and options.q_space + do_r_xy_correlation = fixed_orientation and options.q_space is not None # XY correlation - this is what we need for standard 2D SANS - do_xy_correlations = fixed_orientation and options.q_space_2d + do_xy_correlation = fixed_orientation and options.q_space_2d is not None # Z correlation - this is what we need for SESANS when the particles are oriented - do_z_correlations = fixed_orientation and options.sesans + do_z_correlation = fixed_orientation and options.sesans is not None # # Set up output variables # - # Define every output as None initially and update if the calculation is required - radial_distribution: Optional[Tuple[np.ndarray, np.ndarray]] = None - r_xyz_correlation: Optional[Tuple[np.ndarray, np.ndarray]] = None - q_space: Optional[Tuple[np.ndarray, np.ndarray]] = None - q_space_2d: Optional[Tuple[np.ndarray, np.ndarray]] = None - sesans: Optional[Tuple[np.ndarray, np.ndarray]] = None + n_bins = calculation.bin_count + sampling = calculation.spatial_sampling_method + radial_bin_edges = np.linspace(0, sampling.radius, n_bins+1) if do_radial_distribution else None + r_xyz_bin_edges = np.linspace(0, sampling.max_xyz(), n_bins+1) if do_r_xyz_correlation else None + r_xy_bin_edges = np.linspace(0, sampling.max_xy(), n_bins+1) if do_r_xy_correlation else None + xy_bin_edges = (np.linspace(0, sampling.max_x(), n_bins+1), + np.linspace(0, sampling.max_y(), n_bins+1)) if do_xy_correlation else None + radial_distribution = None + radial_counts = None + r_xyz_correlation = None + r_xyz_counts = None + r_xy_correlation = None + r_xy_counts = None + xy_correlation = None + xy_counts = None + + # + # Seed + # + + # TODO: This needs to be done properly if calculation.seed is None: - seed = None + seed = 0 else: seed = calculation.seed - if calculation.orientation == OrientationalDistribution.UNORIENTED: - # Unoriented System - pass + # + # Setup for calculation + # + + sld = calculation.particle_definition.sld + sld_parameters = calculation.parameter_settings.sld_parameters + + magnetism = calculation.particle_definition.magnetism + magnetism_parameters = calculation.parameter_settings.magnetism_parameters + + total_square_sld = 0.0 + + for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method.pairs(calculation.sample_chunk_size_hint): + + # + # Sample the SLD + # + + # TODO: Make sure global variables are accessible to the function calls + sld0 = sld.sld_function(*sld.to_cartesian_conversion(x0, y0, z0), **sld_parameters) + sld1 = sld.sld_function(*sld.to_cartesian_conversion(x1, y1, z1), **sld_parameters) + + rho = sld0 * sld1 + + # + # Build the appropriate histograms + # + + if do_radial_distribution: + + r = np.sqrt(x0**2 + y0**2 + z0**2) + + if radial_distribution is None: + radial_distribution = np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] + radial_counts = np.histogram(r, bins=radial_bin_edges)[0] + + else: + radial_distribution += np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] + radial_counts += np.histogram(r, bins=radial_bin_edges)[0] + + if do_r_xyz_correlation: + + r_xyz = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2) + + if r_xyz_correlation is None: + r_xyz_correlation = np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] + r_xyz_counts = np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] + + else: + r_xyz_correlation += np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] + r_xyz_counts += np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] + + if do_r_xy_correlation: + + r_xy = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) - elif calculation.orientation == OrientationalDistribution.FIXED: - # Oriented System - pass + if r_xy_correlation is None: + r_xy_correlation = np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] + r_xy_counts = np.histogram(r_xy, bins=r_xy_bin_edges)[0] + else: + r_xy_correlation += np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] + r_xy_counts += np.histogram(r_xy, bins=r_xy_bin_edges)[0] + + if do_xy_correlation: + + x = x1 - x0 + y = y1 - y0 + + if xy_correlation is None: + xy_correlation = np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] + xy_counts = np.histogram2d(x, y, bins=xy_bin_edges)[0] + + else: + xy_correlation += np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] + xy_counts += np.histogram2d(x, y, bins=xy_bin_edges)[0] + + if do_z_correlation: + raise NotImplementedError("Z correlation not implemented yet") + + # + # Mean SLD squared, note we have two samples here + # + + total_square_sld += np.sum(sld0**2 + sld1**2) + + # + # Calculate scattering from the histograms + # + + if do_radial_distribution: + bin_centres = 0.5*(radial_bin_edges[1:] + radial_bin_edges[:-1]) + radial_distribution_output = (bin_centres, radial_distribution) else: - raise ValueError("Unknown orientational distribution:", calculation.orientation) + radial_distribution_output = None + + if no_orientation: + if options.q_space is not None: + q = calculation.output_options.q_space() + + bin_centres = r_xyz_bin_edges[1:] + r_xy_bin_edges[:-1] + + + + # We have to be very careful about how we do the numerical integration, specifically with respect to r=0 + qr = np.outer(q, r_xyz_correlation) + + np.sum(np.sinc(qr), axis=1) + + q_space = (q, ) + + if options.q_space_2d: + # USE: scipy.interpolate.CloughTocher2DInterpolator to do this + + pass + + # TODO: SESANS support + sesans_output = None + return ScatteringOutput( - radial_distribution=radial_distribution, - radial_correlation=radial_correlation, - p_of_r=p_of_r, - q_space=q_space, - q_space_2d=q_space_2d, - sesans=sesans, - sesans_2d=sesans_2d, + radial_distribution=radial_distribution_output, + q_space=None, + q_space_2d=None, + sesans=None, calculation_time=time.time() - start_time, seed_used=seed) - diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index 547bdb952c..71640f8708 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -92,15 +92,16 @@ class ScatteringCalculation: 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 ScatteringOutput: radial_distribution: Optional[Tuple[np.ndarray, np.ndarray]] - q_space: Optional[Tuple[np.ndarray, np.ndarray]] - q_space_2d: Optional[Tuple[np.ndarray, np.ndarray]] - sesans: Optional[Tuple[np.ndarray, np.ndarray]] + q_space: Optional[Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]] + q_space_2d: Optional[Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]] + sesans: Optional[Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]] calculation_time: float seed_used: int diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py index 54c71d8953..f6e804bb3a 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py @@ -6,30 +6,13 @@ class SpatialSample(ABC): """ Base class for spatial sampling methods""" - def __init__(self, n_points_desired, radius): - self._n_points_desired = n_points_desired + def __init__(self, n_points, radius): + self.n_points = n_points self.radius = radius @abstractmethod - def _calculate_n_actual(self) -> int: - """ Calculate the actual number of sample points, based on the desired number of points""" - - @property - def n_actual(self) -> int: - """ Actual number of sample points (this might differ from the input number of points)""" - return self._calculate_n_actual() - - @abstractmethod - def sampling_details(self) -> str: - """ A string describing the details of the sample points """ - - @abstractmethod - def pairs(self, size_hint: int) -> Tuple[VectorComponents3, VectorComponents3]: - """ Pairs of sample points """ - - @abstractmethod - def singles(self, size_hint: int) -> VectorComponents3: - """ Sample points """ + def __call__(self, size_hint: int) -> (VectorComponents3, VectorComponents3): + """ Get pairs of points """ @abstractmethod def bounding_surface_check_points(self) -> VectorComponents3: @@ -51,32 +34,33 @@ def max_xy(self): """ @abstractmethod + def max_principal_axis(self): + """ Maximum distance between points in any principal axis""" + + def max_x(self): """ Maximum distance between points in X projection For Oriented 2D SANS """ + return self.max_principal_axis() - @abstractmethod def max_y(self): """ Maximum distance between points in Y projection For Oriented 2D SANS """ + return self.max_principal_axis() - @abstractmethod def max_z(self): """ Maximum distance between points in Z projection - For Oriented SESANS + For completeness """ + return self.max_principal_axis() def __repr__(self): - return "%s(n=%i,r=%g)" % (self.__class__.__name__, self._n_points_desired, self.radius) - - @abstractmethod - def start_location(self, size_hint: int) -> VectorComponents3: - """ Get the sample points """ + return "%s(n=%i,r=%g)" % (self.__class__.__name__, self.n_points, self.radius) @abstractmethod def sample_volume(self) -> float: diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py index d4ec63de53..f01a846f56 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py @@ -19,4 +19,4 @@ def __call__(self, a: np.ndarray, b: np.ndarray, c: np.ndarray, **kwargs: float) class CoordinateSystemTransform(Protocol): """ Type of functions that can represent a coordinate transform""" - def __call__(self, a: np.ndarray, b: np.ndarray, c: np.ndarray) -> VectorComponents3: ... \ No newline at end of file + 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/sampling_method_tests.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py new file mode 100644 index 0000000000..fa30d6f3ba --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py @@ -0,0 +1,112 @@ +import numpy as np +import matplotlib.pyplot as plt +from scipy.interpolate import interp1d +import time + +from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( + UniformCubeSample, BiasedSampleCube, BiasedSampleSphere) + +test_radius = 5 +sample_radius = 10 + +def octahedron(x,y,z): + inds = np.abs(x) + np.abs(y) + np.abs(z) <= test_radius + sld = np.zeros_like(x) + sld[inds] = 1.0 + + return sld + + +def cube(x,y,z): + + inds = np.logical_and( + np.abs(x) < test_radius, + np.logical_and( + np.abs(y) < test_radius, + np.abs(z) < test_radius)) + + sld = np.zeros_like(x) + sld[inds] = 1.0 + + return sld + + +def off_centre_cube(x,y,z): + + inds = np.logical_and( + np.abs(x+test_radius/2) < test_radius, + np.logical_and( + np.abs(y+test_radius/2) < test_radius, + np.abs(z+test_radius/2) < test_radius)) + + sld = np.zeros_like(x) + sld[inds] = 1.0 + + return sld + + +def sphere(x, y, z): + + inds = x**2 + y**2 + z**2 <= test_radius**2 + sld = np.zeros_like(x) + sld[inds] = 1.0 + + return sld + +test_functions = [cube, sphere, octahedron, off_centre_cube] + +bin_edges = np.linspace(0, np.sqrt(3)*sample_radius, 201) +bin_centre = 0.5*(bin_edges[1:] + bin_edges[:-1]) + +for test_function in test_functions: + + plt.figure("Test function " + test_function.__name__) + + for sampler_cls in (UniformCubeSample, BiasedSampleSphere, BiasedSampleCube): + + print(sampler_cls.__name__) + for repeat in range(3): + sampler = sampler_cls(n_points=1_000_000, radius=sample_radius) + + start_time = time.time() + + distro = None + counts = None + + for (x0, y0, z0), (x1, y1, z1) in sampler(size_hint=1000): + + sld0 = test_function(x0, y0, z0) + sld1 = test_function(x1, y1, z1) + + rho = sld0 * sld1 + + r = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2) + + if distro is None: + distro = np.histogram(r, bins=bin_edges, weights=rho)[0] + counts = np.histogram(r, bins=bin_edges)[0] + + else: + distro += np.histogram(r, bins=bin_edges, weights=rho)[0] + counts += np.histogram(r, bins=bin_edges)[0] + + + good_values = counts > 0 + + distro = distro[good_values].astype(float) + counts = counts[good_values] + bin_centres_good = bin_centre[good_values] + + distro /= counts + distro *= sampler.sample_volume() + + f = interp1d(bin_centres_good, distro, + kind='linear', bounds_error=False, fill_value=0, assume_sorted=True) + + + plt.plot(bin_centre, f(bin_centre), label=sampler.__class__.__name__+" %i"%(repeat+1)) + plt.legend() + + print("Time:", time.time() - start_time) + +plt.show() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py index 909910ee81..923e2ebced 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py @@ -3,109 +3,453 @@ import numpy as np from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 +class RandomSpatialSample(SpatialSample): + """ Base class for random sampling methods """ + def __init__(self, n_points: int, radius: float, seed: Optional[int] = None): + super().__init__(n_points, radius) + self._seed = seed + self.rng = np.random.default_rng(seed=seed) -class RandomSample(SpatialSample): - def __init__(self, n_points_desired: int, radius: float, seed: Optional[int] = None): - super().__init__(n_points_desired, radius) - self.seed = seed + @property + def seed(self): + return self._seed - def start_location(self, size_hint: int): - n_full = self._n_points_desired // size_hint - n_rest = self._n_points_desired % size_hint + @seed.setter + def seed(self, s): + self._seed = s + self.rng = np.random.default_rng(seed=s) - for i in range(n_full): - yield self.generate(size_hint) + def __repr__(self): + return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self.n_points, self.radius, str(self.seed)) - if n_rest > 0: - yield self.generate(n_rest) + @abstractmethod + def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: + """ Generate pairs of points, each uniformly distrubted over the sample space, with + their distance distributed uniformly - def pairs(self, size_hint): - n_full = self._n_points_desired // size_hint - n_rest = self._n_points_desired % size_hint + :returns: number of points generated, and the pairs of points""" - for i in range(n_full): - yield self.generate(size_hint), self.generate(size_hint) + def __call__(self, size_hint: int): + """ __call__ is a generator that goes through the points in chunks that aim to be a certain size + for efficiency, we do not require that the chunks are exactly the size requested""" - if n_rest > 0: - yield self.generate(n_rest), self.generate(n_rest) + n_remaining = self.n_points - def singles(self, size_hint: int): - n_full = self._n_points_desired // size_hint - n_rest = self._n_points_desired % size_hint + while n_remaining > 0: - for i in range(n_full): - yield self.generate(size_hint) + n, pairs = self.generate_pairs(min((n_remaining, size_hint))) - if n_rest > 0: - yield self.generate(n_rest) + if n <= n_remaining: + # Still plenty to go, return regardless of size + yield pairs + n_remaining -= n - @abstractmethod - def generate(self, n): - """ Generate n random points""" + else: + # We've made more points than we needed, just return enough to complete the requested amount + (x0, y0, z0), (x1, y1, z1) = pairs + yield (x0[:n_remaining], y0[:n_remaining], z0[:n_remaining]), \ + (x1[:n_remaining], y1[:n_remaining], z1[:n_remaining]) + n_remaining = 0 - def __repr__(self): - return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self._n_points_desired, self.radius, str(self.seed)) +class UniformCubeSample(RandomSpatialSample): + """ Uniformly sample pairs of points from a cube """ + def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: + """ Generate pairs of points, each within a cube""" -class RandomSampleSphere(RandomSample): - """ Rejection Random Sampler for a sphere with a given radius """ + pts = (2*self.radius) * (self.rng.random(size=(size_hint, 6)) - 0.5) - def _calculate_n_actual(self) -> int: - return self._n_points_desired + return size_hint, ((pts[:, 0], pts[:, 1], pts[:, 2]), (pts[:, 3], pts[:, 4], pts[:, 5])) - def sampling_details(self) -> str: - return "" - def sample_volume(self) -> float: - return (4*np.pi/3)*(self.radius**3) + def max_xyz(self): + """ Maximum distance between points in 3D - along the main diagonal""" + return 2 * self.radius * np.sqrt(3) - def generate(self, n): - # Sample within a sphere + def max_xy(self): + """ Maximum distance between points in 2D projection in an axis - along + the diagonal of a face (or equivalent)""" + return 2 * self.radius * np.sqrt(2) - # A sphere will occupy pi/6 of a cube, which is 0.5236 ish - # With rejection sampling we need to oversample by about a factor of 2 + def max_principal_axis(self): + """ Maximum distance between points along one axis - along one of the axes """ + return 2 * self.radius + def sample_volume(self): + """ Volume of sampling region - a cube """ + return 8*(self.radius**3) - target_n = n + def bounding_surface_check_points(self) -> VectorComponents3: + """Bounding box check points - output_data = [] - while target_n > 0: - xyz = np.random.random((int(1.91 * target_n)+1, 3)) - 0.5 + 8 corners + 12 edge centres + 6 face centres + """ - indices = np.sum(xyz**2, axis=1) <= 0.25 + corners = [ + [ self.radius, self.radius, self.radius], + [ self.radius, self.radius, -self.radius], + [ self.radius, -self.radius, self.radius], + [ self.radius, -self.radius, -self.radius], + [-self.radius, self.radius, self.radius], + [-self.radius, self.radius, -self.radius], + [-self.radius, -self.radius, self.radius], + [-self.radius, -self.radius, -self.radius]] - xyz = xyz[indices, :] + edge_centres = [ + [ self.radius, self.radius, 0 ], + [ self.radius, -self.radius, 0 ], + [-self.radius, self.radius, 0 ], + [-self.radius, -self.radius, 0 ], + [ self.radius, 0, self.radius], + [ self.radius, 0, -self.radius], + [-self.radius, 0, self.radius], + [-self.radius, 0, -self.radius], + [ 0, self.radius, self.radius], + [ 0, self.radius, -self.radius], + [ 0, -self.radius, self.radius], + [ 0, -self.radius, -self.radius]] - if xyz.shape[0] > target_n: - output_data.append(xyz[:target_n, :]) - target_n = 0 - else: - output_data.append(xyz) - target_n -= xyz.shape[0] + face_centres = [ + [ self.radius, 0, 0 ], + [ -self.radius, 0, 0 ], + [ 0, self.radius, 0 ], + [ 0, -self.radius, 0 ], + [ 0, 0, self.radius ], + [ 0, 0, -self.radius ]] + + check_points = np.concatenate((corners, edge_centres, face_centres), axis=0) + + return check_points[:, 0], check_points[:, 1], check_points[:, 2] + + +class DistanceBiasedSample(RandomSpatialSample): + """ Base class for samplers with a bias towards shorter distances""" + def cube_sample(self, n: int) -> np.ndarray: + """ Sample uniformly from a 2r side length cube, centred on the origin """ + + return (2*self.radius) * (self.rng.random(size=(n, 3)) - 0.5) + + def uniform_radial_sample(self, n: int) -> VectorComponents3: + """ Sample within the maximum possible radius, uniformly over the distance from the + origin (not a uniform distribution in space)""" + + xyz = self.rng.standard_normal(size=(n, 3)) + + scaling = self.max_xyz() * self.rng.random(size=n) / np.sqrt(np.sum(xyz**2, axis=1)) + + return xyz * scaling[:, np.newaxis] + +class BiasedSampleSphere(DistanceBiasedSample): + """ Sample over a sphere with a specified radius """ + + def generate_pairs(self, n): + """ Rejection sample pairs in a sphere + + The cube_sample method will generate valid start points with a probability (sphere + volume/cube volume) of 0.5235 + The uniform_radial_sample will generate deltas landing within the sample volume with + a probability (r radius sphere / 2r radius sphere) of 0.125 + + Overall then, the probability of not being rejected is 0.0654498 + so we need to sample 15.278874 times as many points than requested if we want to + get an expected number of points equal to the requested number + + We also add an extra couple of points to the required number, so that for small + requests numbers, the chance of fulfilling the request is much bigger than 0.5. + For example, if n=1, we'll have slightly less the 50% probability, if we use n+1, + well have 75%, if we use n+2 we'll have 83%. + + It seems like quite a lot to reject, but it should be less costly than undersampling the radius + + """ - xyz = np.concatenate(output_data, axis=0) * (2*self.radius) + n_start = int((n+2)*15.278) - return xyz[:,0], xyz[:,1], xyz[:,2] + xyz0 = self.cube_sample(n_start) + in_sphere = np.sum(xyz0**2, axis=1) <= self.radius**2 -class RandomSampleCube(RandomSample): + xyz0 = xyz0[in_sphere,:] + + dxyz = self.uniform_radial_sample(xyz0.shape[0]) + xyz1 = xyz0 + dxyz + + in_sphere = np.sum(xyz1**2, axis=1) <= self.radius**2 + + xyz0 = xyz0[in_sphere, :] + xyz1 = xyz1[in_sphere, :] + + return xyz0.shape[0], ((xyz0[:, 0], xyz0[:, 1], xyz0[:, 2]), (xyz1[:, 0], xyz1[:, 1], xyz1[:, 2])) + + def bounding_surface_check_points(self) -> VectorComponents3: + """ Points to check: + + 6 principal directions + 50 random points over the sphere + """ + + principal_directions = [ + [ self.radius, 0, 0 ], + [ -self.radius, 0, 0 ], + [ 0, self.radius, 0 ], + [ 0, -self.radius, 0 ], + [ 0, 0, self.radius ], + [ 0, 0, -self.radius ]] + + random_points = self.rng.standard_normal(size=(50, 3)) + + random_points /= self.radius / np.sqrt(np.sum(random_points**2, axis=1)) + + check_points = np.concatenate((principal_directions, random_points), axis=0) + + return check_points[:, 0], check_points[:, 1], check_points[:, 2] + def max_xyz(self): + """ Maximum distance between points in 3D - opposite sides of sphere """ + return 2*self.radius + + def max_xy(self): + """ Maximum distance between points in 2D projection - also opposite sides of sphere """ + return 2*self.radius + + def max_principal_axis(self): + """ Maximum distance between points in an axis - again, opposite sides of sphere """ + return 2*self.radius + + def sample_volume(self): + return (4*np.pi/3)*(self.radius**3) + + +class BiasedSampleCube(DistanceBiasedSample): """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" - def _calculate_n_actual(self) -> int: - return self._n_points_desired + def generate_pairs(self, n): + """ Rejection sample pairs in a sphere + + The cube_sample method will generate valid start points with 100% probability + The uniform_radial_sample will generate deltas landing within the sample volume with + a probability ( side length 2 cube / 2 sqrt(3) radius sphere) of + + Overall then, the probability of not being rejected is 0.04594 + so we need to sample 21.766 times as many points than requested if we want to + get an expected number of points equal to the requested number + + We also add an extra couple of points to the required number, so that for small + requests numbers, the chance of fulfilling the request is much bigger than 0.5. + For example, if n=1, we'll have slightly less the 50% probability, if we use n+1, + well have 75%, if we use n+2 we'll have 83%. + + This rejects more points than the sphere sampling, but the pruning is faster. + + """ + + n_start = int((n + 2) * 21.766) - def sampling_details(self) -> str: - return "" + xyz0 = self.cube_sample(n_start) - def sample_volume(self) -> float: + dxyz = self.uniform_radial_sample(n_start) + + xyz1 = xyz0 + dxyz + + in_cube = np.all(np.abs(xyz1) <= self.radius, axis=1) + + xyz0 = xyz0[in_cube, :] + xyz1 = xyz1[in_cube, :] + + return xyz0.shape[0], ((xyz0[:, 0], xyz0[:, 1], xyz0[:, 2]), (xyz1[:, 0], xyz1[:, 1], xyz1[:, 2])) + + def max_xyz(self): + """ Maximum distance between points in 3D - along the main diagonal""" + return 2 * self.radius * np.sqrt(3) + + def max_xy(self): + """ Maximum distance between points in 2D projection in an axis - along + the diagonal of a face (or equivalent)""" + return 2 * self.radius * np.sqrt(2) + + def max_principal_axis(self): + """ Maximum distance between points along one axis - along one of the axes """ + return 2 * self.radius + + def sample_volume(self): + """ Volume of sampling region - a cube """ return 8*(self.radius**3) - def generate(self, n): - # Sample within a cube + def bounding_surface_check_points(self) -> VectorComponents3: + """Bounding box check points + + 8 corners + 12 edge centres + 6 face centres + """ + + corners = [ + [ self.radius, self.radius, self.radius], + [ self.radius, self.radius, -self.radius], + [ self.radius, -self.radius, self.radius], + [ self.radius, -self.radius, -self.radius], + [-self.radius, self.radius, self.radius], + [-self.radius, self.radius, -self.radius], + [-self.radius, -self.radius, self.radius], + [-self.radius, -self.radius, -self.radius]] + + edge_centres = [ + [ self.radius, self.radius, 0 ], + [ self.radius, -self.radius, 0 ], + [-self.radius, self.radius, 0 ], + [-self.radius, -self.radius, 0 ], + [ self.radius, 0, self.radius], + [ self.radius, 0, -self.radius], + [-self.radius, 0, self.radius], + [-self.radius, 0, -self.radius], + [ 0, self.radius, self.radius], + [ 0, self.radius, -self.radius], + [ 0, -self.radius, self.radius], + [ 0, -self.radius, -self.radius]] + + face_centres = [ + [ self.radius, 0, 0 ], + [ -self.radius, 0, 0 ], + [ 0, self.radius, 0 ], + [ 0, -self.radius, 0 ], + [ 0, 0, self.radius ], + [ 0, 0, -self.radius ]] + + check_points = np.concatenate((corners, edge_centres, face_centres), axis=0) + + return check_points[:, 0], check_points[:, 1], check_points[:, 2] + +def visual_distribution_check(sampler: SpatialSample, axis_curve, r_curve, size_hint=500): + + n_bins = 200 + + print("Size hint:", size_hint) + n_total = 0 + + x0_hist = None + x1_hist = None + y0_hist = None + y1_hist = None + z0_hist = None + z1_hist = None + r_hist = None + + bin_edges = np.linspace(-sampler.max_xyz(), sampler.max_xyz(), n_bins+1) + bin_centres = 0.5*(bin_edges[1:] + bin_edges[:-1]) + + r_bin_edges = np.linspace(0, sampler.max_xyz()) + r_bin_centres = 0.5 * (r_bin_edges[1:] + r_bin_edges[:-1]) + + for (x0, y0, z0), (x1, y1, z1) in sampler(size_hint): + + n = len(x0) + n_total += n + + print("Size",n,"chunk,", n_total, "so far") + + r = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) + + if x0_hist is None: + x0_hist = np.histogram(x0, bins=bin_edges)[0].astype("float") + y0_hist = np.histogram(y0, bins=bin_edges)[0].astype("float") + z0_hist = np.histogram(z0, bins=bin_edges)[0].astype("float") + x1_hist = np.histogram(x1, bins=bin_edges)[0].astype("float") + y1_hist = np.histogram(y1, bins=bin_edges)[0].astype("float") + z1_hist = np.histogram(z1, bins=bin_edges)[0].astype("float") + r_hist = np.histogram(r, bins=r_bin_edges)[0].astype("float") + + else: + x0_hist += np.histogram(x0, bins=bin_edges)[0] + y0_hist += np.histogram(y0, bins=bin_edges)[0] + z0_hist += np.histogram(z0, bins=bin_edges)[0] + x1_hist += np.histogram(x1, bins=bin_edges)[0] + y1_hist += np.histogram(y1, bins=bin_edges)[0] + z1_hist += np.histogram(z1, bins=bin_edges)[0] + r_hist += np.histogram(r, bins=r_bin_edges)[0] + + + x0_hist /= sampler.n_points / n_bins + y0_hist /= sampler.n_points / n_bins + z0_hist /= sampler.n_points / n_bins + x1_hist /= sampler.n_points / n_bins + y1_hist /= sampler.n_points / n_bins + z1_hist /= sampler.n_points / n_bins + r_hist /= sampler.n_points / n_bins + + import matplotlib.pyplot as plt + + plt.subplot(3, 3, 1) + plt.plot(bin_centres, x0_hist) + plt.plot(bin_edges, axis_curve(bin_edges)) + + plt.subplot(3, 3, 2) + plt.plot(bin_centres, y0_hist) + plt.plot(bin_edges, axis_curve(bin_edges)) + + plt.subplot(3, 3, 3) + plt.plot(bin_centres, z0_hist) + plt.plot(bin_edges, axis_curve(bin_edges)) + + plt.subplot(3, 3, 4) + plt.plot(bin_centres, x1_hist) + plt.plot(bin_edges, axis_curve(bin_edges)) + + plt.subplot(3, 3, 5) + plt.plot(bin_centres, y1_hist) + plt.plot(bin_edges, axis_curve(bin_edges)) + + plt.subplot(3, 3, 6) + plt.plot(bin_centres, z1_hist) + plt.plot(bin_edges, axis_curve(bin_edges)) + + plt.subplot(3, 3, 8) + plt.plot(r_bin_centres, r_hist) + plt.plot(r_bin_edges, r_curve(r_bin_edges)) + + +if __name__ == "__main__": + + import matplotlib.pyplot as plt + + plt.figure("Sphere") + + sphere_sampler = BiasedSampleSphere(10_000, radius=10) + def sphere_proj_density(x): + return np.ones_like(x) + + def sphere_r_curve(x): + return np.ones_like(x) + + visual_distribution_check(sphere_sampler, axis_curve=sphere_proj_density, r_curve=sphere_r_curve) + + + plt.figure("Cube") + + cube_sampler = BiasedSampleCube(10_000, radius=10) + def cube_proj_density(x): + return np.ones_like(x) + + def cube_r_curve(x): + return np.ones_like(x) + + visual_distribution_check(cube_sampler, axis_curve=cube_proj_density, r_curve=cube_r_curve) + + plt.figure("Cube - unform") + + cube_sampler = UniformCubeSample(10_000, radius=10) + def cube_proj_density(x): + return np.ones_like(x) + + def cube_r_curve(x): + return np.ones_like(x) + + visual_distribution_check(cube_sampler, axis_curve=cube_proj_density, r_curve=cube_r_curve) + + plt.show() - xyz = (np.random.random((n, 3)) - 0.5)*(2*self.radius) - return xyz[:,0], xyz[:,1], xyz[:,2] From 7ec2c9a9e06c5507b77c1c6428e1e91f14aec8f5 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 19 Jun 2023 11:18:15 +0100 Subject: [PATCH 23/38] Advanced sampling options and refactoring of sampling methods --- .../ParticleEditor/DesignWindow.py | 6 +- .../ParticleEditor/sampling_method_tests.py | 24 +- .../ParticleEditor/sampling_methods.py | 370 +++++++++--------- 3 files changed, 215 insertions(+), 185 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index e1cf3d93ed..8fdf6441b0 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -21,7 +21,7 @@ from sas.qtgui.Perspectives.ParticleEditor.vectorise import vectorise_sld from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( - SpatialSample, BiasedSampleSphere, BiasedSampleCube) + SpatialSample, RadiallyBiasedSphereSample, RadiallyBiasedCubeSample) from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( QSample, ZSample, ScatteringCalculation, OutputOptions, CalculationParameters, ParticleDefinition) @@ -292,10 +292,10 @@ def spatialSampling(self) -> SpatialSample: seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None if sample_type == 0: - return BiasedSampleSphere(radius=radius, n_points_desired=n_desired, seed=seed) + return RadiallyBiasedSphereSample(radius=radius, n_points_desired=n_desired, seed=seed) elif sample_type == 1: - return BiasedSampleCube(radius=radius, n_points_desired=n_desired, seed=seed) + return RadiallyBiasedCubeSample(radius=radius, n_points_desired=n_desired, seed=seed) else: raise ValueError("Unknown index for spatial sampling method combo") diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py index fa30d6f3ba..cdf2ec7ef2 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py @@ -4,7 +4,9 @@ import time from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( - UniformCubeSample, BiasedSampleCube, BiasedSampleSphere) + UniformCubeSample, UniformSphereSample, + RadiallyBiasedCubeSample, RadiallyBiasedSphereSample, + MixedCubeSample, MixedSphereSample) test_radius = 5 sample_radius = 10 @@ -54,15 +56,23 @@ def sphere(x, y, z): return sld test_functions = [cube, sphere, octahedron, off_centre_cube] +sampler_classes = [UniformCubeSample, UniformSphereSample, + RadiallyBiasedCubeSample, RadiallyBiasedSphereSample, + MixedCubeSample, MixedSphereSample] + +colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] + bin_edges = np.linspace(0, np.sqrt(3)*sample_radius, 201) bin_centre = 0.5*(bin_edges[1:] + bin_edges[:-1]) for test_function in test_functions: + legends = [] + plt.figure("Test function " + test_function.__name__) - for sampler_cls in (UniformCubeSample, BiasedSampleSphere, BiasedSampleCube): + for sampler_cls, color in zip(sampler_classes, colors): print(sampler_cls.__name__) for repeat in range(3): @@ -104,9 +114,15 @@ def sphere(x, y, z): kind='linear', bounds_error=False, fill_value=0, assume_sorted=True) - plt.plot(bin_centre, f(bin_centre), label=sampler.__class__.__name__+" %i"%(repeat+1)) - plt.legend() + ax = plt.plot(bin_centre, f(bin_centre), color=color) + if repeat == 0: + legends.append((ax[0], sampler.__class__.__name__)) + print("Time:", time.time() - start_time) + handles = [handle for handle, _ in legends] + titles = [title for _, title in legends] + plt.legend(handles, titles) + plt.show() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py index 923e2ebced..d478f8b2ea 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py @@ -1,12 +1,13 @@ -from typing import Optional, Tuple +from typing import Optional, Tuple, List from abc import ABC, abstractmethod import numpy as np from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 -class RandomSpatialSample(SpatialSample): - """ Base class for random sampling methods """ + + +class RandomSample(SpatialSample): def __init__(self, n_points: int, radius: float, seed: Optional[int] = None): super().__init__(n_points, radius) self._seed = seed @@ -24,6 +25,40 @@ def seed(self, s): def __repr__(self): return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self.n_points, self.radius, str(self.seed)) +class MixedSample(SpatialSample): + + def __init__(self, n_points: int, radius: float, seed: Optional[int] = None): + super().__init__(n_points, radius) + self._seed = seed + self.children = self._create_children() + + @abstractmethod + def _create_children(self) -> List[RandomSample]: + """ Create the components that need to be mixed together""" + @property + def seed(self): + return self._seed + + @seed.setter + def seed(self, s): + self._seed = s + for child in self.children: + child.seed = s + + def __call__(self, size_hint: int): + for child in self.children: + for chunk in child(size_hint=size_hint): + yield chunk + + + +class RandomSampleWithRejection(RandomSample): + """ Base class for random sampling methods, allows for rejection sampling """ + + # These two variables are used for efficient rejection sampling + rejection_sampling_request_factor = 1.0 # 1 / fraction of points kept by rejection sampling + rejection_sampling_request_offset = 0.0 # Offset used to bias away from empty samples, see __call__ + @abstractmethod def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: """ Generate pairs of points, each uniformly distrubted over the sample space, with @@ -39,7 +74,20 @@ def __call__(self, size_hint: int): while n_remaining > 0: - n, pairs = self.generate_pairs(min((n_remaining, size_hint))) + # How many points would we ideally like to generate in this chunk + required_n_this_chunk = min((n_remaining, size_hint)) + + # + # Calculate number to request before any rejection + # + # We also add an extra couple of points to the required number, so that for small + # requests numbers, the chance of fulfilling the request is much bigger than 0.5. + # For example, if n=1, we'll have slightly less the 50% probability, if we use n+1, + # well have 75%, if we use n+2 we'll have 83%. + # + request_amount = int((required_n_this_chunk + self.rejection_sampling_request_offset) * self.rejection_sampling_request_factor) + + n, pairs = self.generate_pairs(request_amount) if n <= n_remaining: # Still plenty to go, return regardless of size @@ -55,16 +103,8 @@ def __call__(self, size_hint: int): n_remaining = 0 - -class UniformCubeSample(RandomSpatialSample): - """ Uniformly sample pairs of points from a cube """ - def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: - """ Generate pairs of points, each within a cube""" - - pts = (2*self.radius) * (self.rng.random(size=(size_hint, 6)) - 0.5) - - return size_hint, ((pts[:, 0], pts[:, 1], pts[:, 2]), (pts[:, 3], pts[:, 4], pts[:, 5])) - +class CubeSample(SpatialSample): + """ Mixin for cube based samplers """ def max_xyz(self): """ Maximum distance between points in 3D - along the main diagonal""" @@ -128,65 +168,8 @@ def bounding_surface_check_points(self) -> VectorComponents3: return check_points[:, 0], check_points[:, 1], check_points[:, 2] -class DistanceBiasedSample(RandomSpatialSample): - """ Base class for samplers with a bias towards shorter distances""" - def cube_sample(self, n: int) -> np.ndarray: - """ Sample uniformly from a 2r side length cube, centred on the origin """ - - return (2*self.radius) * (self.rng.random(size=(n, 3)) - 0.5) - - def uniform_radial_sample(self, n: int) -> VectorComponents3: - """ Sample within the maximum possible radius, uniformly over the distance from the - origin (not a uniform distribution in space)""" - - xyz = self.rng.standard_normal(size=(n, 3)) - - scaling = self.max_xyz() * self.rng.random(size=n) / np.sqrt(np.sum(xyz**2, axis=1)) - - return xyz * scaling[:, np.newaxis] - -class BiasedSampleSphere(DistanceBiasedSample): - """ Sample over a sphere with a specified radius """ - - def generate_pairs(self, n): - """ Rejection sample pairs in a sphere - - The cube_sample method will generate valid start points with a probability (sphere - volume/cube volume) of 0.5235 - The uniform_radial_sample will generate deltas landing within the sample volume with - a probability (r radius sphere / 2r radius sphere) of 0.125 - - Overall then, the probability of not being rejected is 0.0654498 - so we need to sample 15.278874 times as many points than requested if we want to - get an expected number of points equal to the requested number - - We also add an extra couple of points to the required number, so that for small - requests numbers, the chance of fulfilling the request is much bigger than 0.5. - For example, if n=1, we'll have slightly less the 50% probability, if we use n+1, - well have 75%, if we use n+2 we'll have 83%. - - It seems like quite a lot to reject, but it should be less costly than undersampling the radius - - """ - - n_start = int((n+2)*15.278) - - xyz0 = self.cube_sample(n_start) - - in_sphere = np.sum(xyz0**2, axis=1) <= self.radius**2 - - xyz0 = xyz0[in_sphere,:] - - dxyz = self.uniform_radial_sample(xyz0.shape[0]) - xyz1 = xyz0 + dxyz - - in_sphere = np.sum(xyz1**2, axis=1) <= self.radius**2 - - xyz0 = xyz0[in_sphere, :] - xyz1 = xyz1[in_sphere, :] - - return xyz0.shape[0], ((xyz0[:, 0], xyz0[:, 1], xyz0[:, 2]), (xyz1[:, 0], xyz1[:, 1], xyz1[:, 2])) - +class SphereSample(SpatialSample): + """ Mixin for sampling within a sphere""" def bounding_surface_check_points(self) -> VectorComponents3: """ Points to check: @@ -202,7 +185,7 @@ def bounding_surface_check_points(self) -> VectorComponents3: [ 0, 0, self.radius ], [ 0, 0, -self.radius ]] - random_points = self.rng.standard_normal(size=(50, 3)) + random_points = np.random.standard_normal(size=(50, 3)) random_points /= self.radius / np.sqrt(np.sum(random_points**2, axis=1)) @@ -225,34 +208,91 @@ def sample_volume(self): return (4*np.pi/3)*(self.radius**3) -class BiasedSampleCube(DistanceBiasedSample): - """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" +class UniformCubeSample(RandomSampleWithRejection, CubeSample): + """ Uniformly sample pairs of points from a cube """ + def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: + """ Generate pairs of points, each within a cube""" + + pts = (2*self.radius) * (self.rng.random(size=(size_hint, 6)) - 0.5) + + return size_hint, ((pts[:, 0], pts[:, 1], pts[:, 2]), (pts[:, 3], pts[:, 4], pts[:, 5])) + +class UniformSphereSample(RandomSampleWithRejection, SphereSample): + """ Uniformly sample pairs of points from a sphere """ + + def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: + """ Generate pairs of points, each within a cube""" + + + pts = (2*self.radius) * (self.rng.random(size=(size_hint, 6)) - 0.5) + + squared = pts**2 + r2 = self.radius * self.radius + + in_spheres = np.logical_and( + np.sum(squared[:, :3], axis=1) < r2, + np.sum(squared[:, 3:], axis=1) < r2) + + pts = pts[in_spheres, :] + + return size_hint, ((pts[:, 0], pts[:, 1], pts[:, 2]), (pts[:, 3], pts[:, 4], pts[:, 5])) + + +class RadiallyBiasedSample(RandomSampleWithRejection): + """ Base class for samplers with a bias towards shorter distances""" + def cube_sample(self, n: int) -> np.ndarray: + """ Sample uniformly from a 2r side length cube, centred on the origin """ + + return (2*self.radius) * (self.rng.random(size=(n, 3)) - 0.5) + + def uniform_radial_sample(self, n: int) -> VectorComponents3: + """ Sample within the maximum possible radius, uniformly over the distance from the + origin (not a uniform distribution in space)""" + + xyz = self.rng.standard_normal(size=(n, 3)) + + scaling = self.max_xyz() * self.rng.random(size=n) / np.sqrt(np.sum(xyz**2, axis=1)) + + return xyz * scaling[:, np.newaxis] + + +class RadiallyBiasedSphereSample(RadiallyBiasedSample, SphereSample): + """ Sample over a sphere with a specified radius """ + + rejection_sampling_request_factor = 0.9998/0.19632 # Calibrated to 10000 samples with offset of 2 + rejection_sampling_request_offset = 2 def generate_pairs(self, n): - """ Rejection sample pairs in a sphere + """ Rejection sample pairs in a sphere """ - The cube_sample method will generate valid start points with 100% probability - The uniform_radial_sample will generate deltas landing within the sample volume with - a probability ( side length 2 cube / 2 sqrt(3) radius sphere) of + xyz0 = self.cube_sample(n) - Overall then, the probability of not being rejected is 0.04594 - so we need to sample 21.766 times as many points than requested if we want to - get an expected number of points equal to the requested number + in_sphere = np.sum(xyz0**2, axis=1) <= self.radius**2 - We also add an extra couple of points to the required number, so that for small - requests numbers, the chance of fulfilling the request is much bigger than 0.5. - For example, if n=1, we'll have slightly less the 50% probability, if we use n+1, - well have 75%, if we use n+2 we'll have 83%. + xyz0 = xyz0[in_sphere,:] - This rejects more points than the sphere sampling, but the pruning is faster. + dxyz = self.uniform_radial_sample(xyz0.shape[0]) + xyz1 = xyz0 + dxyz - """ + in_sphere = np.sum(xyz1**2, axis=1) <= self.radius**2 - n_start = int((n + 2) * 21.766) + xyz0 = xyz0[in_sphere, :] + xyz1 = xyz1[in_sphere, :] + + return xyz0.shape[0], ((xyz0[:, 0], xyz0[:, 1], xyz0[:, 2]), (xyz1[:, 0], xyz1[:, 1], xyz1[:, 2])) + + +class RadiallyBiasedCubeSample(RadiallyBiasedSample, CubeSample): + """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" - xyz0 = self.cube_sample(n_start) + rejection_sampling_request_factor = 0.9998/0.25888 # Calibrated to 10000 samples with offset of 2 + rejection_sampling_request_offset = 2 + def generate_pairs(self, n): + """ Rejection sample pairs in a sphere """ + + xyz0 = self.cube_sample(n) - dxyz = self.uniform_radial_sample(n_start) + dxyz = self.uniform_radial_sample(n) xyz1 = xyz0 + dxyz @@ -263,72 +303,54 @@ def generate_pairs(self, n): return xyz0.shape[0], ((xyz0[:, 0], xyz0[:, 1], xyz0[:, 2]), (xyz1[:, 0], xyz1[:, 1], xyz1[:, 2])) - def max_xyz(self): - """ Maximum distance between points in 3D - along the main diagonal""" - return 2 * self.radius * np.sqrt(3) - def max_xy(self): - """ Maximum distance between points in 2D projection in an axis - along - the diagonal of a face (or equivalent)""" - return 2 * self.radius * np.sqrt(2) - def max_principal_axis(self): - """ Maximum distance between points along one axis - along one of the axes """ - return 2 * self.radius +class MixedSphereSample(MixedSample, SphereSample): + """ Mixture of samples from the uniform and radially biased spherical samplers - def sample_volume(self): - """ Volume of sampling region - a cube """ - return 8*(self.radius**3) - def bounding_surface_check_points(self) -> VectorComponents3: - """Bounding box check points + Note: the default biased fraction is different between these mixture classes, + so as to make the lower r sampling be approximately uniform. + """ + def __init__(self, n_points, radius, seed: Optional[int]=None, biased_fraction=0.25): + self._biased_fraction = biased_fraction + super().__init__(n_points, radius, seed) - 8 corners - 12 edge centres - 6 face centres - """ + def _create_children(self) -> List[RandomSample]: + n_biased = int(self._biased_fraction*self.n_points) + n_non_biased = self.n_points - n_biased - corners = [ - [ self.radius, self.radius, self.radius], - [ self.radius, self.radius, -self.radius], - [ self.radius, -self.radius, self.radius], - [ self.radius, -self.radius, -self.radius], - [-self.radius, self.radius, self.radius], - [-self.radius, self.radius, -self.radius], - [-self.radius, -self.radius, self.radius], - [-self.radius, -self.radius, -self.radius]] + return [RadiallyBiasedSphereSample(n_biased, self.radius, self.seed), + UniformSphereSample(n_non_biased, self.radius, self.seed)] - edge_centres = [ - [ self.radius, self.radius, 0 ], - [ self.radius, -self.radius, 0 ], - [-self.radius, self.radius, 0 ], - [-self.radius, -self.radius, 0 ], - [ self.radius, 0, self.radius], - [ self.radius, 0, -self.radius], - [-self.radius, 0, self.radius], - [-self.radius, 0, -self.radius], - [ 0, self.radius, self.radius], - [ 0, self.radius, -self.radius], - [ 0, -self.radius, self.radius], - [ 0, -self.radius, -self.radius]] - face_centres = [ - [ self.radius, 0, 0 ], - [ -self.radius, 0, 0 ], - [ 0, self.radius, 0 ], - [ 0, -self.radius, 0 ], - [ 0, 0, self.radius ], - [ 0, 0, -self.radius ]] +class MixedCubeSample(MixedSample, CubeSample): + """ Mixture of samples from the uniform and radially biased cube samplers - check_points = np.concatenate((corners, edge_centres, face_centres), axis=0) + Note: the default biased fraction is different between these mixture classes, + so as to make the lower r sampling be approximately uniform. + """ + + def __init__(self, n_points, radius, seed: Optional[int] = None, biased_fraction=0.5): + self._biased_fraction = biased_fraction + super().__init__(n_points, radius, seed) + + def _create_children(self) -> List[RandomSample]: + n_biased = int(self._biased_fraction * self.n_points) + n_non_biased = self.n_points - n_biased + + return [RadiallyBiasedCubeSample(n_biased, self.radius, self.seed), + UniformCubeSample(n_non_biased, self.radius, self.seed)] - return check_points[:, 0], check_points[:, 1], check_points[:, 2] -def visual_distribution_check(sampler: SpatialSample, axis_curve, r_curve, size_hint=500): +def visual_distribution_check(sampler: SpatialSample, size_hint=10_000): n_bins = 200 + print("") + print("Sampler:", sampler) print("Size hint:", size_hint) + n_total = 0 x0_hist = None @@ -345,12 +367,16 @@ def visual_distribution_check(sampler: SpatialSample, axis_curve, r_curve, size_ r_bin_edges = np.linspace(0, sampler.max_xyz()) r_bin_centres = 0.5 * (r_bin_edges[1:] + r_bin_edges[:-1]) + chunk_sizes = [] for (x0, y0, z0), (x1, y1, z1) in sampler(size_hint): n = len(x0) + n_total += n + if n_total < sampler.n_points - size_hint: + chunk_sizes.append(n) - print("Size",n,"chunk,", n_total, "so far") + # print("Size",n,"chunk,", n_total, "so far") r = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) @@ -381,74 +407,62 @@ def visual_distribution_check(sampler: SpatialSample, axis_curve, r_curve, size_ z1_hist /= sampler.n_points / n_bins r_hist /= sampler.n_points / n_bins + print("Mean:", np.mean(chunk_sizes)) + import matplotlib.pyplot as plt plt.subplot(3, 3, 1) plt.plot(bin_centres, x0_hist) - plt.plot(bin_edges, axis_curve(bin_edges)) plt.subplot(3, 3, 2) plt.plot(bin_centres, y0_hist) - plt.plot(bin_edges, axis_curve(bin_edges)) plt.subplot(3, 3, 3) plt.plot(bin_centres, z0_hist) - plt.plot(bin_edges, axis_curve(bin_edges)) plt.subplot(3, 3, 4) plt.plot(bin_centres, x1_hist) - plt.plot(bin_edges, axis_curve(bin_edges)) plt.subplot(3, 3, 5) plt.plot(bin_centres, y1_hist) - plt.plot(bin_edges, axis_curve(bin_edges)) plt.subplot(3, 3, 6) plt.plot(bin_centres, z1_hist) - plt.plot(bin_edges, axis_curve(bin_edges)) plt.subplot(3, 3, 8) plt.plot(r_bin_centres, r_hist) - plt.plot(r_bin_edges, r_curve(r_bin_edges)) if __name__ == "__main__": - import matplotlib.pyplot as plt - - plt.figure("Sphere") - - sphere_sampler = BiasedSampleSphere(10_000, radius=10) - def sphere_proj_density(x): - return np.ones_like(x) + n_samples = 10_000_000 - def sphere_r_curve(x): - return np.ones_like(x) - - visual_distribution_check(sphere_sampler, axis_curve=sphere_proj_density, r_curve=sphere_r_curve) - - - plt.figure("Cube") - - cube_sampler = BiasedSampleCube(10_000, radius=10) - def cube_proj_density(x): - return np.ones_like(x) + import matplotlib.pyplot as plt - def cube_r_curve(x): - return np.ones_like(x) + plt.figure("Sphere -biased") + sphere_sampler = RadiallyBiasedSphereSample(n_samples, radius=10) + visual_distribution_check(sphere_sampler) - visual_distribution_check(cube_sampler, axis_curve=cube_proj_density, r_curve=cube_r_curve) + plt.figure("Cube - biased") + sampler = RadiallyBiasedCubeSample(n_samples, radius=10) + visual_distribution_check(sampler) plt.figure("Cube - unform") + sampler = UniformCubeSample(n_samples, radius=10) + visual_distribution_check(sampler) + + plt.figure("Sphere - unform") + sampler = UniformSphereSample(n_samples, radius=10) + visual_distribution_check(sampler) - cube_sampler = UniformCubeSample(10_000, radius=10) - def cube_proj_density(x): - return np.ones_like(x) + plt.figure("Cube - mixed") + sampler = MixedCubeSample(n_samples, radius=10) + visual_distribution_check(sampler) - def cube_r_curve(x): - return np.ones_like(x) + plt.figure("Sphere - mixed") + sampler = MixedSphereSample(n_samples, radius=10) + visual_distribution_check(sampler) - visual_distribution_check(cube_sampler, axis_curve=cube_proj_density, r_curve=cube_r_curve) plt.show() From d9af11eb8059bf044f027865436f0729454e053e Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 24 Jul 2023 11:37:06 +0100 Subject: [PATCH 24/38] WIP breakpoint --- .../ParticleEditor/DesignWindow.py | 153 +++++--- .../ParticleEditor/Plots/CorrelationCanvas.py | 58 +++ .../ParticleEditor/Plots/QCanvas.py | 35 +- .../Plots/{RCanvas.py => RDFCanvas.py} | 8 +- .../Plots/SamplingDistributionCanvas.py | 40 ++ .../ParticleEditor/UI/DesignWindowUI.ui | 365 ++++++++++-------- .../ParticleEditor/calculations.py | 194 ---------- .../ParticleEditor/calculations/__init__.py | 0 .../{ => calculations}/boundary_check.py | 0 .../ParticleEditor/calculations/debye.py | 21 + .../ParticleEditor/calculations/run_all.py | 14 + .../ParticleEditor/datamodel/calculation.py | 32 +- .../Perspectives/ParticleEditor/defaults.py | 10 +- .../ParticleEditor/old_calculations.py | 330 ++++++++++++++++ .../sampling/check_point_samplers.py | 20 + .../ParticleEditor/sampling/chunker.py | 15 + .../ParticleEditor/sampling/points.py | 42 ++ .../ParticleEditor/sampling_methods.py | 2 - .../Perspectives/ParticleEditor/scattering.py | 221 ----------- 19 files changed, 898 insertions(+), 662 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/Plots/CorrelationCanvas.py rename src/sas/qtgui/Perspectives/ParticleEditor/Plots/{RCanvas.py => RDFCanvas.py} (71%) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/Plots/SamplingDistributionCanvas.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/__init__.py rename src/sas/qtgui/Perspectives/ParticleEditor/{ => calculations}/boundary_check.py (100%) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/check_point_samplers.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/scattering.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 8fdf6441b0..51d6d964ce 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -12,8 +12,11 @@ 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.RDFCanvas import RDFCanvas +from sas.qtgui.Perspectives.ParticleEditor.Plots.CorrelationCanvas import CorrelationCanvas from sas.qtgui.Perspectives.ParticleEditor.Plots.QCanvas import QCanvas -from sas.qtgui.Perspectives.ParticleEditor.Plots.RCanvas import RCanvas +from sas.qtgui.Perspectives.ParticleEditor.Plots.SamplingDistributionCanvas import SamplingDistributionCanvas from sas.qtgui.Perspectives.ParticleEditor.UI.DesignWindowUI import Ui_DesignWindow @@ -21,19 +24,29 @@ from sas.qtgui.Perspectives.ParticleEditor.vectorise import vectorise_sld from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( - SpatialSample, RadiallyBiasedSphereSample, RadiallyBiasedCubeSample) + SpatialSample, + MixedSphereSample, MixedCubeSample, + UniformSphereSample, UniformCubeSample +) from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - QSample, ZSample, ScatteringCalculation, OutputOptions, CalculationParameters, ParticleDefinition) + OrientationalDistribution, + QSample, ZSample, ScatteringCalculation, OutputOptions, CalculationParameters, + 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.scattering import ( - OrientationalDistribution, ScatteringCalculation, calculate_scattering) +from sas.qtgui.Perspectives.ParticleEditor.old_calculations import calculate_scattering from sas.qtgui.Perspectives.ParticleEditor.util import format_time_estimate +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""" @@ -44,6 +57,7 @@ def __init__(self, parent=None): self.setWindowTitle("Placeholder title") self.parent = parent + # TODO: Set validators on fields # # First Tab @@ -64,9 +78,6 @@ def __init__(self, parent=None): self.codeToolBar.buildButton.clicked.connect(self.doBuild) self.codeToolBar.scatterButton.clicked.connect(self.doScatter) - self.solvent_sld = 0.0 - - topSection = QtWidgets.QVBoxLayout() topSection.addWidget(self.pythonViewer) topSection.addWidget(self.codeToolBar) @@ -114,8 +125,6 @@ def __init__(self, parent=None): self.structureFactorCombo.addItem("None") # TODO: Structure Factor Options - self.solventSLDBox.valueChanged.connect(self.onSolventSLDBoxChanged) - # # Calculation Tab @@ -125,18 +134,13 @@ def __init__(self, parent=None): self.methodCombo.addItem(option) # Spatial sampling changed - self.methodCombo.currentIndexChanged.connect(self.updateSpatialSampling) - self.sampleRadius.valueChanged.connect(self.updateSpatialSampling) - self.nSamplePoints.valueChanged.connect(self.updateSpatialSampling) - self.fixRandomSeed.clicked.connect(self.updateSpatialSampling) - 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.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) @@ -144,13 +148,33 @@ def __init__(self, parent=None): # Output Tabs # - self.realCanvas = RCanvas() + # Sampling Canvas + self.samplingCanvas = SamplingDistributionCanvas() outputLayout = QtWidgets.QVBoxLayout() - outputLayout.addWidget(self.realCanvas) + outputLayout.addWidget(self.samplingCanvas) + + self.samplingTab.setLayout(outputLayout) - self.realSpaceTab.setLayout(outputLayout) + # RDF + self.rdfCanvas = RDFCanvas() + + outputLayout = QtWidgets.QVBoxLayout() + outputLayout.addWidget(self.rdfCanvas) + + self.rdfTab.setLayout(outputLayout) + + # Corrleations + + self.correlationCanvas = CorrelationCanvas() + + outputLayout = QtWidgets.QVBoxLayout() + outputLayout.addWidget(self.correlationCanvas) + + self.correlationTab.setLayout(outputLayout) + + # Output self.outputCanvas = QCanvas() @@ -174,8 +198,8 @@ def __init__(self, parent=None): self.last_calculation_n_r: int = 0 self.last_calculation_n_q: int = 0 - self.sld_function: Optional[np.ndarray] = None - self.sld_coordinate_mapping: Optional[np.ndarray] = None + 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 @@ -183,12 +207,6 @@ def onRadiusChanged(self): if self.radiusFromParticleTab.isChecked(): self.sampleRadius.setValue(self.functionViewer.radius_control.radius()) - def onSolventSLDBoxChanged(self): - sld = float(self.solventSLDBox.value()) - self.solvent_sld = sld - # self.functionViewer.solvent_sld = sld # TODO: Think more about where to put this variable - self.functionViewer.updateImage() - def onTimeEstimateParametersChanged(self): """ Called when the number of samples changes """ @@ -198,7 +216,7 @@ def onTimeEstimateParametersChanged(self): # 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 * self.last_calculation_n_q) + 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()) @@ -260,8 +278,18 @@ def doBuild(self): def outputOptions(self) -> OutputOptions: """ Get the OutputOptions object representing the desired outputs from the calculation """ - pass + q_space = self.qSampling() if self.sas1DOption.isChecked() else None + q_space_2d = None + sesans = None + + return OutputOptions( + radial_distribution=self.includeRadialDistribution.isChecked(), + sampling_distributions=self.includeSamplingDistribution.isChecked(), + realspace=self.includeCorrelations.isChecked(), + q_space=q_space, + q_space_2d=q_space_2d, + sesans=sesans) def orientationalDistribution(self) -> OrientationalDistribution: """ Get the OrientationalDistribution object that represents the GUI selected orientational distribution""" @@ -276,11 +304,6 @@ def orientationalDistribution(self) -> OrientationalDistribution: return orientation - def updateSpatialSampling(self): - """ Update the spatial sampling object """ - self.spatialSampling = self._spatialSampling() - self.sampleDetails.setText(self.spatialSampling.sampling_details()) - # print(self.spatialSampling) def spatialSampling(self) -> SpatialSample: """ Calculate the spatial sampling object based on current gui settings""" @@ -288,27 +311,46 @@ def spatialSampling(self) -> SpatialSample: # All the methods need the radius, number of points, etc radius = float(self.sampleRadius.value()) - n_desired = int(self.nSamplePoints.value()) + n_points = int(self.nSamplePoints.value()) seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None if sample_type == 0: - return RadiallyBiasedSphereSample(radius=radius, n_points_desired=n_desired, seed=seed) + return UniformSphereSample(radius=radius, n_points=n_points, seed=seed) + # return MixedSphereSample(radius=radius, n_points=n_points, seed=seed) elif sample_type == 1: - return RadiallyBiasedCubeSample(radius=radius, n_points_desired=n_desired, seed=seed) + return UniformCubeSample(radius=radius, n_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: - pass + return self._parameterTableModel.calculation_parameters() def polarisationVector(self) -> np.ndarray: """ Get a numpy vector representing the GUI specified polarisation vector""" - pass def currentSeed(self): return self.randomSeed @@ -348,23 +390,19 @@ def doScatter(self): self.codeText("Calculating scattering...") if build_success: - calc = self._scatteringCalculation() + calc = self.scatteringCalculation() try: scattering_result = calculate_scattering(calc) # Time estimates self.last_calculation_time = scattering_result.calculation_time - self.last_calculation_n_r = scattering_result.spatial_sampling_method._calculate_n_actual() - self.last_calculation_n_q = scattering_result.q_sampling_method.n_points + 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) - - # Plot - self.realCanvas.data = scattering_result - self.outputCanvas.data = scattering_result - self.tabWidget.setCurrentIndex(5) # Move to output tab if complete + self.display_calculation_result(scattering_result) except Exception: self.codeError(traceback.format_exc()) @@ -373,6 +411,16 @@ def doScatter(self): else: self.codeError("Build failed, scattering cancelled") + def display_calculation_result(self, scattering_result: ScatteringOutput): + """ Update graphs and select tab""" + + # Plot + self.samplingCanvas.data = scattering_result + self.rdfCanvas.data = scattering_result + self.correlationCanvas.data = scattering_result + self.outputCanvas.data = scattering_result + + self.tabWidget.setCurrentIndex(7) # Move to output tab if complete def onFit(self): """ Fit functionality requested""" pass @@ -389,11 +437,6 @@ def codeWarning(self, text): """ Show a warning about input code""" self.outputViewer.addWarning(text) - def updateQSampling(self): - """ Update the spatial sampling object """ - self.qSampling = self._qSampling() - print(self.qSampling) # TODO: Remove - 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 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 index 195b03cfb3..e87d50c37c 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py @@ -5,7 +5,7 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure -from sas.qtgui.Perspectives.ParticleEditor.scattering import ScatteringOutput +from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput import numpy as np def spherical_form_factor(q, r): @@ -33,25 +33,30 @@ def data(self): def data(self, scattering_output: ScatteringOutput): self._data = scattering_output + self.axes.cla() - q_values = scattering_output.q_sampling_method() - i_values = scattering_output.intensity_data - self.axes.cla() + if self._data.q_space is not None: + plot_data = scattering_output.q_space.q_space_data - if scattering_output.q_sampling_method.is_log: - self.axes.loglog(q_values, i_values) + q_sample = plot_data.abscissa + q_values = q_sample() + i_values = plot_data.ordinate - 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) + 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 - # self.axes.loglog(q_values, spherical_form_factor(q_values, 50)) - else: - self.axes.semilogy(q_values, i_values) + # For comparisons: TODO: REMOVE + thing = spherical_form_factor(q_values, 50) + self.axes.loglog(q_values, thing*np.max(i_values)/np.max(thing)) + else: + self.axes.semilogy(q_values, i_values) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py similarity index 71% rename from src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py rename to src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py index 1dd2893a42..bbaf5ed0da 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py @@ -5,10 +5,10 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure -from sas.qtgui.Perspectives.ParticleEditor.scattering import ScatteringOutput +from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput -class RCanvas(FigureCanvas): +class RDFCanvas(FigureCanvas): """ Plot window for output from scattering calculations""" def __init__(self, parent=None, width=5, height=4, dpi=100): @@ -31,8 +31,8 @@ def data(self, scattering_output: ScatteringOutput): self.axes.cla() - if scattering_output.r_values is not None and scattering_output.realspace_intensity is not None: + if self._data.radial_distribution is not None: - self.axes.plot(scattering_output.r_values, scattering_output.realspace_intensity) + 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/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 2b980c3237..4a60f624d9 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -7,7 +7,7 @@ 0 0 994 - 484 + 480 @@ -71,27 +71,17 @@ - + 10 10 - - - - Solvent SLD - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - + - Orientational Distribution + Structure Factor Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter @@ -99,47 +89,20 @@ - + - - + + - Structure Factor + Orientational Distribution Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 4 - - - -100.000000000000000 - - - 100.000000000000000 - - - 0.100000000000000 - - - - - - - 10<sup>-6</sup>Ã…<sup>-2</sup> - - - - + @@ -236,17 +199,31 @@ - - + + - Logaritmic + Ang - - true + + + + + + Q Max + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - + + + + 0.5 + + + + 10 @@ -262,21 +239,49 @@ - - + + - Ang + Logaritmic (applies only to 1D) + + + true - - + + - Ang + Neutron Polarisation - + + + + + + 1 + + + + + + + 0 + + + + + + + 0 + + + + + + Q Samples @@ -286,7 +291,81 @@ - + + + + Check sampling boundary for SLD continuity + + + true + + + + + + + + + + Qt::AlignCenter + + + + + + + Outputs + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + 0 + + + + + 1D SAS + + + true + + + + + + + 2D SAS + + + + + + + SESANS + + + + + + + + + Sample Radius + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + @@ -316,14 +395,7 @@ - - - - Sample Radius - - - - + Sample Method @@ -333,78 +405,37 @@ - - - - 0.0005 - - - - - + + - Q Min + Random Seed Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - 0.5 - - - - - + + - Q Max + Q Min Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - 0 - - - - - 1D - - - true - - - - - - - 2D - - - - - - - - - - + + - Output Type + Sample Points Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - + @@ -428,17 +459,7 @@ - - - - Sample Points - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - + @@ -456,65 +477,67 @@ - - + + - Neutron Polarisation + Ang - - + + - Random Seed - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 0.0005 - - - - + + + + - 1 + Sampling Distribution + + + true - - + + - 0 + Radial Distribution + + + true - - + + - 0 + Correlations + + + true - - - - - - - Qt::AlignCenter + + + + + 0 + 0 + - - - - - Check sampling boundary for SLD continuity + Extra Outputs - - true + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter @@ -559,9 +582,19 @@ Fitting - + + + Sampling Distribution + + + + + RDF + + + - Real Space + Correlations diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py deleted file mode 100644 index df75ea944f..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations.py +++ /dev/null @@ -1,194 +0,0 @@ -from typing import Optional, Tuple -import numpy as np -import time - -from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - ScatteringCalculation, ScatteringOutput, OrientationalDistribution) - - -def calculate_scattering(calculation: ScatteringCalculation): - """ Main scattering calculation """ - - start_time = time.time() # Track how long it takes - - # What things do we need to calculate - options = calculation.output_options - fixed_orientation = calculation.orientation == OrientationalDistribution.FIXED - no_orientation = calculation.orientation == OrientationalDistribution.UNORIENTED - - # Radial SLD distribution - a special plot - doesn't relate to the other things - do_radial_distribution = options.radial_distribution - - # Radial correlation based on distance in x, y and z - this is the quantity that matters for - # unoriented particles - do_r_xyz_correlation = no_orientation and (options.q_space is not None or options.q_space_2d is not None or options.sesans is not None) - - # Radial correlation based on distance in x, y - this is the quantity that matters for 2D - do_r_xy_correlation = fixed_orientation and options.q_space is not None - - # XY correlation - this is what we need for standard 2D SANS - do_xy_correlation = fixed_orientation and options.q_space_2d is not None - - # Z correlation - this is what we need for SESANS when the particles are oriented - do_z_correlation = fixed_orientation and options.sesans is not None - - # - # Set up output variables - # - - n_bins = calculation.bin_count - - sampling = calculation.spatial_sampling_method - - radial_bin_edges = np.linspace(0, sampling.radius, n_bins+1) if do_radial_distribution else None - r_xyz_bin_edges = np.linspace(0, sampling.max_xyz(), n_bins+1) if do_r_xyz_correlation else None - r_xy_bin_edges = np.linspace(0, sampling.max_xy(), n_bins+1) if do_r_xy_correlation else None - xy_bin_edges = (np.linspace(0, sampling.max_x(), n_bins+1), - np.linspace(0, sampling.max_y(), n_bins+1)) if do_xy_correlation else None - - radial_distribution = None - radial_counts = None - r_xyz_correlation = None - r_xyz_counts = None - r_xy_correlation = None - r_xy_counts = None - xy_correlation = None - xy_counts = None - - # - # Seed - # - - # TODO: This needs to be done properly - if calculation.seed is None: - seed = 0 - else: - seed = calculation.seed - - # - # Setup for calculation - # - - sld = calculation.particle_definition.sld - sld_parameters = calculation.parameter_settings.sld_parameters - - magnetism = calculation.particle_definition.magnetism - magnetism_parameters = calculation.parameter_settings.magnetism_parameters - - total_square_sld = 0.0 - - for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method.pairs(calculation.sample_chunk_size_hint): - - # - # Sample the SLD - # - - # TODO: Make sure global variables are accessible to the function calls - sld0 = sld.sld_function(*sld.to_cartesian_conversion(x0, y0, z0), **sld_parameters) - sld1 = sld.sld_function(*sld.to_cartesian_conversion(x1, y1, z1), **sld_parameters) - - rho = sld0 * sld1 - - # - # Build the appropriate histograms - # - - if do_radial_distribution: - - r = np.sqrt(x0**2 + y0**2 + z0**2) - - if radial_distribution is None: - radial_distribution = np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] - radial_counts = np.histogram(r, bins=radial_bin_edges)[0] - - else: - radial_distribution += np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] - radial_counts += np.histogram(r, bins=radial_bin_edges)[0] - - if do_r_xyz_correlation: - - r_xyz = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2) - - if r_xyz_correlation is None: - r_xyz_correlation = np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] - r_xyz_counts = np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] - - else: - r_xyz_correlation += np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] - r_xyz_counts += np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] - - if do_r_xy_correlation: - - r_xy = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) - - if r_xy_correlation is None: - r_xy_correlation = np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] - r_xy_counts = np.histogram(r_xy, bins=r_xy_bin_edges)[0] - - else: - r_xy_correlation += np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] - r_xy_counts += np.histogram(r_xy, bins=r_xy_bin_edges)[0] - - if do_xy_correlation: - - x = x1 - x0 - y = y1 - y0 - - if xy_correlation is None: - xy_correlation = np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] - xy_counts = np.histogram2d(x, y, bins=xy_bin_edges)[0] - - else: - xy_correlation += np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] - xy_counts += np.histogram2d(x, y, bins=xy_bin_edges)[0] - - if do_z_correlation: - raise NotImplementedError("Z correlation not implemented yet") - - # - # Mean SLD squared, note we have two samples here - # - - total_square_sld += np.sum(sld0**2 + sld1**2) - - # - # Calculate scattering from the histograms - # - - if do_radial_distribution: - bin_centres = 0.5*(radial_bin_edges[1:] + radial_bin_edges[:-1]) - radial_distribution_output = (bin_centres, radial_distribution) - else: - radial_distribution_output = None - - if no_orientation: - if options.q_space is not None: - q = calculation.output_options.q_space() - - bin_centres = r_xyz_bin_edges[1:] + r_xy_bin_edges[:-1] - - - - # We have to be very careful about how we do the numerical integration, specifically with respect to r=0 - qr = np.outer(q, r_xyz_correlation) - - np.sum(np.sinc(qr), axis=1) - - q_space = (q, ) - - if options.q_space_2d: - # USE: scipy.interpolate.CloughTocher2DInterpolator to do this - - pass - - # TODO: SESANS support - sesans_output = None - - - return ScatteringOutput( - radial_distribution=radial_distribution_output, - q_space=None, - q_space_2d=None, - sesans=None, - calculation_time=time.time() - start_time, - seed_used=seed) 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/boundary_check.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py similarity index 100% rename from src/sas/qtgui/Perspectives/ParticleEditor/boundary_check.py rename to src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py 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..7b1c036e9f --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py @@ -0,0 +1,21 @@ +from typing import Optional, Tuple +import time + +import numpy as np +from scipy.interpolate import interp1d + +from scipy.special import jv as bessel + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + ScatteringCalculation, ScatteringOutput, OrientationalDistribution, SamplingDistribution, + QPlotData, QSpaceCalcDatum, RealPlotData, + SLDDefinition, MagnetismDefinition, SpatialSample, QSample, CalculationParameters) + +def debye( + sld_function: SLDDefinition, + magnetism_function: Optional[MagnetismDefinition], + parameters: CalculationParameters, + spatial_sample: SpatialSample, + q_sample: QSample): + + for (x1, y1, z1), (x2, y2, z2) in \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py new file mode 100644 index 0000000000..a4e61552ed --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py @@ -0,0 +1,14 @@ +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringCalculation, OrientationalDistribution +from sas.qtgui.Perspectives.ParticleEditor.calculations.debye import debye + +def calculate_scattering(calculation: ScatteringCalculation): + if calculation.bounding_surface_sld_check: + pass + + sld_def = calculation.particle_definition.sld + mag_def = calculation.particle_definition.magnetism + params = calculation.parameter_settings + + if calculation.orientation == OrientationalDistribution.UNORIENTED: + if calculation.output_options.q_space: + debye_data = debye(sld_def, mag_def, params, ) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index 71640f8708..1bc43cae53 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -1,4 +1,4 @@ -from typing import Optional, Callable, Tuple, Protocol +from typing import Optional, Callable, Tuple, Protocol, List import numpy as np from enum import Enum from dataclasses import dataclass @@ -48,6 +48,7 @@ def __call__(self): class OutputOptions: """ Options """ radial_distribution: bool # Create a radial distribution function from the origin + sampling_distributions: bool # Return the sampling distributions used in the calculation realspace: bool # Return realspace data q_space: Optional[QSample] = None q_space_2d: Optional[QSample] = None @@ -96,12 +97,35 @@ class ScatteringCalculation: sample_chunk_size_hint: int = 100_000 +@dataclass +class SamplingDistribution: + name: str + bin_edges: np.ndarray + counts: np.ndarray + +@dataclass +class QPlotData: + abscissa: QSample + ordinate: np.ndarray + +@dataclass +class RealPlotData: + abscissa: np.ndarray + ordinate: np.ndarray + + +@dataclass +class QSpaceCalcDatum: + q_space_data: QPlotData + correlation_data: Optional[RealPlotData] + @dataclass class ScatteringOutput: radial_distribution: Optional[Tuple[np.ndarray, np.ndarray]] - q_space: Optional[Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]] - q_space_2d: Optional[Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]] - sesans: Optional[Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]] + q_space: Optional[QSpaceCalcDatum] + q_space_2d: Optional[QSpaceCalcDatum] + sesans: Optional[RealPlotData] + sampling_distributions: List[SamplingDistribution] calculation_time: float seed_used: int diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py index 7e59f9fe68..f359a2e7dc 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py @@ -24,11 +24,19 @@ def sld(r,theta,phi) Here's a simple example: """ -def sld(x,y,z): +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 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py b/src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py new file mode 100644 index 0000000000..6d1d69d6d3 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py @@ -0,0 +1,330 @@ +from typing import Optional, Tuple +import time + +import numpy as np +from scipy.interpolate import interp1d + +from scipy.special import jv as bessel + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + ScatteringCalculation, ScatteringOutput, OrientationalDistribution, SamplingDistribution, + QPlotData, QSpaceCalcDatum, RealPlotData) + +def calculate_average(input_data, counts, original_bin_edges, new_bin_edges, two_times_sld_squared_sum): + """ Get averaged data, taking into account under sampling + + :returns: mean correlation, associated bin sizes""" + + total_counts = np.sum(counts) + mean_rho = 0.5 * two_times_sld_squared_sum / total_counts + + original_bin_centres = 0.5 * (original_bin_edges[1:] + original_bin_edges[:-1]) + new_bin_centres = 0.5 * (new_bin_edges[1:] + new_bin_edges[:-1]) + + non_empty = counts > 0 + means = input_data[non_empty] / counts[non_empty] + mean_function = interp1d(original_bin_centres[non_empty], means, + bounds_error=False, fill_value=(mean_rho, 0), assume_sorted=True) + + delta_rs = 0.5 * (new_bin_edges[1:] - new_bin_edges[:-1]) + + return mean_function(new_bin_centres), delta_rs + + + + +def calculate_scattering(calculation: ScatteringCalculation): + """ Main scattering calculation """ + + start_time = time.time() # Track how long it takes + + # Some dereferencing + sampling = calculation.spatial_sampling_method + parameters = calculation.parameter_settings + + # What things do we need to calculate + options = calculation.output_options + fixed_orientation = calculation.orientation == OrientationalDistribution.FIXED + no_orientation = calculation.orientation == OrientationalDistribution.UNORIENTED + + # Radial SLD distribution - a special plot - doesn't relate to the other things + do_radial_distribution = options.radial_distribution + + # Radial correlation based on distance in x, y and z - this is the quantity that matters for + # unoriented particles + do_r_xyz_correlation = no_orientation and (options.q_space is not None or options.q_space_2d is not None or options.sesans is not None) + + # Radial correlation based on distance in x, y - this is the quantity that matters for 2D + do_r_xy_correlation = fixed_orientation and options.q_space is not None + + # XY correlation - this is what we need for standard 2D SANS + do_xy_correlation = fixed_orientation and options.q_space_2d is not None + + # Z correlation - this is what we need for SESANS when the particles are oriented + do_z_correlation = fixed_orientation and options.sesans is not None + + # + # Set up output variables + # + + n_bins = calculation.bin_count + + + + radial_bin_edges = np.linspace(0, sampling.radius, n_bins+1) if do_radial_distribution else None + r_xyz_bin_edges = np.linspace(0, sampling.max_xyz(), n_bins+1) if do_r_xyz_correlation else None + r_xy_bin_edges = np.linspace(0, sampling.max_xy(), n_bins+1) if do_r_xy_correlation else None + xy_bin_edges = (np.linspace(0, sampling.max_x(), n_bins+1), + np.linspace(0, sampling.max_y(), n_bins+1)) if do_xy_correlation else None + + radial_distribution = None + radial_counts = None + r_xyz_correlation = None + r_xyz_counts = None + r_xy_correlation = None + r_xy_counts = None + xy_correlation = None + xy_counts = None + + # + # Seed + # + + # TODO: This needs to be done properly + if calculation.seed is None: + seed = 0 + else: + seed = calculation.seed + + # + # Setup for calculation + # + + sld = calculation.particle_definition.sld + sld_parameters = calculation.parameter_settings.sld_parameters + + magnetism = calculation.particle_definition.magnetism + magnetism_parameters = calculation.parameter_settings.magnetism_parameters + + total_square_sld_times_two = 0.0 + + + for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method(calculation.sample_chunk_size_hint): + + # + # Sample the SLD + # + + # TODO: Make sure global variables are accessible to the function calls + sld0 = sld.sld_function(*sld.to_cartesian_conversion(x0, y0, z0), **sld_parameters) + sld1 = sld.sld_function(*sld.to_cartesian_conversion(x1, y1, z1), **sld_parameters) + + rho = sld0 * sld1 + + # + # Build the appropriate histograms + # + + if do_radial_distribution: + + r = np.sqrt(x0**2 + y0**2 + z0**2) + + if radial_distribution is None: + radial_distribution = np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] + radial_counts = np.histogram(r, bins=radial_bin_edges)[0] + + else: + radial_distribution += np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] + radial_counts += np.histogram(r, bins=radial_bin_edges)[0] + + if do_r_xyz_correlation: + + # r_xyz = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 )#+ (z1 - z0) ** 2) + r_xyz = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2) + + if r_xyz_correlation is None: + r_xyz_correlation = np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] + r_xyz_counts = np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] + + else: + r_xyz_correlation += np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] + r_xyz_counts += np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] + + if do_r_xy_correlation: + + r_xy = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) + + if r_xy_correlation is None: + r_xy_correlation = np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] + r_xy_counts = np.histogram(r_xy, bins=r_xy_bin_edges)[0] + + else: + r_xy_correlation += np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] + r_xy_counts += np.histogram(r_xy, bins=r_xy_bin_edges)[0] + + if do_xy_correlation: + + x = x1 - x0 + y = y1 - y0 + + if xy_correlation is None: + xy_correlation = np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] + xy_counts = np.histogram2d(x, y, bins=xy_bin_edges)[0] + + else: + xy_correlation += np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] + xy_counts += np.histogram2d(x, y, bins=xy_bin_edges)[0] + + if do_z_correlation: + raise NotImplementedError("Z correlation not implemented yet") + + # + # Mean SLD squared, note we have two samples here, so don't forget to divide later + # + + total_square_sld_times_two += np.sum(sld0**2 + sld1**2) + + # + # Calculate scattering from the histograms + # + + q_space = None + + if do_radial_distribution: + bin_centres = 0.5*(radial_bin_edges[1:] + radial_bin_edges[:-1]) + + good_bins = radial_counts > 0 + + averages = radial_distribution[good_bins] / radial_counts[good_bins] + + radial_distribution_output = (bin_centres[good_bins], averages) + else: + radial_distribution_output = None + + sampling_distributions = [] + + if no_orientation: + if options.q_space is not None: + q = calculation.output_options.q_space() + + qr = np.outer(q, r_xyz_bin_edges) + + # intensity = parameters.background + parameters.scale * np.sum(np.sinc(qr), axis=1) + # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) + total_square_sld_times_two + # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) + + # Integral of r^2 sinc(qr) + sections = ((np.sin(qr) - qr * np.cos(qr)).T / q**3).T + sections[:, 0] = 0 + sections = sections[:, 1:] - sections[:, :-1] + + bin_centres = 0.5 * (r_xyz_bin_edges[1:] + r_xyz_bin_edges[:-1]) + + r_xyz_correlation_interp, delta_r = calculate_average( + r_xyz_correlation, r_xyz_counts, + r_xyz_bin_edges, r_xyz_bin_edges, + total_square_sld_times_two) + + 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 + + def poissony(data, scale=100_000): + return np.random.poisson(lam=scale*data)/scale + + + # intensity = np.sum(poissony(f(bin_centres))*delta_r*sections, axis=1) + intensity = np.sum(f(bin_centres)*delta_r*sections, axis=1) + # intensity = np.sum(r_xyz_correlation*delta_r*sections, axis=1) + + + # intensity = np.abs(np.sum(r_xyz_correlation_patched*diffs, axis=1) + mean_rho*sampling.sample_volume()) + + + q_space_part = QPlotData(calculation.output_options.q_space, intensity) + + if calculation.output_options.realspace: + r_space_part = RealPlotData(bin_centres, poissony(f(bin_centres))) + # r_space_part = RealPlotData(bin_centres, r_xyz_correlation/np.max(r_xyz_correlation)) + else: + r_space_part = None + + q_space = QSpaceCalcDatum(q_space_part, r_space_part) + + if options.sampling_distributions: + sampling_distribution = SamplingDistribution( + "r_xyz", + bin_centres, + r_xyz_counts) + + sampling_distributions.append(sampling_distribution) + + if options.q_space_2d is not None: + pass + + if options.sesans: + # TODO: SESANS support + sesans_output = None + + else: + + if options.q_space: + q = calculation.output_options.q_space() + + qr = np.outer(q, r_xy_bin_edges) + + # intensity = parameters.background + parameters.scale * np.sum(np.sinc(qr), axis=1) + # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) + total_square_sld_times_two + # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) + + # Integral of r^2 sinc(qr) + sections = bessel(0, qr) + sections[:, 0] = 0 + + r_xy_correlation_interp, delta_r = calculate_average( + r_xy_correlation, r_xy_counts, + r_xy_bin_edges, r_xy_bin_edges, + total_square_sld_times_two) + + intensity = np.sum(r_xy_correlation_interp * delta_r * sections, axis=1) + # intensity = np.abs(np.sum(r_xyz_correlation_patched*diffs, axis=1) + mean_rho*sampling.sample_volume()) + + q_space_part = QPlotData(calculation.output_options.q_space, intensity) + if calculation.output_options.realspace: + r_space_part = RealPlotData(r_xy_bin_edges, r_xy_correlation_interp) + else: + r_space_part = None + + q_space = QSpaceCalcDatum(q_space_part, r_space_part) + + if options.sampling_distributions: + sampling_distribution = SamplingDistribution( + "r_xy", + 0.5*(r_xy_bin_edges[1:] + r_xy_bin_edges[:-1]), + r_xy_counts) + + sampling_distributions.append(sampling_distribution) + + if options.q_space_2d: + # USE: scipy.interpolate.CloughTocher2DInterpolator to do this + + pass + + # TODO: implement + # TODO: Check that the sampling method is appropriate - it probably isn't + pass + + return ScatteringOutput( + radial_distribution=radial_distribution_output, + q_space=q_space, + q_space_2d=None, + sesans=None, + sampling_distributions=sampling_distributions, + calculation_time=time.time() - start_time, + seed_used=seed) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/check_point_samplers.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/check_point_samplers.py new file mode 100644 index 0000000000..d61ef17e21 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/check_point_samplers.py @@ -0,0 +1,20 @@ +import matplotlib.pyplot as plt + +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import GridPointGenerator + + + +fig = plt.figure("Grid plot") +ax = fig.add_subplot(projection='3d') + +gen = GridPointGenerator(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/sampling/chunker.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py new file mode 100644 index 0000000000..18a3441652 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py @@ -0,0 +1,15 @@ + + + + +class PairwiseChunkGenerator: + """ Class that takes a point generator, and produces all pairwise combinations in chunks + + This trades off speed for space. + """ + def __init__(self): + pass + + +class NoChunks: + def __init__(self, point_generator: PointGenerator): \ 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..5717b04950 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py @@ -0,0 +1,42 @@ +from abc import ABC, abstractmethod +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 + +import math +import numpy as np + +class PointGenerator(ABC): + """ Base class for point generators """ + + def __init__(self, radius: float, n_points: int): + self.radius = radius + self.n_points = n_points + + @abstractmethod + def generate(self, start_index: int, end_index: int) -> VectorComponents3: + """ Generate points from start_index up to end_index """ + + +class GridPointGenerator(PointGenerator): + 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) + + + 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 RandomPointGenerator(PointGenerator): + pass + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py index d478f8b2ea..d40685b0bf 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py @@ -5,8 +5,6 @@ from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 - - class RandomSample(SpatialSample): def __init__(self, n_points: int, radius: float, seed: Optional[int] = None): super().__init__(n_points, radius) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py b/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py deleted file mode 100644 index 6b0b205cb7..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/scattering.py +++ /dev/null @@ -1,221 +0,0 @@ -from typing import Dict, DefaultDict, Callable, Optional, Any, Tuple, Union -from enum import Enum -from dataclasses import dataclass - -import time - -import numpy as np -from scipy.special import jv as besselJ - -from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - QSample, OrientationalDistribution, ScatteringCalculation, ScatteringOutput) - - -def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput: - """ Main function for calculating scattering""" - - start_time = time.time() # Track how long it takes - - # Calculate contribution of SLD - if calculation.orientation == OrientationalDistribution.UNORIENTED: - - print("Unoriented") - - # Try a different method, estimate the radial distribution - n_r = 1000 - n_r_upscale = 10000 - bin_edges = np.linspace(0, calculation.spatial_sampling_method.radius, n_r+1) - bin_size = calculation.spatial_sampling_method.radius / n_r - - sld = None - counts = None - sld_total = 0 - - for x0, y0, z0 in calculation.spatial_sampling_method.singles(calculation.sample_chunk_size_hint): - - # evaluate sld - input_coordinates1 = calculation.sld_function_from_cartesian(x0, y0, z0) - # input_coordinates2 = calculation.sld_function_from_cartesian(x1, y1, z1) - - sld1 = calculation.sld_function(*input_coordinates1, **calculation.sld_function_parameters) - sld1 -= calculation.solvent_sld - # - # sld2 = calculation.sld_function(*input_coordinates2, **calculation.sld_function_parameters) - # sld2 -= calculation.solvent_sld - # - # rho = sld1*sld2 - rho = sld1 - - # Do the integration - # sample_rs = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) - sample_rs = np.sqrt(x0**2 + y0**2 + z0**2) - # sample_rs = np.abs(np.sqrt(x0**2 + y0**2 + z0**2) - np.sqrt(x1**2 + y1**2 + z1**2)) - - if sld is None: - sld = np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] - counts = np.histogram(sample_rs, bins=bin_edges)[0] - else: - sld += np.histogram(sample_rs, bins=bin_edges, weights=rho)[0] - counts += np.histogram(sample_rs, bins=bin_edges)[0] - - sld_total += np.sum(sld1) #+ np.sum(sld2) - - if counts is None or sld is None: - raise ValueError("No sample points") - - - # Remove all zero count bins - non_empty_bins = counts > 0 - - # print(np.count_nonzero(non_empty_bins)) - - # Calculate the mean sld at each radius - r_small = (bin_edges[:-1] + 0.5 * bin_size)[non_empty_bins] - sld_average = sld[non_empty_bins] / counts[non_empty_bins] - - - # Upscale - r_upscaled = np.arange(0, n_r_upscale+1) * (calculation.spatial_sampling_method.radius / n_r_upscale) - # bin_centres = 0.5*(r_upscaled[1:] + r_upscaled[:-1]) - upscaled_sld_average = np.interp(r_upscaled, r_small, sld_average) - - - # - # Do transform - # - - q = calculation.q_sampling_method() - qr = np.outer(q, r_upscaled[1:]) - # - # # Power of q must be -1 for correct slope at low q - # f = np.sum((new_averages * (r_large * r_large)) * np.sinc(qr/np.pi), axis=1) # Correct for sphere with COM sampling - - # Change in sld for each bin - deltas = np.diff(upscaled_sld_average) - - # r_large is the right hand bin entry - - factors = (np.sin(qr) - qr * np.cos(qr)) / (qr ** 3) - # - # import matplotlib.pyplot as plt - # start_count = 10 - # for i, delta in enumerate(deltas): - # if i%10 == 0: - # if delta != 0: - # f = np.sum(deltas[:i] * (r_upscaled[1:i+1]**2) * factors[:, :i], axis=1) - # f /= f[0] - # if start_count > 0: - # plt.loglog(q, f*f, color='r') - # else: - # plt.loglog(q, f*f, color='k') - # start_count -= 1 - # plt.show() - - f = np.sum(deltas * factors, axis=1) - # f = np.sum((deltas * (r_upscaled[1:]**2)) * factors, axis=1) - - # Value at qr=0 - # mean_density = sld_total / (2*calculation.spatial_sampling_method.n_actual) # 2 because we sampled two points above - # f0 = mean_density * calculation.spatial_sampling_method.sample_volume() - - - intensity = f**2 # Correct for sphere with COM sampling - - intensity /= calculation.spatial_sampling_method.n_actual - - # intensity += 1e-12 - - # Calculate magnet contribution - # TODO: implement magnetic scattering - - # Wrap up - - calculation_time = time.time() - start_time - - return ScatteringOutput( - output_type=calculation.output_type, - q_sampling_method=calculation.q_sampling_method, - spatial_sampling_method=calculation.spatial_sampling_method, - intensity_data=intensity, - calculation_time=calculation_time, - r_values=r_upscaled, - realspace_intensity=upscaled_sld_average) - - - elif calculation.orientation == OrientationalDistribution.FIXED: - - print("Oriented") - - # Try a different method, estimate the radial distribution - - - sld = None - counts = None - sld_total = 0 - q = calculation.q_sampling_method() - - n_r = 1000 - n_r_upscale = 10000 - bin_edges = np.linspace(0, calculation.spatial_sampling_method.radius, n_r + 1) - bin_size = calculation.spatial_sampling_method.radius / n_r - - for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method.pairs(calculation.sample_chunk_size_hint): - - # evaluate sld - input_coordinates1 = calculation.sld_function_from_cartesian(x0, y0, z0) - input_coordinates2 = calculation.sld_function_from_cartesian(x1, y1, z1) - - sld1 = calculation.sld_function(*input_coordinates1, **calculation.sld_function_parameters) - sld1 -= calculation.solvent_sld - - sld2 = calculation.sld_function(*input_coordinates2, **calculation.sld_function_parameters) - sld2 -= calculation.solvent_sld - - rho = sld1*sld2 - - r_xy = np.sqrt((x1-x0)**2 + (y1-y0)**2) - - if sld is None: - sld = np.histogram(r_xy, bins=bin_edges, weights=rho)[0] - counts = np.histogram(r_xy, bins=bin_edges)[0] - else: - sld += np.histogram(r_xy, bins=bin_edges, weights=rho)[0] - counts += np.histogram(r_xy, bins=bin_edges)[0] - - sld_total += np.sum(sld1) + np.sum(sld2) - - if counts is None or sld is None: - raise ValueError("No sample points") - - - # Value at qr=zero - f /= calculation.spatial_sampling_method.n_actual - - mean_density = sld_total / calculation.spatial_sampling_method.n_actual - f0 = mean_density / calculation.spatial_sampling_method.sample_volume() - - - # intensity = f*f # Correct for sphere with COM sampling - # intensity = np.real(f) - intensity = f + f0 - - # intensity = np.real(fft) - - # intensity = (f + f0)**2 - - - # Calculate magnet contribution - # TODO: implement magnetic scattering - - # Wrap up - - calculation_time = time.time() - start_time - - return ScatteringOutput( - output_type=calculation.output_type, - q_sampling_method=calculation.q_sampling_method, - spatial_sampling_method=calculation.spatial_sampling_method, - intensity_data=intensity, - calculation_time=calculation_time, - r_values=None, - realspace_intensity=None) From 04cdade3a034163e736a1983629b8c2c952a46d6 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Fri, 28 Jul 2023 14:37:23 +0100 Subject: [PATCH 25/38] Work on new calculation function refactor --- .../ParticleEditor/calculations/debye.py | 4 ++- .../ParticleEditor/calculations/run_all.py | 17 +++++++++- .../calculations/run_function.py | 32 +++++++++++++++++++ .../ParticleEditor/sampling/chunker.py | 27 +++++++++++++--- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py index 7b1c036e9f..d3e1db6dd7 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py @@ -11,6 +11,8 @@ QPlotData, QSpaceCalcDatum, RealPlotData, SLDDefinition, MagnetismDefinition, SpatialSample, QSample, CalculationParameters) +from sas.qtgui.Perspectives.ParticleEditor.sampling.chunker import NoChunks + def debye( sld_function: SLDDefinition, magnetism_function: Optional[MagnetismDefinition], @@ -18,4 +20,4 @@ def debye( spatial_sample: SpatialSample, q_sample: QSample): - for (x1, y1, z1), (x2, y2, z2) in \ No newline at end of file + for (x1, y1, z1), (x2, y2, z2) in NoChunks(spatial_sample): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py index a4e61552ed..c5f54ed059 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py @@ -1,9 +1,24 @@ from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringCalculation, OrientationalDistribution from sas.qtgui.Perspectives.ParticleEditor.calculations.debye import debye +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): + + # 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: - pass + 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 ") sld_def = calculation.particle_definition.sld mag_def = calculation.particle_definition.magnetism 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..b7329e47ce --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py @@ -0,0 +1,32 @@ +""" Help 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) + +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/sampling/chunker.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py index 18a3441652..61dea9e165 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py @@ -1,15 +1,32 @@ +from typing import Tuple +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import PointGenerator +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 +from abc import ABC, abstractmethod +class Chunker(ABC): + def __init__(self, point_generator: PointGenerator): + self.point_generator = point_generator -class PairwiseChunkGenerator: + 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. """ - def __init__(self): - pass + #TODO + + +class NoChunks(Chunker): -class NoChunks: - def __init__(self, point_generator: PointGenerator): \ No newline at end of file + def _iterator(self): + points = self.point_generator.generate(0, self.point_generator.n_points) + yield points, points \ No newline at end of file From eec088eeec9e777774ad5d0d2b01a66cfd1a54d3 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Tue, 1 Aug 2023 10:18:45 +0100 Subject: [PATCH 26/38] Temp stop point --- .../ParticleEditor/calculations/debye.py | 19 ++++- .../calculations/run_function.py | 4 +- .../ParticleEditor/sampling/chunker.py | 32 -------- .../ParticleEditor/sampling/chunking.py | 74 +++++++++++++++++++ .../ParticleEditor/sampling/points.py | 46 +++++++++++- 5 files changed, 135 insertions(+), 40 deletions(-) delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py index d3e1db6dd7..c6a1f57e72 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py @@ -11,13 +11,24 @@ QPlotData, QSpaceCalcDatum, RealPlotData, SLDDefinition, MagnetismDefinition, SpatialSample, QSample, CalculationParameters) -from sas.qtgui.Perspectives.ParticleEditor.sampling.chunker import NoChunks +from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk + +from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism def debye( - sld_function: SLDDefinition, - magnetism_function: Optional[MagnetismDefinition], + sld_definition: SLDDefinition, + magnetism_definition: Optional[MagnetismDefinition], parameters: CalculationParameters, spatial_sample: SpatialSample, q_sample: QSample): - for (x1, y1, z1), (x2, y2, z2) in NoChunks(spatial_sample): + # First chunking layer, chunks for dealing with VERY large sample densities + # Uses less memory at the cost of using more processing power + for (x1, y1, z1), (x2, y2, z2) in SingleChunk(spatial_sample): + sld1 = run_sld(sld_definition, parameters, x1, y1, z1) + sld2 = run_sld(sld_definition, parameters, x2, y2, z2) + + if magnetism_definition is not None: + pass + # TODO: implement magnetism + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py index b7329e47ce..81de04e61f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py @@ -1,10 +1,11 @@ -""" Help functions that run SLD and magnetism functions """ +""" 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 """ @@ -18,6 +19,7 @@ def run_sld(sld_definition: SLDDefinition, parameters: CalculationParameters, x: return sld_function(a, b, c, **parameter_dict) + 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 """ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py deleted file mode 100644 index 61dea9e165..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunker.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Tuple - -from sas.qtgui.Perspectives.ParticleEditor.sampling.points import PointGenerator -from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 - -from abc import ABC, abstractmethod - -class Chunker(ABC): - def __init__(self, point_generator: PointGenerator): - 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 NoChunks(Chunker): - - def _iterator(self): - points = self.point_generator.generate(0, self.point_generator.n_points) - yield points, points \ No newline at end of file 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..49682d879a --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py @@ -0,0 +1,74 @@ +""" + +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 + +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import PointGenerator +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 + +from abc import ABC, abstractmethod + + +class Chunker(ABC): + def __init__(self, point_generator: PointGenerator): + 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 + + +def pairwise_chunk_iterator(left_data: Sequence[Sequence[Any]], right_data: Sequence[Sequence[Any]]) -> Tuple[Sequence[Any], Sequence[Any]]: + """ Generator to do chunking""" \ 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 index 5717b04950..b95e803f3c 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py @@ -1,27 +1,43 @@ +from typing import Dict + from abc import ABC, abstractmethod from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 import math import numpy as np +from collections import defaultdict + + class PointGenerator(ABC): """ Base class for point generators """ - def __init__(self, radius: float, n_points: int): + 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 """ class GridPointGenerator(PointGenerator): + """ 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) + 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: @@ -37,6 +53,30 @@ def generate(self, start_index: int, end_index: int) -> VectorComponents3: (((z_inds + 0.5) / self.n_points_per_axis) - 0.5) * self.radius) class RandomPointGenerator(PointGenerator): - pass + """ 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] From 8a6c53f9be85e43c323738c7e3d7c35e59bf5b1f Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Fri, 4 Aug 2023 14:54:08 +0100 Subject: [PATCH 27/38] Optimised debye implementation --- .../ParticleEditor/calculations/debye.py | 83 +++++++++++++++++-- .../ParticleEditor/sampling/chunking.py | 45 +++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py index c6a1f57e72..d227f90cf2 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py @@ -11,7 +11,7 @@ QPlotData, QSpaceCalcDatum, RealPlotData, SLDDefinition, MagnetismDefinition, SpatialSample, QSample, CalculationParameters) -from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk +from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk, pairwise_chunk_iterator from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism @@ -20,15 +20,84 @@ def debye( magnetism_definition: Optional[MagnetismDefinition], parameters: CalculationParameters, spatial_sample: SpatialSample, - q_sample: QSample): + q_sample: QSample, + minor_chunk_size=1000, + preallocate=True): + + q = q_sample().reshape(1,-1) + + 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, y1, z1), (x2, y2, z2) in SingleChunk(spatial_sample): - sld1 = run_sld(sld_definition, parameters, x1, y1, z1) - sld2 = run_sld(sld_definition, parameters, x2, y2, z2) + for (x1_large, y1_large, z1_large), (x2_large, y2_large, z2_large) in SingleChunk(spatial_sample): + 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: - if magnetism_definition is not None: - pass + 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 (x1, y1, z1, sld_total_1), (x2, y2, z2, sld_total_2) 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): + + + + # Optimised calculation of euclidean distance, use pre-allocated memory + + if preallocate: + n1 = len(x1) + n2 = len(x2) + + r_data_1[:n1, :n2] = x1.reshape(1, -1) - x2.reshape(-1, 1) + r_data_1 **= 2 + + r_data_2[:n1, :n2] = y1.reshape(1, -1) - y2.reshape(-1, 1) + r_data_1[:n1, :n2] += r_data_2**2 + + r_data_2[:n1, :n2] += z1.reshape(1, -1) - z2.reshape(-1, 1) + r_data_1[:n1, :n2] += r_data_2 ** 2 + + np.sqrt(r_data_1, out=r_data_1) # in place sqrt + + r_data_2[:n1, :n2] = sld_total_1.reshape(1, -1) * sld_total_2.reshape(-1, 1) + + # Build a table of r times q values + + rq_data[:(n1 * n2), :] = r_data_1.reshape(-1, 1) * q.reshape(1, -1) + + # Calculate sinc part + rq_data[:(n1 * n2), :] = np.sinc(rq_data[:(n1 * n2), :]) + + + # Multiply by paired density + rq_data[:(n1 * n2), :] *= r_data_2.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.reshape(-1, 1) + + # Add to q data + output += np.sum(rq_data, axis=1) + + else: + + # Non optimised + r_squared = (x1.reshape(1, -1) - x2.reshape(-1, 1)) ** 2 + \ + (y1.reshape(1, -1) - y2.reshape(-1, 1)) ** 2 + \ + (z1.reshape(1, -1) - z2.reshape(-1, 1)) ** 2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py index 49682d879a..e8f9fb4037 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py @@ -1,3 +1,5 @@ + + """ Functions designed to help avoid using too much memory, chunks up the pairwise distributions @@ -35,6 +37,8 @@ from typing import Tuple, Sequence, Any +import math + from sas.qtgui.Perspectives.ParticleEditor.sampling.points import PointGenerator from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 @@ -70,5 +74,42 @@ def _iterator(self): yield points, points -def pairwise_chunk_iterator(left_data: Sequence[Sequence[Any]], right_data: Sequence[Sequence[Any]]) -> Tuple[Sequence[Any], Sequence[Any]]: - """ Generator to do chunking""" \ No newline at end of file +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) + + n_left = math.ceil(left_len / chunk_size) + n_right = math.ceil(right_len / chunk_size) + + for i in range(n_left): + + left_index_1 = i*chunk_size + left_index_2 = min(((i+1)*chunk_size, n_left)) + + left_chunk = (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(((i+1)*chunk_size, n_left)) + + right_chunk = (datum[right_index_1:right_index_2] for datum in right_data) + + yield left_chunk, right_chunk From 0ec395c779933ef20f66b2385cfe8a388bc4bed3 Mon Sep 17 00:00:00 2001 From: gdrosos Date: Fri, 11 Aug 2023 16:07:31 +0300 Subject: [PATCH 28/38] Remove unused dependency: h5py --- build_tools/requirements.txt | 1 - setup.py | 2 +- src/sas/system/log.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index b785e11727..c1a65bae9f 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -6,7 +6,6 @@ pytest_qt pytest-mock unittest-xml-reporting tinycc -h5py sphinx pyparsing html5lib diff --git a/setup.py b/setup.py index 1195979830..f1526f7e91 100644 --- a/setup.py +++ b/setup.py @@ -93,7 +93,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/system/log.py b/src/sas/system/log.py index ae31cdf48a..7a6066a1ec 100644 --- a/src/sas/system/log.py +++ b/src/sas/system/log.py @@ -17,7 +17,6 @@ IGNORED_PACKAGES = { 'matplotlib': 'ERROR', 'numba': 'WARN', - 'h5py': 'ERROR', 'ipykernel': 'CRITICAL', } From c04e276471cdd477544e94d4fdab4915d89c8c10 Mon Sep 17 00:00:00 2001 From: gdrosos Date: Fri, 11 Aug 2023 17:54:08 +0300 Subject: [PATCH 29/38] Revert logging changes in log.py --- src/sas/system/log.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sas/system/log.py b/src/sas/system/log.py index 7a6066a1ec..ae31cdf48a 100644 --- a/src/sas/system/log.py +++ b/src/sas/system/log.py @@ -17,6 +17,7 @@ IGNORED_PACKAGES = { 'matplotlib': 'ERROR', 'numba': 'WARN', + 'h5py': 'ERROR', 'ipykernel': 'CRITICAL', } From 2a1d03801ed3c4ecc539275e66e2b915a2f2ee44 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Wed, 16 Aug 2023 09:07:38 +0100 Subject: [PATCH 30/38] Revised sampling procedure --- .../ParticleEditor/calculations/debye.py | 57 ++++++++++++------- .../calculations/debye_benchmark.py | 57 +++++++++++++++++++ .../ParticleEditor/calculations/fq.py | 39 +++++++++++++ .../calculations/run_function.py | 2 +- .../ParticleEditor/sampling/chunking.py | 25 +++++--- .../ParticleEditor/sampling/points.py | 21 +++++++ .../sampling/test_point_generator.py | 32 +++++++++++ 7 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_point_generator.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py index d227f90cf2..c90e1d1145 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py @@ -3,8 +3,8 @@ 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, @@ -12,6 +12,7 @@ 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 PointGenerator from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism @@ -19,19 +20,19 @@ def debye( sld_definition: SLDDefinition, magnetism_definition: Optional[MagnetismDefinition], parameters: CalculationParameters, - spatial_sample: SpatialSample, + point_generator: PointGenerator, q_sample: QSample, minor_chunk_size=1000, preallocate=True): - q = q_sample().reshape(1,-1) + 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(spatial_sample): + 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) @@ -44,18 +45,21 @@ def debye( 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 (x1, y1, z1, sld_total_1), (x2, y2, z2, sld_total_2) in pairwise_chunk_iterator( + 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 @@ -63,41 +67,56 @@ def debye( n1 = len(x1) n2 = len(x2) - r_data_1[:n1, :n2] = x1.reshape(1, -1) - x2.reshape(-1, 1) + r_data_1[:n1, :n2] = np.subtract.outer(x1, x2) r_data_1 **= 2 - r_data_2[:n1, :n2] = y1.reshape(1, -1) - y2.reshape(-1, 1) - r_data_1[:n1, :n2] += r_data_2**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] += z1.reshape(1, -1) - z2.reshape(-1, 1) - r_data_1[:n1, :n2] += r_data_2 ** 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] = sld_total_1.reshape(1, -1) * sld_total_2.reshape(-1, 1) + 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), :] = r_data_1.reshape(-1, 1) * q.reshape(1, -1) + 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.reshape(-1, 1) + 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.reshape(-1, 1) + rq_data[:(n1 * n2), :] *= r_data_1[:n1, :n2].reshape(-1, 1) # Add to q data - output += np.sum(rq_data, axis=1) + output += np.sum(rq_data, axis=0) else: # Non optimised - r_squared = (x1.reshape(1, -1) - x2.reshape(-1, 1)) ** 2 + \ - (y1.reshape(1, -1) - y2.reshape(-1, 1)) ** 2 + \ - (z1.reshape(1, -1) - z2.reshape(-1, 1)) ** 2 + 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..2413fd2f9a --- /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 GridPointGenerator + +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 = GridPointGenerator(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..eacae6d8cc --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py @@ -0,0 +1,39 @@ +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, + QPlotData, QSpaceCalcDatum, RealPlotData, + 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 PointGenerator, PointGeneratorStepper + +from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism + +def calculate_fq_vectors( + sld_definition: SLDDefinition, + magnetism_definition: Optional[MagnetismDefinition], + parameters: CalculationParameters, + point_generator: PointGenerator, + q_sample: QSample, + q_normal_vector: np.ndarray, + chunk_size=1_000_000) -> np.ndarray: + + q_magnitudes = q_sample() + + for x, y, z in PointGeneratorStepper(point_generator, chunk_size): + + sld = run_sld(sld_definition, parameters, x, y, z) + + # TODO: Magnetism + + r = np.sqrt(x*q_normal_vector[0] + y*q_normal_vector[1] + z*q_normal_vector[2]) + + rq = + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py index 81de04e61f..4f4225340c 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py @@ -17,7 +17,7 @@ def run_sld(sld_definition: SLDDefinition, parameters: CalculationParameters, x: 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) + 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: diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py index e8f9fb4037..de5e2d0611 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py @@ -50,7 +50,7 @@ def __init__(self, point_generator: PointGenerator): self.point_generator = point_generator def __iter__(self): - return self._iterator + return self._iterator() @abstractmethod def _iterator(self) -> Tuple[VectorComponents3, VectorComponents3]: @@ -85,6 +85,7 @@ def sublist_lengths(data: Sequence[Sequence[Any]]) -> int: 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]], @@ -95,21 +96,31 @@ def pairwise_chunk_iterator( left_len = sublist_lengths(left_data) right_len = sublist_lengths(right_data) - n_left = math.ceil(left_len / chunk_size) - n_right = math.ceil(right_len / chunk_size) + # 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, n_left)) + left_index_2 = min(((i+1)*chunk_size, left_len)) - left_chunk = (datum[left_index_1:left_index_2] for datum in left_data) + 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(((i+1)*chunk_size, n_left)) + 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) - right_chunk = (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/points.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py index b95e803f3c..d528d1128f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py @@ -27,6 +27,8 @@ def generate(self, start_index: int, end_index: int) -> VectorComponents3: """ Generate points from start_index up to end_index """ + + class GridPointGenerator(PointGenerator): """ Generate points on a grid within a cube with side length 2*radius """ def __init__(self, radius: float, desired_points: int): @@ -80,3 +82,22 @@ def generate(self, start_index: int, end_index: int) -> VectorComponents3: return xyz[:, 0], xyz[:, 1], xyz[:, 2] +class PointGeneratorStepper: + """ Generate batches of step_size points from a PointGenerator instance""" + + def __init__(self, point_generator: PointGenerator, 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/sampling/test_point_generator.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_point_generator.py new file mode 100644 index 0000000000..50b1422dfa --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_point_generator.py @@ -0,0 +1,32 @@ + +from pytest import mark + +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import ( + GridPointGenerator, + RandomPointGenerator, + PointGeneratorStepper) + + + +@mark.parametrize("splits", [42, 100, 381,999]) +@mark.parametrize("npoints", [100,1000,8001]) +def test_with_grid(npoints, splits): + point_generator = GridPointGenerator(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 = RandomPointGenerator(100, npoints) + n_measured = 0 + for x, y, z in PointGeneratorStepper(point_generator, splits): + n_measured += len(x) + + assert n_measured == npoints + + + From 975a1b698198a51ffb82349cf5fc819cefbda44d Mon Sep 17 00:00:00 2001 From: Ellis Hewins Date: Thu, 17 Aug 2023 16:20:12 +0100 Subject: [PATCH 31/38] Fixed unmatched method signatures in Box&Wedge Interactor child classes --- src/sas/qtgui/Plotting/Slicers/BoxSlicer.py | 15 ++++++++++----- src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py | 16 ++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py index 65760e6f66..00e26b91e8 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) @@ -256,7 +257,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): @@ -273,6 +274,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): """ """ @@ -383,6 +385,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): """ """ @@ -493,12 +496,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 """ @@ -526,12 +530,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/WedgeSlicer.py b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py index 03ac2978a8..38fc9f9918 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 SectorInteractorQ 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) @@ -206,7 +208,6 @@ def _post_data(self, new_sector=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 @@ -327,18 +328,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.manipulations import SectorQ super()._post_data(SectorQ) @@ -349,12 +352,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.manipulations import SectorPhi super()._post_data(SectorPhi) From c52dee1bc4d8dc4e6bb0834bd37d8ff267248775 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 18 Aug 2023 16:42:05 +0200 Subject: [PATCH 32/38] just pass the Data* object, since it is going to be assigned directly --- src/sas/qtgui/Calculators/DataOperationUtilityPanel.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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 From 35ad0acf96e6c62208f5aa15627df03206b9d359 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Thu, 31 Aug 2023 16:17:08 +0100 Subject: [PATCH 33/38] Interface for selecting orientational distribution --- .../AngularSamplingMethodSelector.py | 120 +++++++ .../ParticleEditor/GeodesicSampleSelector.py | 57 ++++ .../ParticleEditor/calculations/fq.py | 15 +- .../ParticleEditor/datamodel/calculation.py | 1 - .../ParticleEditor/datamodel/sampling.py | 68 ---- .../ParticleEditor/sampling/__init__.py | 0 .../ParticleEditor/sampling/angles.py | 79 +++++ .../ParticleEditor/sampling/geodesic.py | 316 ++++++++++++++++++ .../ParticleEditor/sampling/test_angles.py | 54 +++ 9 files changed, 639 insertions(+), 71 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/GeodesicSampleSelector.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/__init__.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/geodesic.py create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_angles.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py new file mode 100644 index 0000000000..04da375923 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py @@ -0,0 +1,120 @@ +from typing import List, Tuple + +from PySide6.QtWidgets import QWidget, QVBoxLayout, QFormLayout, QComboBox, QDoubleSpinBox + +from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import GeodesicDivisions +from sas.qtgui.Perspectives.ParticleEditor.sampling.angles import angular_sampling_methods, AngularDistribution +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/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/calculations/fq.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py index eacae6d8cc..5cf488f649 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py @@ -16,7 +16,7 @@ from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism -def calculate_fq_vectors( +def scattering_via_fq( sld_definition: SLDDefinition, magnetism_definition: Optional[MagnetismDefinition], parameters: CalculationParameters, @@ -27,6 +27,8 @@ def calculate_fq_vectors( q_magnitudes = q_sample() + fq = None + for x, y, z in PointGeneratorStepper(point_generator, chunk_size): sld = run_sld(sld_definition, parameters, x, y, z) @@ -35,5 +37,14 @@ def calculate_fq_vectors( r = np.sqrt(x*q_normal_vector[0] + y*q_normal_vector[1] + z*q_normal_vector[2]) - rq = + i_r_dot_q = np.multiply.outer(r, 1j*q_magnitudes) + + if fq is None: + fq = np.sum(sld*np.exp(i_r_dot_q), axis=0) + else: + fq += np.sum(sld*np.exp(i_r_dot_q), axis=0) + + return fq.real**2 + fq.imag**2 # Best way to avoid pointless square roots in np.abs + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index 1bc43cae53..f15117ae60 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -3,7 +3,6 @@ from enum import Enum from dataclasses import dataclass -from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import ( SLDFunction, MagnetismFunction, CoordinateSystemTransform) from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import CalculationParameters diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py deleted file mode 100644 index f6e804bb3a..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/sampling.py +++ /dev/null @@ -1,68 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Tuple - -from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 - - -class SpatialSample(ABC): - """ Base class for spatial sampling methods""" - def __init__(self, n_points, radius): - self.n_points = n_points - self.radius = radius - - @abstractmethod - def __call__(self, size_hint: int) -> (VectorComponents3, VectorComponents3): - """ Get pairs of points """ - - @abstractmethod - def bounding_surface_check_points(self) -> VectorComponents3: - """ Points that are used to check that the SLD is consistent - with the solvent SLD at the edge of the sampling space""" - - @abstractmethod - def max_xyz(self): - """ maximum distance between in points in X, Y and Z - - For non-oriented scattering - """ - - @abstractmethod - def max_xy(self): - """ Maximum distance between points in X,Y projection - - For Oriented 1D SANS - """ - - @abstractmethod - def max_principal_axis(self): - """ Maximum distance between points in any principal axis""" - - - def max_x(self): - """ Maximum distance between points in X projection - - For Oriented 2D SANS - """ - return self.max_principal_axis() - - def max_y(self): - """ Maximum distance between points in Y projection - - For Oriented 2D SANS - """ - return self.max_principal_axis() - - def max_z(self): - """ Maximum distance between points in Z projection - - For completeness - """ - return self.max_principal_axis() - - def __repr__(self): - return "%s(n=%i,r=%g)" % (self.__class__.__name__, self.n_points, self.radius) - - @abstractmethod - def sample_volume(self) -> float: - """ Volume of sample area """ - 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..8f826fb655 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py @@ -0,0 +1,79 @@ + +""" + +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 +from abc import ABC, abstractmethod +import numpy as np + +from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic, GeodesicDivisions + + +class AngularDistribution(ABC): + """ Base class for angular distributions """ + + @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""" + + +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]) + + @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 + + @staticmethod + def name(): + return "Unoriented" + + def sample_points_and_weights(self) -> Tuple[np.ndarray, np.ndarray]: + return Geodesic.by_divisions(self.divisions) + + @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/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/test_angles.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_angles.py new file mode 100644 index 0000000000..ae56ce5f80 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/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 From 081016685cabf448224556c75af3c73a5b30a1c9 Mon Sep 17 00:00:00 2001 From: gdrosos Date: Sun, 3 Sep 2023 14:37:37 +0300 Subject: [PATCH 34/38] Add h5py to requirements.txt --- build_tools/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index 89997c3d53..0d4d43d8a7 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -6,6 +6,7 @@ pytest_qt pytest-mock unittest-xml-reporting tinycc +h5py sphinx pyparsing html5lib From 72afb91cb97680a724776fba7df4a4dcb198a519 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 4 Sep 2023 13:37:23 +0100 Subject: [PATCH 35/38] Re-implemented scattering calculation --- .../AngularSamplingMethodSelector.py | 3 +- .../ParticleEditor/DesignWindow.py | 105 +--- .../ParticleEditor/Plots/QCanvas.py | 1 - .../ParticleEditor/UI/DesignWindowUI.ui | 265 +++------- .../ParticleEditor/calculations/calculate.py | 65 +++ .../ParticleEditor/calculations/debye.py | 6 +- .../calculations/debye_benchmark.py | 4 +- .../ParticleEditor/calculations/fq.py | 34 +- .../ParticleEditor/calculations/run_all.py | 29 -- .../ParticleEditor/datamodel/calculation.py | 83 ++-- .../check_point_samplers.py | 4 +- .../ParticleEditor/old_calculations.py | 330 ------------- .../ParticleEditor/sampling/angles.py | 30 +- .../ParticleEditor/sampling/chunking.py | 4 +- .../ParticleEditor/sampling/points.py | 61 ++- .../ParticleEditor/sampling_method_tests.py | 128 ----- .../ParticleEditor/sampling_methods.py | 467 ------------------ .../{sampling => tests}/test_angles.py | 0 .../test_point_generator.py | 8 +- 19 files changed, 292 insertions(+), 1335 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py rename src/sas/qtgui/Perspectives/ParticleEditor/{sampling => debug}/check_point_samplers.py (85%) delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py delete mode 100644 src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py rename src/sas/qtgui/Perspectives/ParticleEditor/{sampling => tests}/test_angles.py (100%) rename src/sas/qtgui/Perspectives/ParticleEditor/{sampling => tests}/test_point_generator.py (82%) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py index 04da375923..783691127a 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py @@ -2,8 +2,9 @@ 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, AngularDistribution +from sas.qtgui.Perspectives.ParticleEditor.sampling.angles import angular_sampling_methods from sas.qtgui.Perspectives.ParticleEditor.GeodesicSampleSelector import GeodesicSamplingSpinBox class ParametersForm(QWidget): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 51d6d964ce..931029e2a8 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -8,39 +8,38 @@ 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.RDFCanvas import RDFCanvas -from sas.qtgui.Perspectives.ParticleEditor.Plots.CorrelationCanvas import CorrelationCanvas from sas.qtgui.Perspectives.ParticleEditor.Plots.QCanvas import QCanvas -from sas.qtgui.Perspectives.ParticleEditor.Plots.SamplingDistributionCanvas import SamplingDistributionCanvas -from sas.qtgui.Perspectives.ParticleEditor.UI.DesignWindowUI import Ui_DesignWindow 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.sampling_methods import ( - SpatialSample, - MixedSphereSample, MixedCubeSample, - UniformSphereSample, UniformCubeSample -) from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - OrientationalDistribution, - QSample, ZSample, ScatteringCalculation, OutputOptions, CalculationParameters, + + 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.old_calculations import calculate_scattering +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) @@ -86,7 +85,6 @@ def __init__(self, parent=None): topWidget = QtWidgets.QWidget() topWidget.setLayout(topSection) - splitter.addWidget(topWidget) splitter.addWidget(self.outputViewer) splitter.setStretchFactor(0, 3) @@ -117,11 +115,10 @@ def __init__(self, parent=None): # Ensemble tab # - # Populate combo boxes - self.orientationCombo.addItem("Unoriented") - self.orientationCombo.addItem("Fixed Orientation") - self.orientationCombo.setCurrentIndex(0) + self.angularSamplingMethodSelector = AngularSamplingMethodSelector() + + self.topLayout.addWidget(self.angularSamplingMethodSelector, 0, 1) self.structureFactorCombo.addItem("None") # TODO: Structure Factor Options @@ -129,7 +126,7 @@ def __init__(self, parent=None): # # Calculation Tab # - self.methodComboOptions = ["Sphere Monte Carlo", "Cube Monte Carlo"] + self.methodComboOptions = ["Grid", "Random"] for option in self.methodComboOptions: self.methodCombo.addItem(option) @@ -148,34 +145,6 @@ def __init__(self, parent=None): # Output Tabs # - # Sampling Canvas - self.samplingCanvas = SamplingDistributionCanvas() - - outputLayout = QtWidgets.QVBoxLayout() - outputLayout.addWidget(self.samplingCanvas) - - self.samplingTab.setLayout(outputLayout) - - # RDF - - self.rdfCanvas = RDFCanvas() - - outputLayout = QtWidgets.QVBoxLayout() - outputLayout.addWidget(self.rdfCanvas) - - self.rdfTab.setLayout(outputLayout) - - # Corrleations - - self.correlationCanvas = CorrelationCanvas() - - outputLayout = QtWidgets.QVBoxLayout() - outputLayout.addWidget(self.correlationCanvas) - - self.correlationTab.setLayout(outputLayout) - - # Output - self.outputCanvas = QCanvas() outputLayout = QtWidgets.QVBoxLayout() @@ -276,36 +245,13 @@ def doBuild(self): self.codeError(e.args[0]) return False - def outputOptions(self) -> OutputOptions: - """ Get the OutputOptions object representing the desired outputs from the calculation """ - - q_space = self.qSampling() if self.sas1DOption.isChecked() else None - q_space_2d = None - sesans = None - - return OutputOptions( - radial_distribution=self.includeRadialDistribution.isChecked(), - sampling_distributions=self.includeSamplingDistribution.isChecked(), - realspace=self.includeCorrelations.isChecked(), - q_space=q_space, - q_space_2d=q_space_2d, - sesans=sesans) - - def orientationalDistribution(self) -> OrientationalDistribution: - """ Get the OrientationalDistribution object that represents the GUI selected orientational distribution""" - orientation_index = self.orientationCombo.currentIndex() - - if orientation_index == 0: - orientation = OrientationalDistribution.UNORIENTED - elif orientation_index == 1: - orientation = OrientationalDistribution.FIXED - else: - raise ValueError("Unknown index for orientation combo") - return orientation + def angularDistribution(self) -> AngularDistribution: + """ Get the AngularDistribution object that represents the GUI selected orientational distribution""" + return self.angularSamplingMethodSelector.generate_sampler() - def spatialSampling(self) -> SpatialSample: + def spatialSampling(self) -> SpatialDistribution: """ Calculate the spatial sampling object based on current gui settings""" sample_type = self.methodCombo.currentIndex() @@ -315,11 +261,11 @@ def spatialSampling(self) -> SpatialSample: seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None if sample_type == 0: - return UniformSphereSample(radius=radius, n_points=n_points, seed=seed) + return GridSampling(radius=radius, n_points=n_points, seed=seed) # return MixedSphereSample(radius=radius, n_points=n_points, seed=seed) elif sample_type == 1: - return UniformCubeSample(radius=radius, n_points=n_points, seed=seed) + return UniformCubeSampling(radius=radius, n_points=n_points, seed=seed) # return MixedCubeSample(radius=radius, n_points=n_points, seed=seed) else: @@ -351,15 +297,15 @@ def parametersForCalculation(self) -> CalculationParameters: 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 to """ - output_options = self.outputOptions() - orientation = self.orientationalDistribution() + is to be passed to the solver""" + angular_distribution = self.angularDistribution() spatial_sampling = self.spatialSampling() particle_definition = self.particleDefinition() parameter_definition = self.parametersForCalculation() @@ -368,8 +314,7 @@ def scatteringCalculation(self) -> ScatteringCalculation: bounding_surface_check = self.continuityCheck.isChecked() return ScatteringCalculation( - output_options=output_options, - orientation=orientation, + angular_sampling=angular_distribution, spatial_sampling_method=spatial_sampling, particle_definition=particle_definition, parameter_settings=parameter_definition, diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py index e87d50c37c..4d55803f04 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py @@ -5,7 +5,6 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure -from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput import numpy as np def spherical_form_factor(q, r): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 4a60f624d9..4ecfe96564 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -6,8 +6,8 @@ 0 0 - 994 - 480 + 992 + 477 @@ -20,14 +20,14 @@ - 0 + 2 Definition - + Parameters @@ -71,7 +71,7 @@ - + 10 @@ -101,9 +101,6 @@ - - - @@ -199,14 +196,7 @@ - - - - Ang - - - - + Q Max @@ -216,14 +206,14 @@ - - + + - 0.5 + Ang - + 10 @@ -239,7 +229,7 @@ - + Logaritmic (applies only to 1D) @@ -249,14 +239,31 @@ - + Neutron Polarisation - + + + + 0.5 + + + + + + + Q Samples + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + @@ -281,17 +288,7 @@ - - - - Q Samples - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - + Check sampling boundary for SLD continuity @@ -301,7 +298,7 @@ - + @@ -311,61 +308,10 @@ - - - - Outputs - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - 0 - - - - - 1D SAS - - - true - - - - - - - 2D SAS - - - - - - - SESANS - - - - - - - - - Sample Radius - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - + - + @@ -395,47 +341,75 @@ - - + + - Sample Method + Sample Radius Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + - Random Seed + Q Min Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + - Q Min + Sample Points Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + - Sample Points + Sample Method Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - + + + + Random Seed + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + 0 + + + + + + + Fix Seed + + + + + + @@ -459,88 +433,20 @@ - - - - - - 0 - - - - - - - Fix Seed - - - - - - + Ang - + 0.0005 - - - - - - Sampling Distribution - - - true - - - - - - - Radial Distribution - - - true - - - - - - - Correlations - - - true - - - - - - - - - - 0 - 0 - - - - Extra Outputs - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - @@ -577,26 +483,11 @@ - + Fitting - - - Sampling Distribution - - - - - RDF - - - - - Correlations - - Q Space 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..2ca1893c8a --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py @@ -0,0 +1,65 @@ +import time + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringCalculation, 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) + + output = ScatteringOutput( + q_space=scattering, + 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 index c90e1d1145..6d3d7e9d68 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py @@ -8,11 +8,11 @@ from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( ScatteringCalculation, ScatteringOutput, OrientationalDistribution, SamplingDistribution, - QPlotData, QSpaceCalcDatum, RealPlotData, + 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 PointGenerator +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism @@ -20,7 +20,7 @@ def debye( sld_definition: SLDDefinition, magnetism_definition: Optional[MagnetismDefinition], parameters: CalculationParameters, - point_generator: PointGenerator, + point_generator: SpatialDistribution, q_sample: QSample, minor_chunk_size=1000, preallocate=True): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py index 2413fd2f9a..a4badafed1 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py @@ -3,7 +3,7 @@ import matplotlib.pyplot as plt from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import SLDDefinition, CalculationParameters, QSample -from sas.qtgui.Perspectives.ParticleEditor.sampling.points import GridPointGenerator +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid from sas.qtgui.Perspectives.ParticleEditor.calculations.debye import debye @@ -30,7 +30,7 @@ def transform(x,y,z): sld_parameters={}, magnetism_parameters={}) -point_generator = GridPointGenerator(100, 10_000) +point_generator = Grid(100, 10_000) q = QSample(1e-3, 1, 101, True) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py index 5cf488f649..957644112e 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py @@ -7,12 +7,12 @@ from scipy.spatial.distance import cdist from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - ScatteringCalculation, ScatteringOutput, OrientationalDistribution, SamplingDistribution, - QPlotData, QSpaceCalcDatum, RealPlotData, - SLDDefinition, MagnetismDefinition, SpatialSample, QSample, CalculationParameters) + ScatteringCalculation, ScatteringOutput, SamplingDistribution, + QSpaceScattering, QSpaceCalcDatum, RealSpaceScattering, + 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 PointGenerator, PointGeneratorStepper +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution, PointGeneratorStepper from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism @@ -20,14 +20,15 @@ def scattering_via_fq( sld_definition: SLDDefinition, magnetism_definition: Optional[MagnetismDefinition], parameters: CalculationParameters, - point_generator: PointGenerator, + point_generator: SpatialDistribution, q_sample: QSample, - q_normal_vector: np.ndarray, + angular_distribution: AngularDistribution, chunk_size=1_000_000) -> np.ndarray: q_magnitudes = q_sample() - fq = None + direction_vectors, direction_weights = angular_distribution.sample_points_and_weights() + fq = np.zeros((angular_distribution.n_points, q_sample.n_points)) # Dictionary for fq for all angles for x, y, z in PointGeneratorStepper(point_generator, chunk_size): @@ -35,16 +36,21 @@ def scattering_via_fq( # TODO: Magnetism - r = np.sqrt(x*q_normal_vector[0] + y*q_normal_vector[1] + z*q_normal_vector[2]) + for direction_index, direction_vector in enumerate(direction_vectors): - i_r_dot_q = np.multiply.outer(r, 1j*q_magnitudes) + r = np.sqrt(x*direction_vector[0] + y*direction_vector[1] + z*direction_vectors[2]) - if fq is None: - fq = np.sum(sld*np.exp(i_r_dot_q), axis=0) - else: - fq += np.sum(sld*np.exp(i_r_dot_q), axis=0) + i_r_dot_q = np.multiply.outer(r, 1j*q_magnitudes) - return fq.real**2 + fq.imag**2 # Best way to avoid pointless square roots in np.abs + 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 + + return np.sum(f_squared, axis=0) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py deleted file mode 100644 index c5f54ed059..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_all.py +++ /dev/null @@ -1,29 +0,0 @@ -from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringCalculation, OrientationalDistribution -from sas.qtgui.Perspectives.ParticleEditor.calculations.debye import debye -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): - - # 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 ") - - sld_def = calculation.particle_definition.sld - mag_def = calculation.particle_definition.magnetism - params = calculation.parameter_settings - - if calculation.orientation == OrientationalDistribution.UNORIENTED: - if calculation.output_options.q_space: - debye_data = debye(sld_def, mag_def, params, ) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index f15117ae60..c869202c74 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -2,11 +2,14 @@ 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 """ @@ -28,36 +31,49 @@ def __call__(self): return np.linspace(self.start, self.end, self.n_points) -class ZSample: - """ Sample of correlation space """ +class SpatialDistribution(ABC): + """ Base class for point generators """ - def __init__(self, start, end, n_points): - self.start = start - self.end = end + 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 - def __repr__(self): - return f"QSampling({self.start}, {self.end}, n={self.n_points})" + @property + def info(self): + """ Information to be displayed in the settings window next to the point number input """ + return "" - def __call__(self): - return np.linspace(self.start, self.end, self.n_points) + @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) -> VectorComponents3: + """ Points used to check that the SLD/magnetism vector are zero outside the sample space""" -@dataclass -class OutputOptions: - """ Options """ - radial_distribution: bool # Create a radial distribution function from the origin - sampling_distributions: bool # Return the sampling distributions used in the calculation - realspace: bool # Return realspace data - q_space: Optional[QSample] = None - q_space_2d: Optional[QSample] = None - sesans: Optional[ZSample] = None +class AngularDistribution(ABC): + """ Base class for angular distributions """ + @property + @abstractmethod + def n_points(self) -> int: + """ Number of sample points """ -class OrientationalDistribution(Enum): - """ Types of orientation supported """ - FIXED = "Fixed" - UNORIENTED = "Unoriented" + @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 @@ -84,9 +100,9 @@ class ParticleDefinition: @dataclass class ScatteringCalculation: """ Specification for a scattering calculation """ - output_options: OutputOptions - orientation: OrientationalDistribution - spatial_sampling_method: SpatialSample + q_sampling: QSample + angular_sampling: AngularDistribution + spatial_sampling_method: SpatialDistribution particle_definition: ParticleDefinition parameter_settings: CalculationParameters polarisation_vector: Optional[np.ndarray] @@ -103,29 +119,20 @@ class SamplingDistribution: counts: np.ndarray @dataclass -class QPlotData: +class QSpaceScattering: abscissa: QSample ordinate: np.ndarray @dataclass -class RealPlotData: +class RealSpaceScattering: abscissa: np.ndarray ordinate: np.ndarray -@dataclass -class QSpaceCalcDatum: - q_space_data: QPlotData - correlation_data: Optional[RealPlotData] - @dataclass class ScatteringOutput: - radial_distribution: Optional[Tuple[np.ndarray, np.ndarray]] - q_space: Optional[QSpaceCalcDatum] - q_space_2d: Optional[QSpaceCalcDatum] - sesans: Optional[RealPlotData] - sampling_distributions: List[SamplingDistribution] + q_space: Optional[QSpaceScattering] calculation_time: float - seed_used: int + seed_used: Optional[int] diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/check_point_samplers.py b/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py similarity index 85% rename from src/sas/qtgui/Perspectives/ParticleEditor/sampling/check_point_samplers.py rename to src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py index d61ef17e21..e59cdb3384 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/check_point_samplers.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py @@ -1,13 +1,13 @@ import matplotlib.pyplot as plt -from sas.qtgui.Perspectives.ParticleEditor.sampling.points import GridPointGenerator +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid fig = plt.figure("Grid plot") ax = fig.add_subplot(projection='3d') -gen = GridPointGenerator(100, 250) +gen = Grid(100, 250) n_total = gen.n_points diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py b/src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py deleted file mode 100644 index 6d1d69d6d3..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/old_calculations.py +++ /dev/null @@ -1,330 +0,0 @@ -from typing import Optional, Tuple -import time - -import numpy as np -from scipy.interpolate import interp1d - -from scipy.special import jv as bessel - -from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - ScatteringCalculation, ScatteringOutput, OrientationalDistribution, SamplingDistribution, - QPlotData, QSpaceCalcDatum, RealPlotData) - -def calculate_average(input_data, counts, original_bin_edges, new_bin_edges, two_times_sld_squared_sum): - """ Get averaged data, taking into account under sampling - - :returns: mean correlation, associated bin sizes""" - - total_counts = np.sum(counts) - mean_rho = 0.5 * two_times_sld_squared_sum / total_counts - - original_bin_centres = 0.5 * (original_bin_edges[1:] + original_bin_edges[:-1]) - new_bin_centres = 0.5 * (new_bin_edges[1:] + new_bin_edges[:-1]) - - non_empty = counts > 0 - means = input_data[non_empty] / counts[non_empty] - mean_function = interp1d(original_bin_centres[non_empty], means, - bounds_error=False, fill_value=(mean_rho, 0), assume_sorted=True) - - delta_rs = 0.5 * (new_bin_edges[1:] - new_bin_edges[:-1]) - - return mean_function(new_bin_centres), delta_rs - - - - -def calculate_scattering(calculation: ScatteringCalculation): - """ Main scattering calculation """ - - start_time = time.time() # Track how long it takes - - # Some dereferencing - sampling = calculation.spatial_sampling_method - parameters = calculation.parameter_settings - - # What things do we need to calculate - options = calculation.output_options - fixed_orientation = calculation.orientation == OrientationalDistribution.FIXED - no_orientation = calculation.orientation == OrientationalDistribution.UNORIENTED - - # Radial SLD distribution - a special plot - doesn't relate to the other things - do_radial_distribution = options.radial_distribution - - # Radial correlation based on distance in x, y and z - this is the quantity that matters for - # unoriented particles - do_r_xyz_correlation = no_orientation and (options.q_space is not None or options.q_space_2d is not None or options.sesans is not None) - - # Radial correlation based on distance in x, y - this is the quantity that matters for 2D - do_r_xy_correlation = fixed_orientation and options.q_space is not None - - # XY correlation - this is what we need for standard 2D SANS - do_xy_correlation = fixed_orientation and options.q_space_2d is not None - - # Z correlation - this is what we need for SESANS when the particles are oriented - do_z_correlation = fixed_orientation and options.sesans is not None - - # - # Set up output variables - # - - n_bins = calculation.bin_count - - - - radial_bin_edges = np.linspace(0, sampling.radius, n_bins+1) if do_radial_distribution else None - r_xyz_bin_edges = np.linspace(0, sampling.max_xyz(), n_bins+1) if do_r_xyz_correlation else None - r_xy_bin_edges = np.linspace(0, sampling.max_xy(), n_bins+1) if do_r_xy_correlation else None - xy_bin_edges = (np.linspace(0, sampling.max_x(), n_bins+1), - np.linspace(0, sampling.max_y(), n_bins+1)) if do_xy_correlation else None - - radial_distribution = None - radial_counts = None - r_xyz_correlation = None - r_xyz_counts = None - r_xy_correlation = None - r_xy_counts = None - xy_correlation = None - xy_counts = None - - # - # Seed - # - - # TODO: This needs to be done properly - if calculation.seed is None: - seed = 0 - else: - seed = calculation.seed - - # - # Setup for calculation - # - - sld = calculation.particle_definition.sld - sld_parameters = calculation.parameter_settings.sld_parameters - - magnetism = calculation.particle_definition.magnetism - magnetism_parameters = calculation.parameter_settings.magnetism_parameters - - total_square_sld_times_two = 0.0 - - - for (x0, y0, z0), (x1, y1, z1) in calculation.spatial_sampling_method(calculation.sample_chunk_size_hint): - - # - # Sample the SLD - # - - # TODO: Make sure global variables are accessible to the function calls - sld0 = sld.sld_function(*sld.to_cartesian_conversion(x0, y0, z0), **sld_parameters) - sld1 = sld.sld_function(*sld.to_cartesian_conversion(x1, y1, z1), **sld_parameters) - - rho = sld0 * sld1 - - # - # Build the appropriate histograms - # - - if do_radial_distribution: - - r = np.sqrt(x0**2 + y0**2 + z0**2) - - if radial_distribution is None: - radial_distribution = np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] - radial_counts = np.histogram(r, bins=radial_bin_edges)[0] - - else: - radial_distribution += np.histogram(r, bins=radial_bin_edges, weights=sld0)[0] - radial_counts += np.histogram(r, bins=radial_bin_edges)[0] - - if do_r_xyz_correlation: - - # r_xyz = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 )#+ (z1 - z0) ** 2) - r_xyz = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2) - - if r_xyz_correlation is None: - r_xyz_correlation = np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] - r_xyz_counts = np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] - - else: - r_xyz_correlation += np.histogram(r_xyz, bins=r_xyz_bin_edges, weights=rho)[0] - r_xyz_counts += np.histogram(r_xyz, bins=r_xyz_bin_edges)[0] - - if do_r_xy_correlation: - - r_xy = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) - - if r_xy_correlation is None: - r_xy_correlation = np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] - r_xy_counts = np.histogram(r_xy, bins=r_xy_bin_edges)[0] - - else: - r_xy_correlation += np.histogram(r_xy, bins=r_xy_bin_edges, weights=rho)[0] - r_xy_counts += np.histogram(r_xy, bins=r_xy_bin_edges)[0] - - if do_xy_correlation: - - x = x1 - x0 - y = y1 - y0 - - if xy_correlation is None: - xy_correlation = np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] - xy_counts = np.histogram2d(x, y, bins=xy_bin_edges)[0] - - else: - xy_correlation += np.histogram2d(x, y, bins=xy_bin_edges, weights=rho)[0] - xy_counts += np.histogram2d(x, y, bins=xy_bin_edges)[0] - - if do_z_correlation: - raise NotImplementedError("Z correlation not implemented yet") - - # - # Mean SLD squared, note we have two samples here, so don't forget to divide later - # - - total_square_sld_times_two += np.sum(sld0**2 + sld1**2) - - # - # Calculate scattering from the histograms - # - - q_space = None - - if do_radial_distribution: - bin_centres = 0.5*(radial_bin_edges[1:] + radial_bin_edges[:-1]) - - good_bins = radial_counts > 0 - - averages = radial_distribution[good_bins] / radial_counts[good_bins] - - radial_distribution_output = (bin_centres[good_bins], averages) - else: - radial_distribution_output = None - - sampling_distributions = [] - - if no_orientation: - if options.q_space is not None: - q = calculation.output_options.q_space() - - qr = np.outer(q, r_xyz_bin_edges) - - # intensity = parameters.background + parameters.scale * np.sum(np.sinc(qr), axis=1) - # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) + total_square_sld_times_two - # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) - - # Integral of r^2 sinc(qr) - sections = ((np.sin(qr) - qr * np.cos(qr)).T / q**3).T - sections[:, 0] = 0 - sections = sections[:, 1:] - sections[:, :-1] - - bin_centres = 0.5 * (r_xyz_bin_edges[1:] + r_xyz_bin_edges[:-1]) - - r_xyz_correlation_interp, delta_r = calculate_average( - r_xyz_correlation, r_xyz_counts, - r_xyz_bin_edges, r_xyz_bin_edges, - total_square_sld_times_two) - - 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 - - def poissony(data, scale=100_000): - return np.random.poisson(lam=scale*data)/scale - - - # intensity = np.sum(poissony(f(bin_centres))*delta_r*sections, axis=1) - intensity = np.sum(f(bin_centres)*delta_r*sections, axis=1) - # intensity = np.sum(r_xyz_correlation*delta_r*sections, axis=1) - - - # intensity = np.abs(np.sum(r_xyz_correlation_patched*diffs, axis=1) + mean_rho*sampling.sample_volume()) - - - q_space_part = QPlotData(calculation.output_options.q_space, intensity) - - if calculation.output_options.realspace: - r_space_part = RealPlotData(bin_centres, poissony(f(bin_centres))) - # r_space_part = RealPlotData(bin_centres, r_xyz_correlation/np.max(r_xyz_correlation)) - else: - r_space_part = None - - q_space = QSpaceCalcDatum(q_space_part, r_space_part) - - if options.sampling_distributions: - sampling_distribution = SamplingDistribution( - "r_xyz", - bin_centres, - r_xyz_counts) - - sampling_distributions.append(sampling_distribution) - - if options.q_space_2d is not None: - pass - - if options.sesans: - # TODO: SESANS support - sesans_output = None - - else: - - if options.q_space: - q = calculation.output_options.q_space() - - qr = np.outer(q, r_xy_bin_edges) - - # intensity = parameters.background + parameters.scale * np.sum(np.sinc(qr), axis=1) - # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) + total_square_sld_times_two - # intensity = np.sum((r_xyz_correlation_patched * delta_rs * r_xyz_bin_edges**2) * np.sinc(qr), axis=1) - - # Integral of r^2 sinc(qr) - sections = bessel(0, qr) - sections[:, 0] = 0 - - r_xy_correlation_interp, delta_r = calculate_average( - r_xy_correlation, r_xy_counts, - r_xy_bin_edges, r_xy_bin_edges, - total_square_sld_times_two) - - intensity = np.sum(r_xy_correlation_interp * delta_r * sections, axis=1) - # intensity = np.abs(np.sum(r_xyz_correlation_patched*diffs, axis=1) + mean_rho*sampling.sample_volume()) - - q_space_part = QPlotData(calculation.output_options.q_space, intensity) - if calculation.output_options.realspace: - r_space_part = RealPlotData(r_xy_bin_edges, r_xy_correlation_interp) - else: - r_space_part = None - - q_space = QSpaceCalcDatum(q_space_part, r_space_part) - - if options.sampling_distributions: - sampling_distribution = SamplingDistribution( - "r_xy", - 0.5*(r_xy_bin_edges[1:] + r_xy_bin_edges[:-1]), - r_xy_counts) - - sampling_distributions.append(sampling_distribution) - - if options.q_space_2d: - # USE: scipy.interpolate.CloughTocher2DInterpolator to do this - - pass - - # TODO: implement - # TODO: Check that the sampling method is appropriate - it probably isn't - pass - - return ScatteringOutput( - radial_distribution=radial_distribution_output, - q_space=q_space, - q_space_2d=None, - sesans=None, - sampling_distributions=sampling_distributions, - calculation_time=time.time() - start_time, - seed_used=seed) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py index 8f826fb655..6d69de8cbf 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py @@ -11,31 +11,12 @@ from typing import List, Tuple -from abc import ABC, abstractmethod import numpy as np +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import AngularDistribution from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic, GeodesicDivisions -class AngularDistribution(ABC): - """ Base class for angular distributions """ - - @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""" - - class ZDelta(AngularDistribution): """ Perfectly oriented sample """ @@ -46,6 +27,10 @@ def name(): 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 [] @@ -59,6 +44,7 @@ class Uniform(AngularDistribution): def __init__(self, geodesic_divisions: int): self.divisions = geodesic_divisions + self._n_points = Geodesic.points_for_division_amount(geodesic_divisions) @staticmethod def name(): @@ -67,6 +53,10 @@ def name(): 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)] diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py index de5e2d0611..9bc4adec0f 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py @@ -39,14 +39,14 @@ import math -from sas.qtgui.Perspectives.ParticleEditor.sampling.points import PointGenerator +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: PointGenerator): + def __init__(self, point_generator: SpatialDistribution): self.point_generator = point_generator def __iter__(self): diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py index d528d1128f..f043f002e2 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py @@ -1,7 +1,8 @@ -from typing import Dict +""" -from abc import ABC, abstractmethod -from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 +Instances of the spatial sampler + +""" import math import numpy as np @@ -9,27 +10,32 @@ from collections import defaultdict -class PointGenerator(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 """ - - - - -class GridPointGenerator(PointGenerator): +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 @@ -41,7 +47,6 @@ def __init__(self, radius: float, desired_points: int): 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) @@ -54,7 +59,9 @@ def generate(self, start_index: int, end_index: int) -> VectorComponents3: (((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 RandomPointGenerator(PointGenerator): + + +class RandomCube(BoundedByCube): """ Generate random points in a cube with side length 2*radius""" def __init__(self, radius: float, desired_points: int, seed=None): @@ -85,7 +92,7 @@ def generate(self, start_index: int, end_index: int) -> VectorComponents3: class PointGeneratorStepper: """ Generate batches of step_size points from a PointGenerator instance""" - def __init__(self, point_generator: PointGenerator, step_size: int): + def __init__(self, point_generator: SpatialDistribution, step_size: int): self.point_generator = point_generator self.step_size = step_size diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py deleted file mode 100644 index cdf2ec7ef2..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_method_tests.py +++ /dev/null @@ -1,128 +0,0 @@ -import numpy as np -import matplotlib.pyplot as plt -from scipy.interpolate import interp1d -import time - -from sas.qtgui.Perspectives.ParticleEditor.sampling_methods import ( - UniformCubeSample, UniformSphereSample, - RadiallyBiasedCubeSample, RadiallyBiasedSphereSample, - MixedCubeSample, MixedSphereSample) - -test_radius = 5 -sample_radius = 10 - -def octahedron(x,y,z): - inds = np.abs(x) + np.abs(y) + np.abs(z) <= test_radius - sld = np.zeros_like(x) - sld[inds] = 1.0 - - return sld - - -def cube(x,y,z): - - inds = np.logical_and( - np.abs(x) < test_radius, - np.logical_and( - np.abs(y) < test_radius, - np.abs(z) < test_radius)) - - sld = np.zeros_like(x) - sld[inds] = 1.0 - - return sld - - -def off_centre_cube(x,y,z): - - inds = np.logical_and( - np.abs(x+test_radius/2) < test_radius, - np.logical_and( - np.abs(y+test_radius/2) < test_radius, - np.abs(z+test_radius/2) < test_radius)) - - sld = np.zeros_like(x) - sld[inds] = 1.0 - - return sld - - -def sphere(x, y, z): - - inds = x**2 + y**2 + z**2 <= test_radius**2 - sld = np.zeros_like(x) - sld[inds] = 1.0 - - return sld - -test_functions = [cube, sphere, octahedron, off_centre_cube] -sampler_classes = [UniformCubeSample, UniformSphereSample, - RadiallyBiasedCubeSample, RadiallyBiasedSphereSample, - MixedCubeSample, MixedSphereSample] - -colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] - - -bin_edges = np.linspace(0, np.sqrt(3)*sample_radius, 201) -bin_centre = 0.5*(bin_edges[1:] + bin_edges[:-1]) - -for test_function in test_functions: - - legends = [] - - plt.figure("Test function " + test_function.__name__) - - for sampler_cls, color in zip(sampler_classes, colors): - - print(sampler_cls.__name__) - for repeat in range(3): - sampler = sampler_cls(n_points=1_000_000, radius=sample_radius) - - start_time = time.time() - - distro = None - counts = None - - for (x0, y0, z0), (x1, y1, z1) in sampler(size_hint=1000): - - sld0 = test_function(x0, y0, z0) - sld1 = test_function(x1, y1, z1) - - rho = sld0 * sld1 - - r = np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2 + (z1 - z0) ** 2) - - if distro is None: - distro = np.histogram(r, bins=bin_edges, weights=rho)[0] - counts = np.histogram(r, bins=bin_edges)[0] - - else: - distro += np.histogram(r, bins=bin_edges, weights=rho)[0] - counts += np.histogram(r, bins=bin_edges)[0] - - - good_values = counts > 0 - - distro = distro[good_values].astype(float) - counts = counts[good_values] - bin_centres_good = bin_centre[good_values] - - distro /= counts - distro *= sampler.sample_volume() - - f = interp1d(bin_centres_good, distro, - kind='linear', bounds_error=False, fill_value=0, assume_sorted=True) - - - ax = plt.plot(bin_centre, f(bin_centre), color=color) - if repeat == 0: - legends.append((ax[0], sampler.__class__.__name__)) - - - print("Time:", time.time() - start_time) - - handles = [handle for handle, _ in legends] - titles = [title for _, title in legends] - plt.legend(handles, titles) - -plt.show() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py deleted file mode 100644 index d40685b0bf..0000000000 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling_methods.py +++ /dev/null @@ -1,467 +0,0 @@ -from typing import Optional, Tuple, List -from abc import ABC, abstractmethod -import numpy as np - -from sas.qtgui.Perspectives.ParticleEditor.datamodel.sampling import SpatialSample -from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 - -class RandomSample(SpatialSample): - def __init__(self, n_points: int, radius: float, seed: Optional[int] = None): - super().__init__(n_points, radius) - self._seed = seed - self.rng = np.random.default_rng(seed=seed) - - @property - def seed(self): - return self._seed - - @seed.setter - def seed(self, s): - self._seed = s - self.rng = np.random.default_rng(seed=s) - - def __repr__(self): - return "%s(n=%i,r=%g,seed=%s)" % (self.__class__.__name__, self.n_points, self.radius, str(self.seed)) - -class MixedSample(SpatialSample): - - def __init__(self, n_points: int, radius: float, seed: Optional[int] = None): - super().__init__(n_points, radius) - self._seed = seed - self.children = self._create_children() - - @abstractmethod - def _create_children(self) -> List[RandomSample]: - """ Create the components that need to be mixed together""" - @property - def seed(self): - return self._seed - - @seed.setter - def seed(self, s): - self._seed = s - for child in self.children: - child.seed = s - - def __call__(self, size_hint: int): - for child in self.children: - for chunk in child(size_hint=size_hint): - yield chunk - - - -class RandomSampleWithRejection(RandomSample): - """ Base class for random sampling methods, allows for rejection sampling """ - - # These two variables are used for efficient rejection sampling - rejection_sampling_request_factor = 1.0 # 1 / fraction of points kept by rejection sampling - rejection_sampling_request_offset = 0.0 # Offset used to bias away from empty samples, see __call__ - - @abstractmethod - def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: - """ Generate pairs of points, each uniformly distrubted over the sample space, with - their distance distributed uniformly - - :returns: number of points generated, and the pairs of points""" - - def __call__(self, size_hint: int): - """ __call__ is a generator that goes through the points in chunks that aim to be a certain size - for efficiency, we do not require that the chunks are exactly the size requested""" - - n_remaining = self.n_points - - while n_remaining > 0: - - # How many points would we ideally like to generate in this chunk - required_n_this_chunk = min((n_remaining, size_hint)) - - # - # Calculate number to request before any rejection - # - # We also add an extra couple of points to the required number, so that for small - # requests numbers, the chance of fulfilling the request is much bigger than 0.5. - # For example, if n=1, we'll have slightly less the 50% probability, if we use n+1, - # well have 75%, if we use n+2 we'll have 83%. - # - request_amount = int((required_n_this_chunk + self.rejection_sampling_request_offset) * self.rejection_sampling_request_factor) - - n, pairs = self.generate_pairs(request_amount) - - if n <= n_remaining: - # Still plenty to go, return regardless of size - - yield pairs - n_remaining -= n - - else: - # We've made more points than we needed, just return enough to complete the requested amount - (x0, y0, z0), (x1, y1, z1) = pairs - yield (x0[:n_remaining], y0[:n_remaining], z0[:n_remaining]), \ - (x1[:n_remaining], y1[:n_remaining], z1[:n_remaining]) - - n_remaining = 0 - -class CubeSample(SpatialSample): - """ Mixin for cube based samplers """ - - def max_xyz(self): - """ Maximum distance between points in 3D - along the main diagonal""" - return 2 * self.radius * np.sqrt(3) - - def max_xy(self): - """ Maximum distance between points in 2D projection in an axis - along - the diagonal of a face (or equivalent)""" - return 2 * self.radius * np.sqrt(2) - - def max_principal_axis(self): - """ Maximum distance between points along one axis - along one of the axes """ - return 2 * self.radius - - def sample_volume(self): - """ Volume of sampling region - a cube """ - return 8*(self.radius**3) - - def bounding_surface_check_points(self) -> VectorComponents3: - """Bounding box check points - - 8 corners - 12 edge centres - 6 face centres - """ - - corners = [ - [ self.radius, self.radius, self.radius], - [ self.radius, self.radius, -self.radius], - [ self.radius, -self.radius, self.radius], - [ self.radius, -self.radius, -self.radius], - [-self.radius, self.radius, self.radius], - [-self.radius, self.radius, -self.radius], - [-self.radius, -self.radius, self.radius], - [-self.radius, -self.radius, -self.radius]] - - edge_centres = [ - [ self.radius, self.radius, 0 ], - [ self.radius, -self.radius, 0 ], - [-self.radius, self.radius, 0 ], - [-self.radius, -self.radius, 0 ], - [ self.radius, 0, self.radius], - [ self.radius, 0, -self.radius], - [-self.radius, 0, self.radius], - [-self.radius, 0, -self.radius], - [ 0, self.radius, self.radius], - [ 0, self.radius, -self.radius], - [ 0, -self.radius, self.radius], - [ 0, -self.radius, -self.radius]] - - face_centres = [ - [ self.radius, 0, 0 ], - [ -self.radius, 0, 0 ], - [ 0, self.radius, 0 ], - [ 0, -self.radius, 0 ], - [ 0, 0, self.radius ], - [ 0, 0, -self.radius ]] - - check_points = np.concatenate((corners, edge_centres, face_centres), axis=0) - - return check_points[:, 0], check_points[:, 1], check_points[:, 2] - - -class SphereSample(SpatialSample): - """ Mixin for sampling within a sphere""" - def bounding_surface_check_points(self) -> VectorComponents3: - """ Points to check: - - 6 principal directions - 50 random points over the sphere - """ - - principal_directions = [ - [ self.radius, 0, 0 ], - [ -self.radius, 0, 0 ], - [ 0, self.radius, 0 ], - [ 0, -self.radius, 0 ], - [ 0, 0, self.radius ], - [ 0, 0, -self.radius ]] - - random_points = np.random.standard_normal(size=(50, 3)) - - random_points /= self.radius / np.sqrt(np.sum(random_points**2, axis=1)) - - check_points = np.concatenate((principal_directions, random_points), axis=0) - - return check_points[:, 0], check_points[:, 1], check_points[:, 2] - def max_xyz(self): - """ Maximum distance between points in 3D - opposite sides of sphere """ - return 2*self.radius - - def max_xy(self): - """ Maximum distance between points in 2D projection - also opposite sides of sphere """ - return 2*self.radius - - def max_principal_axis(self): - """ Maximum distance between points in an axis - again, opposite sides of sphere """ - return 2*self.radius - - def sample_volume(self): - return (4*np.pi/3)*(self.radius**3) - - -class UniformCubeSample(RandomSampleWithRejection, CubeSample): - """ Uniformly sample pairs of points from a cube """ - def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: - """ Generate pairs of points, each within a cube""" - - pts = (2*self.radius) * (self.rng.random(size=(size_hint, 6)) - 0.5) - - return size_hint, ((pts[:, 0], pts[:, 1], pts[:, 2]), (pts[:, 3], pts[:, 4], pts[:, 5])) - -class UniformSphereSample(RandomSampleWithRejection, SphereSample): - """ Uniformly sample pairs of points from a sphere """ - - def generate_pairs(self, size_hint: int) -> Tuple[int, Tuple[VectorComponents3, VectorComponents3]]: - """ Generate pairs of points, each within a cube""" - - - pts = (2*self.radius) * (self.rng.random(size=(size_hint, 6)) - 0.5) - - squared = pts**2 - r2 = self.radius * self.radius - - in_spheres = np.logical_and( - np.sum(squared[:, :3], axis=1) < r2, - np.sum(squared[:, 3:], axis=1) < r2) - - pts = pts[in_spheres, :] - - return size_hint, ((pts[:, 0], pts[:, 1], pts[:, 2]), (pts[:, 3], pts[:, 4], pts[:, 5])) - - -class RadiallyBiasedSample(RandomSampleWithRejection): - """ Base class for samplers with a bias towards shorter distances""" - def cube_sample(self, n: int) -> np.ndarray: - """ Sample uniformly from a 2r side length cube, centred on the origin """ - - return (2*self.radius) * (self.rng.random(size=(n, 3)) - 0.5) - - def uniform_radial_sample(self, n: int) -> VectorComponents3: - """ Sample within the maximum possible radius, uniformly over the distance from the - origin (not a uniform distribution in space)""" - - xyz = self.rng.standard_normal(size=(n, 3)) - - scaling = self.max_xyz() * self.rng.random(size=n) / np.sqrt(np.sum(xyz**2, axis=1)) - - return xyz * scaling[:, np.newaxis] - - -class RadiallyBiasedSphereSample(RadiallyBiasedSample, SphereSample): - """ Sample over a sphere with a specified radius """ - - rejection_sampling_request_factor = 0.9998/0.19632 # Calibrated to 10000 samples with offset of 2 - rejection_sampling_request_offset = 2 - - def generate_pairs(self, n): - """ Rejection sample pairs in a sphere """ - - xyz0 = self.cube_sample(n) - - in_sphere = np.sum(xyz0**2, axis=1) <= self.radius**2 - - xyz0 = xyz0[in_sphere,:] - - dxyz = self.uniform_radial_sample(xyz0.shape[0]) - xyz1 = xyz0 + dxyz - - in_sphere = np.sum(xyz1**2, axis=1) <= self.radius**2 - - xyz0 = xyz0[in_sphere, :] - xyz1 = xyz1[in_sphere, :] - - return xyz0.shape[0], ((xyz0[:, 0], xyz0[:, 1], xyz0[:, 2]), (xyz1[:, 0], xyz1[:, 1], xyz1[:, 2])) - - -class RadiallyBiasedCubeSample(RadiallyBiasedSample, CubeSample): - """ Randomly sample points in a 2r x 2r x 2r cube centred at the origin""" - - rejection_sampling_request_factor = 0.9998/0.25888 # Calibrated to 10000 samples with offset of 2 - rejection_sampling_request_offset = 2 - def generate_pairs(self, n): - """ Rejection sample pairs in a sphere """ - - xyz0 = self.cube_sample(n) - - dxyz = self.uniform_radial_sample(n) - - xyz1 = xyz0 + dxyz - - in_cube = np.all(np.abs(xyz1) <= self.radius, axis=1) - - xyz0 = xyz0[in_cube, :] - xyz1 = xyz1[in_cube, :] - - return xyz0.shape[0], ((xyz0[:, 0], xyz0[:, 1], xyz0[:, 2]), (xyz1[:, 0], xyz1[:, 1], xyz1[:, 2])) - - - -class MixedSphereSample(MixedSample, SphereSample): - """ Mixture of samples from the uniform and radially biased spherical samplers - - - Note: the default biased fraction is different between these mixture classes, - so as to make the lower r sampling be approximately uniform. - """ - def __init__(self, n_points, radius, seed: Optional[int]=None, biased_fraction=0.25): - self._biased_fraction = biased_fraction - super().__init__(n_points, radius, seed) - - def _create_children(self) -> List[RandomSample]: - n_biased = int(self._biased_fraction*self.n_points) - n_non_biased = self.n_points - n_biased - - return [RadiallyBiasedSphereSample(n_biased, self.radius, self.seed), - UniformSphereSample(n_non_biased, self.radius, self.seed)] - - -class MixedCubeSample(MixedSample, CubeSample): - """ Mixture of samples from the uniform and radially biased cube samplers - - Note: the default biased fraction is different between these mixture classes, - so as to make the lower r sampling be approximately uniform. - """ - - def __init__(self, n_points, radius, seed: Optional[int] = None, biased_fraction=0.5): - self._biased_fraction = biased_fraction - super().__init__(n_points, radius, seed) - - def _create_children(self) -> List[RandomSample]: - n_biased = int(self._biased_fraction * self.n_points) - n_non_biased = self.n_points - n_biased - - return [RadiallyBiasedCubeSample(n_biased, self.radius, self.seed), - UniformCubeSample(n_non_biased, self.radius, self.seed)] - - -def visual_distribution_check(sampler: SpatialSample, size_hint=10_000): - - n_bins = 200 - - print("") - print("Sampler:", sampler) - print("Size hint:", size_hint) - - n_total = 0 - - x0_hist = None - x1_hist = None - y0_hist = None - y1_hist = None - z0_hist = None - z1_hist = None - r_hist = None - - bin_edges = np.linspace(-sampler.max_xyz(), sampler.max_xyz(), n_bins+1) - bin_centres = 0.5*(bin_edges[1:] + bin_edges[:-1]) - - r_bin_edges = np.linspace(0, sampler.max_xyz()) - r_bin_centres = 0.5 * (r_bin_edges[1:] + r_bin_edges[:-1]) - - chunk_sizes = [] - for (x0, y0, z0), (x1, y1, z1) in sampler(size_hint): - - n = len(x0) - - n_total += n - if n_total < sampler.n_points - size_hint: - chunk_sizes.append(n) - - # print("Size",n,"chunk,", n_total, "so far") - - r = np.sqrt((x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2) - - if x0_hist is None: - x0_hist = np.histogram(x0, bins=bin_edges)[0].astype("float") - y0_hist = np.histogram(y0, bins=bin_edges)[0].astype("float") - z0_hist = np.histogram(z0, bins=bin_edges)[0].astype("float") - x1_hist = np.histogram(x1, bins=bin_edges)[0].astype("float") - y1_hist = np.histogram(y1, bins=bin_edges)[0].astype("float") - z1_hist = np.histogram(z1, bins=bin_edges)[0].astype("float") - r_hist = np.histogram(r, bins=r_bin_edges)[0].astype("float") - - else: - x0_hist += np.histogram(x0, bins=bin_edges)[0] - y0_hist += np.histogram(y0, bins=bin_edges)[0] - z0_hist += np.histogram(z0, bins=bin_edges)[0] - x1_hist += np.histogram(x1, bins=bin_edges)[0] - y1_hist += np.histogram(y1, bins=bin_edges)[0] - z1_hist += np.histogram(z1, bins=bin_edges)[0] - r_hist += np.histogram(r, bins=r_bin_edges)[0] - - - x0_hist /= sampler.n_points / n_bins - y0_hist /= sampler.n_points / n_bins - z0_hist /= sampler.n_points / n_bins - x1_hist /= sampler.n_points / n_bins - y1_hist /= sampler.n_points / n_bins - z1_hist /= sampler.n_points / n_bins - r_hist /= sampler.n_points / n_bins - - print("Mean:", np.mean(chunk_sizes)) - - import matplotlib.pyplot as plt - - plt.subplot(3, 3, 1) - plt.plot(bin_centres, x0_hist) - - plt.subplot(3, 3, 2) - plt.plot(bin_centres, y0_hist) - - plt.subplot(3, 3, 3) - plt.plot(bin_centres, z0_hist) - - plt.subplot(3, 3, 4) - plt.plot(bin_centres, x1_hist) - - plt.subplot(3, 3, 5) - plt.plot(bin_centres, y1_hist) - - plt.subplot(3, 3, 6) - plt.plot(bin_centres, z1_hist) - - plt.subplot(3, 3, 8) - plt.plot(r_bin_centres, r_hist) - - -if __name__ == "__main__": - - n_samples = 10_000_000 - - import matplotlib.pyplot as plt - - plt.figure("Sphere -biased") - sphere_sampler = RadiallyBiasedSphereSample(n_samples, radius=10) - visual_distribution_check(sphere_sampler) - - plt.figure("Cube - biased") - sampler = RadiallyBiasedCubeSample(n_samples, radius=10) - visual_distribution_check(sampler) - - plt.figure("Cube - unform") - sampler = UniformCubeSample(n_samples, radius=10) - visual_distribution_check(sampler) - - plt.figure("Sphere - unform") - sampler = UniformSphereSample(n_samples, radius=10) - visual_distribution_check(sampler) - - plt.figure("Cube - mixed") - sampler = MixedCubeSample(n_samples, radius=10) - visual_distribution_check(sampler) - - plt.figure("Sphere - mixed") - sampler = MixedSphereSample(n_samples, radius=10) - visual_distribution_check(sampler) - - - plt.show() - - diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_angles.py b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py similarity index 100% rename from src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_angles.py rename to src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_point_generator.py b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py similarity index 82% rename from src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_point_generator.py rename to src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py index 50b1422dfa..61c17d40e1 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/test_point_generator.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py @@ -2,8 +2,8 @@ from pytest import mark from sas.qtgui.Perspectives.ParticleEditor.sampling.points import ( - GridPointGenerator, - RandomPointGenerator, + Grid, + RandomCube, PointGeneratorStepper) @@ -11,7 +11,7 @@ @mark.parametrize("splits", [42, 100, 381,999]) @mark.parametrize("npoints", [100,1000,8001]) def test_with_grid(npoints, splits): - point_generator = GridPointGenerator(100, npoints) + point_generator = Grid(100, npoints) n_measured = 0 for x, y, z in PointGeneratorStepper(point_generator, splits): n_measured += len(x) @@ -21,7 +21,7 @@ def test_with_grid(npoints, splits): @mark.parametrize("splits", [42, 100, 381, 999]) @mark.parametrize("npoints", [100, 1000, 8001]) def test_with_grid(npoints, splits): - point_generator = RandomPointGenerator(100, npoints) + point_generator = RandomCube(100, npoints) n_measured = 0 for x, y, z in PointGeneratorStepper(point_generator, splits): n_measured += len(x) From bf5f089a2de30883c84137b41801ee1350814023 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Mon, 4 Sep 2023 14:27:50 +0100 Subject: [PATCH 36/38] Working prototype --- .../ParticleEditor/DesignWindow.py | 21 +++++++++++++------ .../ParticleEditor/Plots/QCanvas.py | 14 ++++++++----- .../ParticleEditor/UI/DesignWindowUI.ui | 8 +++---- .../ParticleEditor/calculations/calculate.py | 7 +++++-- .../ParticleEditor/calculations/fq.py | 12 +++++------ .../ParticleEditor/datamodel/calculation.py | 6 +++++- .../ParticleEditor/sampling/points.py | 2 +- 7 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py index 931029e2a8..8f511d0d71 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -179,6 +179,9 @@ def onRadiusChanged(self): 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 @@ -250,6 +253,13 @@ 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""" @@ -261,11 +271,11 @@ def spatialSampling(self) -> SpatialDistribution: seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None if sample_type == 0: - return GridSampling(radius=radius, n_points=n_points, seed=seed) + 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, n_points=n_points, seed=seed) + return UniformCubeSampling(radius=radius, desired_points=n_points, seed=seed) # return MixedCubeSample(radius=radius, n_points=n_points, seed=seed) else: @@ -307,6 +317,7 @@ def scatteringCalculation(self) -> ScatteringCalculation: 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() @@ -314,6 +325,7 @@ def scatteringCalculation(self) -> ScatteringCalculation: 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, @@ -360,12 +372,9 @@ def display_calculation_result(self, scattering_result: ScatteringOutput): """ Update graphs and select tab""" # Plot - self.samplingCanvas.data = scattering_result - self.rdfCanvas.data = scattering_result - self.correlationCanvas.data = scattering_result self.outputCanvas.data = scattering_result - self.tabWidget.setCurrentIndex(7) # Move to output tab if complete + self.tabWidget.setCurrentIndex(5) # Move to output tab if complete def onFit(self): """ Fit functionality requested""" pass diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py index 4d55803f04..95a3b67bc2 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py @@ -1,12 +1,13 @@ 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 -import numpy as np def spherical_form_factor(q, r): rq = r * q f = (np.sin(rq) - rq * np.cos(rq)) / (rq ** 3) @@ -31,12 +32,14 @@ def data(self): @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: - plot_data = scattering_output.q_space.q_space_data + # print(self._data.q_space) + plot_data = self._data.q_space q_sample = plot_data.abscissa q_values = q_sample() @@ -52,8 +55,9 @@ def data(self, scattering_output: ScatteringOutput): # 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)) + # 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) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui index 4ecfe96564..bc0dd39ce1 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -20,7 +20,7 @@ - 2 + 0 @@ -209,7 +209,7 @@ - Ang + Ang<sup>-1</sup> @@ -232,7 +232,7 @@ - Logaritmic (applies only to 1D) + Logaritmic true @@ -436,7 +436,7 @@ - Ang + Ang<sup>-1</sup> diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py index 2ca1893c8a..15259c6f0d 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py @@ -1,6 +1,7 @@ import time -from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringCalculation, ScatteringOutput +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) @@ -40,8 +41,10 @@ def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput q_sample=q_dist, angular_distribution=angular_dist) + q_data = QSpaceScattering(q_dist, scattering) + output = ScatteringOutput( - q_space=scattering, + q_space=q_data, calculation_time=time.time() - start_time, seed_used=None) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py index 957644112e..f94404e42a 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py @@ -7,8 +7,6 @@ from scipy.spatial.distance import cdist from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( - ScatteringCalculation, ScatteringOutput, SamplingDistribution, - QSpaceScattering, QSpaceCalcDatum, RealSpaceScattering, SLDDefinition, MagnetismDefinition, AngularDistribution, QSample, CalculationParameters) from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk, pairwise_chunk_iterator @@ -28,19 +26,19 @@ def scattering_via_fq( 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)) # Dictionary for fq for all angles + 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) + sld = run_sld(sld_definition, parameters, x, y, z).reshape(-1, 1) # TODO: Magnetism for direction_index, direction_vector in enumerate(direction_vectors): - r = np.sqrt(x*direction_vector[0] + y*direction_vector[1] + z*direction_vectors[2]) + projected_distance = x*direction_vector[0] + y*direction_vector[1] + z*direction_vector[2] - i_r_dot_q = np.multiply.outer(r, 1j*q_magnitudes) + 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) @@ -48,7 +46,7 @@ def scattering_via_fq( 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 + f_squared *= direction_weights.reshape(-1,1) return np.sum(f_squared, axis=0) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py index c869202c74..c55ce7f1e1 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -49,9 +49,13 @@ 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) -> VectorComponents3: + 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 """ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py index f043f002e2..b07f41eb35 100644 --- a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py @@ -31,7 +31,7 @@ class BoundedByCube(SpatialDistribution): [-1,-1, 1], [-1,-1,-1], ], dtype=float) - def bounding_surface_check_points(self) -> VectorComponents3: + def _bounding_surface_check_points(self) -> VectorComponents3: return BoundedByCube._boundary_base_points * self.radius From 483a8f8b9bc2b31a88fadb6d34c5522b1b62d576 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sat, 9 Sep 2023 13:02:38 +0100 Subject: [PATCH 37/38] Revised parameterisation --- .../OrientationViewer/OrientationViewer.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py index 36114a85fc..130f7a23a8 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)), @@ -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)) From 23b434d86bb8c4654d71aae7958b5bdc57553b18 Mon Sep 17 00:00:00 2001 From: lucas-wilkins Date: Sat, 9 Sep 2023 13:03:38 +0100 Subject: [PATCH 38/38] Typo --- .../qtgui/Utilities/OrientationViewer/OrientationViewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py index 130f7a23a8..54437ca983 100644 --- a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py +++ b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py @@ -171,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) @@ -253,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