diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b0f25fcc2..e1858184db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.9 - uses: actions/checkout@v3 - id: set-matrix @@ -195,7 +195,7 @@ jobs: - name: Install utilities to build installer if: ${{ matrix.installer }} run: | - python -m pip install pyinstaller==5.7.0 + python -m pip install pyinstaller - name: Build sasview with pyinstaller if: ${{ matrix.installer }} @@ -283,7 +283,7 @@ jobs: strategy: matrix: os: [ windows-latest, ubuntu-20.04 ] - python-version: [ 3.8 ] + python-version: [ 3.11 ] fail-fast: false name: Test installer diff --git a/.github/workflows/matrix.py b/.github/workflows/matrix.py index 79d52b2475..0b9097ce48 100644 --- a/.github/workflows/matrix.py +++ b/.github/workflows/matrix.py @@ -38,11 +38,12 @@ # List of python versions to use for release builds python_release_list = [ - '3.8', + '3.11', ] # List of python versions to use for tests python_test_list = python_release_list + [ + '3.8', '3.9', '3.10' ] diff --git a/build_tools/requirements.txt b/build_tools/requirements.txt index c00355eca1..d652b7e499 100644 --- a/build_tools/requirements.txt +++ b/build_tools/requirements.txt @@ -1,16 +1,15 @@ -numpy<1.24 -scipy==1.10.0 +numpy +scipy docutils pytest pytest_qt pytest-mock unittest-xml-reporting tinycc -h5py +h5py sphinx pyparsing html5lib -reportlab==3.6.6 pybind11 appdirs six @@ -22,7 +21,7 @@ xhtml2pdf pylint periodictable uncertainties -matplotlib~=3.5.2 +matplotlib lxml pytools cffi @@ -33,7 +32,7 @@ bumps html2text jsonschema pywin32; platform_system == "Windows" -PySide6==6.2.4 +PySide6==6.4.3 twisted zope superqt diff --git a/setup.py b/setup.py index 1195979830..e9d102670c 100644 --- a/setup.py +++ b/setup.py @@ -14,6 +14,7 @@ from setuptools import setup, Command, find_packages + # Manage version number version_file = os.path.join("src", "sas", "system", "version.py") with open(version_file) as fid: @@ -80,6 +81,9 @@ def run(self): 'install', 'build', 'build_py', 'bdist', 'bdist_egg', 'bdist_rpm', 'bdist_wheel', 'develop', 'test' ] + +print(sys.argv) + # determine if this run requires building of Qt GUI ui->py build_qt = any(c in sys.argv for c in build_commands) force_rebuild = "-f" if 'rebuild_ui' in sys.argv or 'clean' in sys.argv else "" @@ -93,7 +97,7 @@ def run(self): # Required packages required = [ 'bumps>=0.7.5.9', 'periodictable>=1.5.0', 'pyparsing>=2.0.0', - 'lxml', 'h5py', + 'lxml', ] if os.name == 'nt': diff --git a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py index 8b9c90f05f..7867002987 100644 --- a/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py +++ b/src/sas/qtgui/Calculators/DataOperationUtilityPanel.py @@ -369,14 +369,7 @@ def _findId(self, name): def _extractData(self, key_id): """ Extract data from file with id contained in list of filenames """ data_complete = self.filenames[key_id] - dimension = data_complete.data.__class__.__name__ - - if dimension in ('Data1D', 'Data2D'): - return copy.deepcopy(data_complete.data) - - else: - logging.error('Error with data format') - return + return copy.deepcopy(data_complete) # ######## # PLOTS diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py new file mode 100644 index 0000000000..783691127a --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/AngularSamplingMethodSelector.py @@ -0,0 +1,121 @@ +from typing import List, Tuple + +from PySide6.QtWidgets import QWidget, QVBoxLayout, QFormLayout, QComboBox, QDoubleSpinBox + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import AngularDistribution +from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import GeodesicDivisions +from sas.qtgui.Perspectives.ParticleEditor.sampling.angles import angular_sampling_methods +from sas.qtgui.Perspectives.ParticleEditor.GeodesicSampleSelector import GeodesicSamplingSpinBox + +class ParametersForm(QWidget): + """ Form that displays the parameters associated with the class (also responsible for generating the sampler)""" + def __init__(self, sampling_class: type, parent=None): + super().__init__(parent=parent) + + self.sampling_class = sampling_class + + self.layout = QFormLayout() + self.parameter_callbacks = [] + + for parameter_name, text, cls in sampling_class.parameters(): + if cls == GeodesicDivisions: + widget = GeodesicSamplingSpinBox() + + def callback(): + return widget.getNDivisions() + + elif cls == float: + widget = QDoubleSpinBox() + + def callback(): + return widget.value() + + + else: + raise TypeError(f"Cannot create appropriate widget for parameter of type '{cls}'") + + self.layout.addRow(text, widget) + self.parameter_callbacks.append((parameter_name, callback)) + + self.setLayout(self.layout) + + def generate_sampler(self) -> AngularDistribution: + """ Generate a sampler based on the selected parameters """ + + parameter_dict = {name: callback() for name, callback in self.parameter_callbacks} + + return self.sampling_class(**parameter_dict) + + + +class AngularSamplingMethodSelector(QWidget): + """ Selects the method for doing angular sampling, and provides access to the parameters """ + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout() + + self.combo = QComboBox() + self.combo.addItems([cls.name() for cls in angular_sampling_methods]) + + subwidget = QWidget() + self.subwidget_layout = QVBoxLayout() + subwidget.setLayout(self.subwidget_layout) + + layout.addWidget(self.combo) + layout.addWidget(subwidget) + + self.setLayout(layout) + + self.entry_widgets = [ParametersForm(cls) for cls in angular_sampling_methods] + + for widget in self.entry_widgets: + self.subwidget_layout.addWidget(widget) + widget.hide() + + self.entry_widgets[0].show() + + self.combo.currentIndexChanged.connect(self.on_update) + + def on_update(self): + for i in range(self.subwidget_layout.count()): + self.subwidget_layout.itemAt(i).widget().hide() + + self.subwidget_layout.itemAt(self.combo.currentIndex()).widget().show() + + def generate_sampler(self) -> AngularDistribution: + """ Create the angular distribution sampler spectified by the current settings""" + return self.subwidget_layout.itemAt(self.combo.currentIndex()).widget().generate_sampler() + +def main(): + """ Show a demo """ + + from PySide6 import QtWidgets + + + app = QtWidgets.QApplication([]) + + + widget = QWidget() + layout = QVBoxLayout() + + sampling = AngularSamplingMethodSelector() + + def callback(): + print(sampling.generate_sampler()) + + button = QtWidgets.QPushButton("Check") + button.clicked.connect(callback) + + layout.addWidget(sampling) + layout.addWidget(button) + + widget.setLayout(layout) + + widget.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py new file mode 100644 index 0000000000..2f0061ac17 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/CodeToolBar.py @@ -0,0 +1,27 @@ +from PySide6 import QtWidgets, QtGui + +from sas.qtgui.Perspectives.ParticleEditor.UI.CodeToolBarUI import Ui_CodeToolBar + +import sas.qtgui.Perspectives.ParticleEditor.UI.icons_rc +class CodeToolBar(QtWidgets.QWidget, Ui_CodeToolBar): + def __init__(self, parent=None): + super().__init__() + + self.setupUi(self) + + + load_icon = QtGui.QIcon() + load_icon.addPixmap(QtGui.QPixmap(":/particle_editor/upload-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + self.loadButton.setIcon(load_icon) + + save_icon = QtGui.QIcon() + save_icon.addPixmap(QtGui.QPixmap(":/particle_editor/download-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + self.saveButton.setIcon(save_icon) + + build_icon = QtGui.QIcon() + build_icon.addPixmap(QtGui.QPixmap(":/particle_editor/hammer-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + self.buildButton.setIcon(build_icon) + + scatter_icon = QtGui.QIcon() + scatter_icon.addPixmap(QtGui.QPixmap(":/particle_editor/scatter-icon.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + self.scatterButton.setIcon(scatter_icon) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py new file mode 100644 index 0000000000..8f511d0d71 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/DesignWindow.py @@ -0,0 +1,419 @@ +import traceback +from typing import Optional + +from datetime import datetime + +import numpy as np +from PySide6 import QtWidgets +from PySide6.QtWidgets import QSpacerItem, QSizePolicy +from PySide6.QtCore import Qt + +from sas.qtgui.Perspectives.ParticleEditor.UI.DesignWindowUI import Ui_DesignWindow + +from sas.qtgui.Perspectives.ParticleEditor.FunctionViewer import FunctionViewer +from sas.qtgui.Perspectives.ParticleEditor.PythonViewer import PythonViewer +from sas.qtgui.Perspectives.ParticleEditor.OutputViewer import OutputViewer +from sas.qtgui.Perspectives.ParticleEditor.CodeToolBar import CodeToolBar + +from sas.qtgui.Perspectives.ParticleEditor.Plots.QCanvas import QCanvas + + +from sas.qtgui.Perspectives.ParticleEditor.function_processor import process_code, FunctionDefinitionFailed +from sas.qtgui.Perspectives.ParticleEditor.vectorise import vectorise_sld + + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + + QSample, ScatteringCalculation, CalculationParameters, AngularDistribution, SpatialDistribution, + SLDDefinition, SLDFunction, MagnetismDefinition, ParticleDefinition, CoordinateSystemTransform, ScatteringOutput) + +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableModel import ParameterTableModel +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTable import ParameterTable +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableButtons import ParameterTableButtons + +from sas.qtgui.Perspectives.ParticleEditor.AngularSamplingMethodSelector import AngularSamplingMethodSelector + +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid as GridSampling +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import RandomCube as UniformCubeSampling + +from sas.qtgui.Perspectives.ParticleEditor.util import format_time_estimate + +from sas.qtgui.Perspectives.ParticleEditor.calculations.calculate import calculate_scattering + +def safe_float(text: str): + try: + return float(text) + except: + return 0.0 + + +class DesignWindow(QtWidgets.QDialog, Ui_DesignWindow): + """ Main window for the particle editor""" + def __init__(self, parent=None): + super().__init__() + + self.setupUi(self) + self.setWindowTitle("Placeholder title") + self.parent = parent + + # TODO: Set validators on fields + + # + # First Tab + # + + hbox = QtWidgets.QHBoxLayout(self) + + splitter = QtWidgets.QSplitter(Qt.Vertical) + + self.pythonViewer = PythonViewer() + self.outputViewer = OutputViewer() + self.codeToolBar = CodeToolBar() + + self.pythonViewer.build_trigger.connect(self.doBuild) + + self.codeToolBar.saveButton.clicked.connect(self.onSave) + self.codeToolBar.loadButton.clicked.connect(self.onLoad) + self.codeToolBar.buildButton.clicked.connect(self.doBuild) + self.codeToolBar.scatterButton.clicked.connect(self.doScatter) + + topSection = QtWidgets.QVBoxLayout() + topSection.addWidget(self.pythonViewer) + topSection.addWidget(self.codeToolBar) + topSection.setContentsMargins(0,0,0,5) + + topWidget = QtWidgets.QWidget() + topWidget.setLayout(topSection) + + splitter.addWidget(topWidget) + splitter.addWidget(self.outputViewer) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 1) + hbox.addWidget(splitter) + + # Function viewer + self.functionViewer = FunctionViewer() + self.functionViewer.radius_control.radiusField.valueChanged.connect(self.onRadiusChanged) + + # A components + hbox.addWidget(self.functionViewer) + self.definitionTab.setLayout(hbox) + + # + # Parameters Tab + # + + self._parameterTableModel = ParameterTableModel() + self.parametersTable = ParameterTable(self._parameterTableModel) + self.parameterTabButtons = ParameterTableButtons() + + self.parameterTabLayout.addWidget(self.parametersTable) + self.parameterTabLayout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) + self.parameterTabLayout.addWidget(self.parameterTabButtons) + + # + # Ensemble tab + # + + + self.angularSamplingMethodSelector = AngularSamplingMethodSelector() + + self.topLayout.addWidget(self.angularSamplingMethodSelector, 0, 1) + + self.structureFactorCombo.addItem("None") # TODO: Structure Factor Options + + + # + # Calculation Tab + # + self.methodComboOptions = ["Grid", "Random"] + for option in self.methodComboOptions: + self.methodCombo.addItem(option) + + # Spatial sampling changed + self.nSamplePoints.valueChanged.connect(self.onTimeEstimateParametersChanged) + + # Q sampling changed + # self.useLogQ.clicked.connect(self.updateQSampling) + # self.qMinBox.textChanged.connect(self.updateQSampling) + # self.qMaxBox.textChanged.connect(self.updateQSampling) + # self.qSamplesBox.valueChanged.connect(self.updateQSampling) + + self.qSamplesBox.valueChanged.connect(self.onTimeEstimateParametersChanged) + + # + # Output Tabs + # + + self.outputCanvas = QCanvas() + + outputLayout = QtWidgets.QVBoxLayout() + outputLayout.addWidget(self.outputCanvas) + + self.qSpaceTab.setLayout(outputLayout) + + # + # Misc + # + + # Populate tables + + self.structureFactorParametersTable.setHorizontalHeaderLabels(["Name", "Value", "Min", "Max", "Fit", ""]) + self.structureFactorParametersTable.horizontalHeader().setStretchLastSection(True) + + # Set up variables + + self.last_calculation_time: Optional[float] = None + self.last_calculation_n_r: int = 0 + self.last_calculation_n_q: int = 0 + + self.sld_function: Optional[SLDFunction] = None + self.sld_coordinate_mapping: Optional[CoordinateSystemTransform] = None + self.magnetism_function: Optional[np.ndarray] = None + self.magnetism_coordinate_mapping: Optional[np.ndarray] = None + + def onRadiusChanged(self): + if self.radiusFromParticleTab.isChecked(): + self.sampleRadius.setValue(self.functionViewer.radius_control.radius()) + + def onTimeEstimateParametersChanged(self): + """ Called when the number of samples changes """ + + # TODO: This needs to be updated based on the number of angular samples now + # Should have a n_points term + # Should have a n_points*n_angles + + # Update the estimate of time + # Assume the amount of time is just determined by the number of + # sample points (the sld calculation is the limiting factor) + + if self.last_calculation_time is not None: + time_per_sample = self.last_calculation_time / self.last_calculation_n_r + + est_time = time_per_sample * int(self.nSamplePoints.value()) * int(self.qSamplesBox.value()) + + self.timeEstimateLabel.setText(f"Estimated Time: {format_time_estimate(est_time)}") + + def onLoad(self): + print("Load clicked") + + def onSave(self): + print("Save clicked") + + def doBuild(self): + """ Build functionality requested""" + + # Get the text from the window + code = self.pythonViewer.toPlainText() + + self.outputViewer.reset() + + try: + # TODO: Make solvent SLD available (process code_takes it as a parameter currently) + + # Evaluate code + function, xyz_converter, extra_parameter_names, extra_parameter_defs = \ + process_code(code, + text_callback=self.codeText, + warning_callback=self.codeError, + error_callback=self.codeError) + + if function is None: + return False + + # TODO: Magnetism + self.parametersTable.update_contents(function, None) + + + # Vectorise if needed + maybe_vectorised = vectorise_sld( + function, + warning_callback=self.codeWarning, + error_callback=self.codeError) # TODO: Deal with args + + if maybe_vectorised is None: + return False + + self.functionViewer.setSLDFunction(maybe_vectorised, xyz_converter) + self.sld_function = maybe_vectorised + self.sld_coordinate_mapping = xyz_converter + + + now = datetime.now() + current_time = now.strftime("%Y-%m-%d %H:%M:%S") + self.codeText(f"Built Successfully at {current_time}") + return True + + except FunctionDefinitionFailed as e: + self.codeError(e.args[0]) + return False + + + def angularDistribution(self) -> AngularDistribution: + """ Get the AngularDistribution object that represents the GUI selected orientational distribution""" + return self.angularSamplingMethodSelector.generate_sampler() + + def qSampling(self) -> QSample: + q_min = float(self.qMinBox.text()) # TODO: Use better box + q_max = float(self.qMaxBox.text()) + n_q = int(self.qSamplesBox.value()) + is_log = bool(self.useLogQ.isChecked()) + + return QSample(q_min, q_max, n_q, is_log) + + def spatialSampling(self) -> SpatialDistribution: + """ Calculate the spatial sampling object based on current gui settings""" + sample_type = self.methodCombo.currentIndex() + + # All the methods need the radius, number of points, etc + radius = float(self.sampleRadius.value()) + n_points = int(self.nSamplePoints.value()) + seed = int(self.randomSeed.text()) if self.fixRandomSeed.isChecked() else None + + if sample_type == 0: + return GridSampling(radius=radius, desired_points=n_points) + # return MixedSphereSample(radius=radius, n_points=n_points, seed=seed) + + elif sample_type == 1: + return UniformCubeSampling(radius=radius, desired_points=n_points, seed=seed) + # return MixedCubeSample(radius=radius, n_points=n_points, seed=seed) + + else: + raise ValueError("Unknown index for spatial sampling method combo") + + def sldDefinition(self) -> SLDDefinition: + + if self.sld_function is not None: + + return SLDDefinition( + self.sld_function, + self.sld_coordinate_mapping) + + else: + raise NotImplementedError("Careful handling of SLD Definitions not implemented yet") + + def magnetismDefinition(self) -> Optional[MagnetismDefinition]: + return None + + def particleDefinition(self) -> ParticleDefinition: + """ Get the ParticleDefinition object that contains the SLD and magnetism functions """ + + return ParticleDefinition( + self.sldDefinition(), + self.magnetismDefinition()) + + def parametersForCalculation(self) -> CalculationParameters: + return self._parameterTableModel.calculation_parameters() + + def polarisationVector(self) -> np.ndarray: + """ Get a numpy vector representing the GUI specified polarisation vector""" + return np.array([0,0,1]) + + def currentSeed(self): + return self.randomSeed + + def scatteringCalculation(self) -> ScatteringCalculation: + """ Get the ScatteringCalculation object that represents the calculation that + is to be passed to the solver""" + angular_distribution = self.angularDistribution() + spatial_sampling = self.spatialSampling() + q_sampling = self.qSampling() + particle_definition = self.particleDefinition() + parameter_definition = self.parametersForCalculation() + polarisation_vector = self.polarisationVector() + seed = self.currentSeed() + bounding_surface_check = self.continuityCheck.isChecked() + + return ScatteringCalculation( + q_sampling=q_sampling, + angular_sampling=angular_distribution, + spatial_sampling_method=spatial_sampling, + particle_definition=particle_definition, + parameter_settings=parameter_definition, + polarisation_vector=polarisation_vector, + seed=seed, + bounding_surface_sld_check=bounding_surface_check, + sample_chunk_size_hint=100_000 + ) + + def doScatter(self): + """ Scatter functionality requested""" + + # attempt to build + # don't do scattering if build fails + + build_success = self.doBuild() + + self.codeText("Calculating scattering...") + + if build_success: + calc = self.scatteringCalculation() + try: + scattering_result = calculate_scattering(calc) + + # Time estimates + self.last_calculation_time = scattering_result.calculation_time + self.last_calculation_n_r = calc.spatial_sampling_method.n_points + + self.onTimeEstimateParametersChanged() + + # Output info + self.codeText("Scattering calculation complete after %g seconds."%scattering_result.calculation_time) + self.display_calculation_result(scattering_result) + + except Exception: + self.codeError(traceback.format_exc()) + + + else: + self.codeError("Build failed, scattering cancelled") + + def display_calculation_result(self, scattering_result: ScatteringOutput): + """ Update graphs and select tab""" + + # Plot + self.outputCanvas.data = scattering_result + + self.tabWidget.setCurrentIndex(5) # Move to output tab if complete + def onFit(self): + """ Fit functionality requested""" + pass + + def codeError(self, text): + """ Show an error concerning input code""" + self.outputViewer.addError(text) + + def codeText(self, text): + """ Show info about input code / code stdout""" + self.outputViewer.addText(text) + + def codeWarning(self, text): + """ Show a warning about input code""" + self.outputViewer.addWarning(text) + + def qSampling(self) -> QSample: + """ Calculate the q sampling object based on current gui settings""" + is_log = self.useLogQ.isEnabled() and self.useLogQ.isChecked() # Only when it's an option + min_q = float(self.qMinBox.text()) + max_q = float(self.qMaxBox.text()) + n_samples = int(self.qSamplesBox.value()) + + return QSample(min_q, max_q, n_samples, is_log) + + +def main(): + """ Demo/testing window""" + + from sas.qtgui.convertUI import main + + main() + + app = QtWidgets.QApplication([]) + window = DesignWindow() + + window.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py new file mode 100644 index 0000000000..d6d4d9b038 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/FunctionViewer.py @@ -0,0 +1,405 @@ +import numpy as np + +from PySide6 import QtGui, QtWidgets, QtCore +from PySide6.QtCore import Qt + + +from sas.qtgui.Perspectives.ParticleEditor.LabelledSlider import LabelledSlider +from sas.qtgui.Perspectives.ParticleEditor.SLDMagnetismOption import SLDMagnetismOption +from sas.qtgui.Perspectives.ParticleEditor.ViewerButtons import AxisButtons, PlaneButtons +from sas.qtgui.Perspectives.ParticleEditor.RadiusSelection import RadiusSelection + +from sas.qtgui.Perspectives.ParticleEditor.defaults import sld as default_sld +from sas.qtgui.Perspectives.ParticleEditor.function_processor import spherical_converter + +def rotation_matrix(alpha: float, beta: float): + + sa = np.sin(alpha) + ca = np.cos(alpha) + sb = np.sin(beta) + cb = np.cos(beta) + + xz = np.array([ + [ ca, 0, -sa], + [ 0, 1, 0], + [ sa, 0, ca]]) + + yz = np.array([ + [1, 0, 0 ], + [0, cb, -sb], + [0, sb, cb]]) + + return np.dot(xz, yz) +def cross_section_coordinates(radius: float, alpha: float, beta: float, plane_distance: float, n_points: int): + + xy_values = np.linspace(-radius, radius, n_points) + + x, y = np.meshgrid(xy_values, xy_values) + x = x.reshape((-1, )) + y = y.reshape((-1, )) + + z = np.zeros_like(x) + plane_distance + + xyz = np.vstack((x, y, z)) + + r = rotation_matrix(alpha, beta) + + return np.dot(r, xyz).T + +def draw_line_in_place(im, x0, y0, dx, dy, channel): + """ Simple line drawing (otherwise need to import something heavyweight)""" + + length = np.sqrt(dx * dx + dy * dy) + # print(x, y, dx, dy, length) + + if length == 0: + return + + for i in range(int(length)): + x = int(x0 + i * dx / length) + y = int(y0 + i * dy / length) + im[y, x, channel] = 255 + + +class FunctionViewer(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.n_draw_layers = 99 + self.layer_size = 100 + self.radius = 1 + self.upscale = 2 + self._size_px = self.layer_size*self.upscale + self.function = default_sld + # self.function = lambda x,y,z: x + self.coordinate_mapping = spherical_converter + + self.alpha = 0.0 + self.beta = np.pi + self.normal_offset = 0.0 + self.mag_theta = 0.0 + self.mag_phi = 0.0 + + self._graphics_viewer_offset = 5 + + + # + # Qt Setup + # + + # Density + + self.densityViewer = QtWidgets.QGraphicsView() + + self.densityScene = QtWidgets.QGraphicsScene() + self.densityViewer.setScene(self.densityScene) + + pixmap = QtGui.QPixmap(self._size_px, self._size_px) + self.densityPixmapItem = self.densityScene.addPixmap(pixmap) + + self.densityViewer.setFixedWidth(self._size_px + self._graphics_viewer_offset) + self.densityViewer.setFixedHeight(self._size_px + self._graphics_viewer_offset) + self.densityViewer.setCursor(Qt.OpenHandCursor) + + # Slice + + self.sliceViewer = QtWidgets.QGraphicsView() + + self.sliceScene = QtWidgets.QGraphicsScene() + self.sliceViewer.setScene(self.sliceScene) + + pixmap = QtGui.QPixmap(self._size_px, self._size_px) + self.slicePixmapItem = self.sliceScene.addPixmap(pixmap) + + self.sliceViewer.setFixedWidth(self._size_px + self._graphics_viewer_offset) + self.sliceViewer.setFixedHeight(self._size_px + self._graphics_viewer_offset) + self.sliceViewer.setCursor(Qt.OpenHandCursor) + + # General control + + self.radius_control = RadiusSelection("View Radius") + self.radius_control.radiusField.valueChanged.connect(self.onRadiusChanged) + + self.plane_buttons = PlaneButtons(self.setAngles) + + self.depth_slider = LabelledSlider("Depth", -100, 100, 0, name_width=35, value_width=35, value_units="%") + self.depth_slider.valueChanged.connect(self.onDepthChanged) + + # Magnetism controls + + self.sld_magnetism_option = SLDMagnetismOption() + self.sld_magnetism_option.sldOption.clicked.connect(self.onDisplayTypeSelected) + self.sld_magnetism_option.magnetismOption.clicked.connect(self.onDisplayTypeSelected) + + self.mag_theta_slider = LabelledSlider("θ", -180, 180, 0) + self.mag_theta_slider.valueChanged.connect(self.onMagThetaChanged) + + self.mag_phi_slider = LabelledSlider("φ", 0, 180, 0) + self.mag_phi_slider.valueChanged.connect(self.onMagPhiChanged) + + self.mag_buttons = AxisButtons(lambda x,y: None) + + magLayout = QtWidgets.QVBoxLayout() + + magGroup = QtWidgets.QGroupBox("B Field (display)") + magGroup.setLayout(magLayout) + + magLayout.addWidget(self.mag_theta_slider) + magLayout.addWidget(self.mag_phi_slider) + magLayout.addWidget(self.mag_buttons) + + # Main layout + + spacer = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + layout = QtWidgets.QVBoxLayout() + + layout.addWidget(self.densityViewer) + layout.addWidget(self.plane_buttons) + layout.addWidget(self.radius_control) + layout.addWidget(self.sliceViewer) + layout.addWidget(self.depth_slider) + layout.addItem(spacer) + layout.addWidget(self.sld_magnetism_option) + layout.addWidget(magGroup) + + self.setLayout(layout) + + self.setFixedWidth(self._size_px + 20) # Perhaps a better way of keeping the viewer width small? + + # Mouse response + self.densityViewer.viewport().installEventFilter(self) + self.sliceViewer.viewport().installEventFilter(self) + + self.lastMouseX = 0 + self.lastMouseY = 0 + self.dThetadX = 0.01 + self.dPhidY = -0.01 + + self.radius = self.radius_control.radius() + + # Show images + self.updateImage() + def eventFilter(self, source, event): + """ Event filter intercept, grabs mouse drags on the images""" + + if event.type() == QtCore.QEvent.MouseButtonPress: + + if (source is self.densityViewer.viewport()) or \ + (source is self.sliceViewer.viewport()): + + x, y = event.pos().x(), event.pos().y() + self.lastMouseX = x + self.lastMouseY = y + + return + + elif event.type() == QtCore.QEvent.MouseMove: + if (source is self.densityViewer.viewport()) or \ + (source is self.sliceViewer.viewport()): + + x, y = event.pos().x(), event.pos().y() + dx = x - self.lastMouseX + dy = y - self.lastMouseY + + self.alpha += self.dThetadX * dx + self.beta += self.dPhidY * dy + + self.alpha %= 2 * np.pi + self.beta %= 2 * np.pi + + self.lastMouseX = x + self.lastMouseY = y + + self.updateImage() + + return + + super().eventFilter(source, event) + + + def onRadiusChanged(self): + """ Draw radius changed """ + self.radius = self.radius_control.radius() + self.updateImage() + + def setSLDFunction(self, fun, coordinate_mapping): + """ Set the function to be plotted """ + self.function = fun + self.coordinate_mapping = coordinate_mapping + + self.updateImage() + + def onDisplayTypeSelected(self): + """ Switch between SLD and magnetism view """ + + if self.sld_magnetism_option.magnetismOption.isChecked(): + print("Magnetic view selected") + if self.sld_magnetism_option.sldOption.isChecked(): + print("SLD view selected") + + # def onThetaChanged(self): + # self.theta = np.pi*float(self.theta_slider.value())/180 + # self.updateImage() + # def onPhiChanged(self): + # self.phi = np.pi * float(self.phi_slider.value()) / 180 + # self.updateImage() + + def onMagThetaChanged(self): + """ Magnetic field theta angle changed """ + + self.mag_theta = np.pi*float(self.mag_theta_slider.value())/180 + self.updateImage(mag_only=True) + def onMagPhiChanged(self): + """ Magnetic field phi angle changed """ + self.mag_phi = np.pi * float(self.mag_phi_slider.value()) / 180 + self.updateImage(mag_only=True) + + def onDepthChanged(self): + """ Callback for cross section depth slider """ + self.normal_offset = self.radius * float(self.depth_slider.value()) / 100 + self.updateImage() + + def setAngles(self, alpha_deg, beta_deg): + """ Set the viewer angles """ + self.alpha = np.pi * alpha_deg / 180 + self.beta = np.pi * (beta_deg + 180) / 180 + + self.updateImage() + + def updateImage(self, mag_only=True): + + """ Update the images in the viewer""" + + # Draw density plot + + bg_values = None + for depth in np.linspace(-self.radius, self.radius, self.n_draw_layers+2)[1:-1]: + sampling = cross_section_coordinates(self.radius, self.alpha, self.beta, depth, self.layer_size) + a, b, c = self.coordinate_mapping(sampling[:, 0], sampling[:, 1], sampling[:, 2]) + + values = self.function(a, b, c) # TODO: Need to handle parameters properly + + if bg_values is None: + bg_values = values + else: + bg_values += values + + min_value = np.min(bg_values) + max_value = np.max(bg_values) + + if min_value == max_value: + bg_values = np.ones_like(bg_values) * 0.5 + else: + diff = max_value - min_value + bg_values -= min_value + bg_values *= (1/diff) + + bg_values = bg_values.reshape((self.layer_size, self.layer_size, 1)) + bg_image_values = np.array(bg_values * 255, dtype=np.uint8) + + bg_image_values = bg_image_values.repeat(self.upscale, axis=0).repeat(self.upscale, axis=1) + + image = np.concatenate((bg_image_values, bg_image_values, bg_image_values), axis=2) + + self.drawScale(image) + self.drawAxes(image) + + height, width, channels = image.shape + bytes_per_line = channels * width + qimage = QtGui.QImage(image.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888) + pixmap = QtGui.QPixmap(qimage) + self.densityPixmapItem.setPixmap(pixmap) + + + # Cross section image + + sampling = cross_section_coordinates(self.radius, self.alpha, self.beta, self.normal_offset, self._size_px) + a,b,c = self.coordinate_mapping(sampling[:, 0], sampling[:, 1], sampling[:, 2]) + + values = self.function(a,b,c) # TODO: Need to handle parameters properly + + values = values.reshape((self._size_px, self._size_px, 1)) + + min_value = np.min(values) + max_value = np.max(values) + + if min_value == max_value: + values = np.zeros_like(values) + else: + diff = max_value - min_value + values -= min_value + values *= (1/diff) + + image_values = np.array(values*255, dtype=np.uint8) + + image = np.concatenate((image_values, image_values, image_values), axis=2) + + self.drawScale(image) + self.drawAxes(image) + + height, width, channels = image.shape + bytes_per_line = channels * width + qimage = QtGui.QImage(image.data, width, height, bytes_per_line, QtGui.QImage.Format_RGB888) + pixmap = QtGui.QPixmap(qimage) + self.slicePixmapItem.setPixmap(pixmap) + + + + def drawScale(self, im): + """ Draw a scalebar """ + pass + + def drawAxes(self, im): + """ Draw a small xyz axis on an image""" + vectors = 20*rotation_matrix(self.alpha, self.beta) + + y = self._size_px - 30 + x = 30 + + # xy coordinates + for i in range(1): + for j in range(1): + + draw_line_in_place(im, x+i, y+j, vectors[0, 0], vectors[0, 1], 0) + draw_line_in_place(im, x+i, y+j, vectors[1, 0], vectors[1, 1], 1) + draw_line_in_place(im, x+i, y+j, vectors[2, 0], vectors[2, 1], 2) + draw_line_in_place(im, x+i, y+j, vectors[2, 0], vectors[2, 1], 1) # Blue is hard to see, make it cyan + +def main(): + """ Show a demo of the function viewer""" + + from sas.qtgui.Perspectives.ParticleEditor.function_processor import spherical_converter + def micelle(r, theta, phi): + out = np.zeros_like(r) + out[r<1] = 4 + out[r<0.8] = 1 + return out + + def cube_function(x, y, z): + + inside = np.logical_and(np.abs(x) <= 0.5, + np.logical_and( + np.abs(y) <= 0.5, + np.abs(z) <= 0.5 )) + # + # print(cube_function) + # print(np.any(inside), np.any(np.logical_not(inside))) + + out = np.zeros_like(x) + out[inside] = 1.0 + + return out + + def pseudo_orbital(r, theta, phi): + return np.exp(-6*r)*r*np.abs(np.cos(2*theta)) + + app = QtWidgets.QApplication([]) + viewer = FunctionViewer() + viewer.setSLDFunction(pseudo_orbital, spherical_converter) + + viewer.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/GeodesicSampleSelector.py b/src/sas/qtgui/Perspectives/ParticleEditor/GeodesicSampleSelector.py new file mode 100644 index 0000000000..854f65f4eb --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/GeodesicSampleSelector.py @@ -0,0 +1,57 @@ +from typing import Optional + +from PySide6.QtWidgets import QSpinBox, QWidget + +from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic + +class GeodesicSamplingSpinBox(QSpinBox): + """ SpinBox that only takes values that corresponds to the number of vertices on a geodesic sphere """ + + def __init__(self, parent: Optional[QWidget]=None): + super().__init__(parent) + + self.setMaximum( + Geodesic.points_for_division_amount( + Geodesic.minimal_divisions_for_points(99999999))) + + self.setMinimum( + Geodesic.points_for_division_amount(1)) + + self.n_divisions = 3 + + self.editingFinished.connect(self.onEditingFinished) + + self.updateDisplayValue() + + + + def updateDisplayValue(self): + self.setValue(Geodesic.points_for_division_amount(self.n_divisions)) + + def onEditingFinished(self): + value = self.value() + self.n_divisions = Geodesic.minimal_divisions_for_points(value) + self.updateDisplayValue() + + def getNDivisions(self): + return self.n_divisions + + def stepBy(self, steps: int): + + self.n_divisions = max([1, self.n_divisions + steps]) + self.updateDisplayValue() + + +def main(): + """ Show a demo of the spinner """ + + from PySide6 import QtWidgets + + app = QtWidgets.QApplication([]) + slider = GeodesicSamplingSpinBox() + slider.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/LabelledSlider.py b/src/sas/qtgui/Perspectives/ParticleEditor/LabelledSlider.py new file mode 100644 index 0000000000..0bd2fd8113 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/LabelledSlider.py @@ -0,0 +1,41 @@ +from PySide6 import QtWidgets +from PySide6.QtCore import Qt + +class LabelledSlider(QtWidgets.QWidget): + """ Slider with labels and value text""" + def __init__(self, name: str, min_value: int, max_value: int, value: int, tick_interval: int=10, name_width=10, value_width=30, value_units="°"): + super().__init__() + + self.units = value_units + + self.name_label = QtWidgets.QLabel(name) + self.name_label.setFixedWidth(name_width) + + self.slider = QtWidgets.QSlider(Qt.Horizontal) + self.slider.setRange(min_value, max_value) + self.slider.setTickInterval(tick_interval) + self.slider.valueChanged.connect(self._on_value_changed) + + self.value_label = QtWidgets.QLabel(str(value) + value_units) + self.value_label.setFixedWidth(value_width) + self.value_label.setAlignment(Qt.AlignRight) + + layout = QtWidgets.QHBoxLayout() + layout.addWidget(self.name_label) + layout.addWidget(self.slider) + layout.addWidget(self.value_label) + layout.setContentsMargins(0,0,0,0) + + self.setLayout(layout) + + + def _on_value_changed(self): + self.value_label.setText("%i"%self.value() + self.units) + + @property + def valueChanged(self): + return self.slider.valueChanged + + def value(self) -> int: + return int(self.slider.value()) + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py new file mode 100644 index 0000000000..7560e62d80 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/OutputViewer.py @@ -0,0 +1,64 @@ + +from PySide6 import QtWidgets +from PySide6.QtGui import QFont + +from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter +from sas.system.version import __version__ as version + +initial_text = f"

Particle Editor Log - SasView {version}

" + +class OutputViewer(QtWidgets.QTextEdit): + """ Python text editor window""" + + def __init__(self, parent=None): + super().__init__(parent) + + # System independent monospace font + f = QFont("unexistent") + f.setStyleHint(QFont.Monospace) + f.setPointSize(9) + f.setWeight(QFont.Weight(500)) + + self.setFont(f) + + self.setText(initial_text) + + def keyPressEvent(self, e): + """ Itercepted key press event""" + + # Do nothing + + return + + def _htmlise(self, text): + return "
".join(text.split("\n")) + + def appendAndMove(self, text): + self.append(text) + scrollbar = self.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def reset(self): + self.setText(initial_text) + + def addError(self, text): + self.appendAndMove(f'{self._htmlise(text)}') + + def addText(self, text): + self.appendAndMove(f'{self._htmlise(text)}') + + def addWarning(self, text): + self.appendAndMove(f'{self._htmlise(text)}') + + + +def main(): + app = QtWidgets.QApplication([]) + viewer = OutputViewer() + + viewer.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterEntries.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterEntries.py new file mode 100644 index 0000000000..b006362af1 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterEntries.py @@ -0,0 +1,91 @@ +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QLineEdit, QCheckBox, QSizePolicy, QSpacerItem +from PySide6.QtGui import QDoubleValidator +from PySide6.QtCore import Qt + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import Parameter, MagnetismParameterContainer +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableModel import ParameterTableModel + + +class ParameterEntry(QWidget): + """ GUI Entry""" + + def __init__(self, parameter: Parameter, model: ParameterTableModel, base_size=50): + super().__init__() + + self._parameter = parameter + self._base_size = base_size + self._model = model + + self.main_layout = QHBoxLayout() + self.main_layout.setContentsMargins(0,0,0,0) + + # Stuff with space either side + self.main_layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) + self._add_items() + self.main_layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) + + self.setLayout(self.main_layout) + + + + def _add_items(self): + """ Add the components to this widget """ + if self._parameter.in_use: + parameter_text = self._parameter.name + else: + parameter_text = f"{self._parameter.name} (unused)" + + self.name_label = QLabel(parameter_text) + self.name_label.setFixedWidth(self._base_size) + self.name_label.setAlignment(Qt.AlignRight) + + self.value_field = QLineEdit() + doubleValidator = QDoubleValidator() + self.value_field.setValidator(doubleValidator) + self.value_field.setText(str(self._parameter.value)) + self.value_field.setFixedWidth(75) + self.value_field.textEdited.connect(self._update_value) + + self.fit_check = QCheckBox("Fit") + self.fit_check.setFixedWidth(40) + + self.main_layout.addWidget(self.name_label) + self.main_layout.addWidget(self.value_field) + self.main_layout.addWidget(self.fit_check) + + + def _update_value(self): + """ Called when the value is changed""" + + try: + value = float(self.value_field.text()) + self._parameter.value = value + print("Set", self._parameter.name, "=", self._parameter.value) + + except ValueError: + # Just don't update + pass + + + + +class MagneticParameterEntry(ParameterEntry): + + def __init__(self, parameter: MagnetismParameterContainer, model: ParameterTableModel): + self._entry = parameter + + super().__init__(parameter.parameter, model) + + def _add_items(self): + super()._add_items() + + self.link_check = QCheckBox("Link to SLD") + self.link_check.setChecked(self._entry.linked) + self.link_check.setFixedWidth(80) + + if self._model.can_link(self._parameter.name): + self.link_check.setEnabled(True) + else: + self.link_check.setEnabled(False) + + self.main_layout.addWidget(self.link_check) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTable.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTable.py new file mode 100644 index 0000000000..edc5e747bf --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTable.py @@ -0,0 +1,94 @@ +from typing import Optional + +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QSpacerItem, QSizePolicy + +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterTableModel import ParameterTableModel +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.ParameterEntries import ParameterEntry, MagneticParameterEntry +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import SLDFunction, MagnetismFunction + +class ParameterTable(QWidget): + """ Main table of parameters """ + def __init__(self, model: ParameterTableModel): + super().__init__() + + self._model = model + + self._layout = QVBoxLayout() + self.setLayout(self._layout) + + self.build() + + def build(self): + """ Build the list of parameters""" + # General parameters + self._layout.addWidget(self._section_heading("General Parameters")) + for parameter in self._model.fixed_parameters: + entry = ParameterEntry(parameter, self._model, base_size=150) + self._layout.addWidget(entry) + + self._layout.addWidget(self._section_heading("SLD Function Parameters")) + for parameter in self._model.sld_parameters: + entry = ParameterEntry(parameter, self._model) + self._layout.addWidget(entry) + + self._layout.addWidget(self._section_heading("Magnetism Parameters")) + for parameter in self._model.magnetism_parameters: + entry = MagneticParameterEntry(parameter, self._model) + self._layout.addWidget(entry) + + def _section_heading(self, text: str): + span = QWidget() + layout = QHBoxLayout() + label = QLabel(""+text+"") + + layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) + layout.addWidget(label) + layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)) + + + span.setLayout(layout) + return span + + def clear(self): + """ Clear the list of parameters """ + while self._layout.count(): + child = self._layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + def rebuild(self): + """ Rebuild the parameter list""" + self.clear() + self.build() + + def clean(self): + """ Clean up the unused parameters in the parameter list """ + self._model.clean() + self.rebuild() + + def update_contents(self, sld_function: SLDFunction, magnetism_function: Optional[MagnetismFunction]): + """ Update the contents of the parameter table with new functions""" + self._model.update_from_code(sld_function, magnetism_function) + self.rebuild() + + +def main(): + """ Show a demo of the table """ + from PySide6 import QtWidgets + app = QtWidgets.QApplication([]) + + model = ParameterTableModel() + + def test_function_1(x, y, z, a, b, c=7): pass + def test_function_2(x, y, z, a, d=2, c=5): pass + + model.update_from_code(test_function_1, test_function_2) + + table = ParameterTable(model) + + table.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py new file mode 100644 index 0000000000..3fb0c2db5d --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableButtons.py @@ -0,0 +1,9 @@ +from PySide6.QtWidgets import QWidget +from sas.qtgui.Perspectives.ParticleEditor.ParameterFunctionality.UI.ParameterTableButtonsUI import Ui_ParameterTableButtons + + +class ParameterTableButtons(QWidget, Ui_ParameterTableButtons): + def __init__(self): + super().__init__() + + self.setupUi(self) \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableModel.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableModel.py new file mode 100644 index 0000000000..6fe8176c3e --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/ParameterTableModel.py @@ -0,0 +1,250 @@ + +""" Code that handles the parameter list backend """ + +from typing import Optional, Dict + +import inspect + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import SLDFunction, MagnetismFunction +from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import ( + SolventSLD, Background, Scale, FunctionParameter, MagnetismParameterContainer, ValueSource, CalculationParameters) + +class LinkingImpossible(Exception): pass + +class ParameterTableModel: + """ + Parameter list backend + + The main issues that this class needs to deal with + 1) Having values that don't get overridden arbitrarily + 2) Magnetism and SLD may or may not have different values for the same parameter name + """ + + def __init__(self): + # General parameters that are always there + self.solvent_sld_parameter = SolventSLD() + self.background_parameter = Background() + self.scale_parameter = Scale() + + self.fixed_parameters = [ + self.solvent_sld_parameter, + self.background_parameter, + self.scale_parameter] + + self._sld_parameters: Dict[str, FunctionParameter] = {} + self._magnetism_parameters: Dict[str, MagnetismParameterContainer] = {} + + @property + def sld_parameters(self): + sorted_keys = sorted(self._sld_parameters.keys()) + return [self._sld_parameters[key] for key in sorted_keys] + + @property + def magnetism_parameters(self): + sorted_keys = sorted(self._magnetism_parameters.keys()) + return [self._magnetism_parameters[key] for key in sorted_keys] + + def can_link(self, name: str): + """ Can a magnetism be linked to an SLD parameter? """ + return (name in self._magnetism_parameters) and (name in self._sld_parameters) + + def set_link_status(self, name: str, linked: bool): + """ Set the status of the link of a magnetism to a SLD parameter""" + + if name not in self._magnetism_parameters: + raise LinkingImpossible(f"{name} is not a magnetic parameter") + + if linked: + if name not in self._sld_parameters: + raise LinkingImpossible(f"Cannot link parameters with name '{name}' - not an SLD parameter") + + self._magnetism_parameters[name].linked = linked + + def update_from_code(self, sld: SLDFunction, magnetism: Optional[MagnetismFunction]): + """ Update the parameter list with sld and magnetism functions """ + + # Mark all SLD/magnetism parameters as not in use, we'll mark them as in use if + # they are there in the functions passed here + for key in self._sld_parameters: + self._sld_parameters[key].in_use = False + + for key in self._magnetism_parameters: + self._magnetism_parameters[key].parameter.in_use = False + + # + # Update base on the SLD function + # + + sig = inspect.signature(sld) + function_parameters = list(sig.parameters.items()) + + if len(function_parameters) < 3: + raise ValueError("SLD Function must have at least 3 parameters") + + # First 3 don't count + # This is not quite the same as what we need to do for magnetism + for parameter_name, parameter_details in function_parameters[3:]: + + if parameter_name in self._sld_parameters: + # + # Parameter exists + # + + param_model = self._sld_parameters[parameter_name] + + # Do we want to update the value? Depends on how it was set + if parameter_details.default is not inspect.Parameter.empty: + if param_model.set_by == ValueSource.DEFAULT or param_model.set_by == ValueSource.CODE: + param_model.value = parameter_details.default + param_model.set_by = ValueSource.CODE + + param_model.in_use = True + + else: + + # + # Parameter does not exist + # + + if parameter_details.default is not inspect.Parameter.empty: + new_parameter = FunctionParameter( + name=parameter_name, + value=parameter_details.default, + in_use=True, + set_by=ValueSource.CODE) + + else: + new_parameter = FunctionParameter( + name=parameter_name, + value=1.0, + in_use=True, + set_by=ValueSource.DEFAULT) + + self._sld_parameters[parameter_name] = new_parameter + + # + # Magnetism parameters + # + + if magnetism is not None: + sig = inspect.signature(magnetism) + function_parameters = list(sig.parameters.items()) + + if len(function_parameters) < 3: + raise ValueError("SLD Function must have at least 3 parameters") + + # Again, first 3 don't count + for parameter_name, parameter_details in function_parameters[3:]: + + if parameter_name in self._magnetism_parameters: + + # + # Parameter exists + # + + param_entry = self._magnetism_parameters[parameter_name] + param_model = param_entry.parameter + + # Do we want to update the value? Depends on how it was set + if parameter_details.default is not inspect.Parameter.empty: + if param_model.set_by == ValueSource.DEFAULT or param_model.set_by == ValueSource.CODE: + param_model.value = parameter_details.default + param_model.set_by = ValueSource.CODE + + param_model.in_use = True + + # Linking status should be unchanged + + else: + + # + # Parameter does not exist + # + + if parameter_details.default is not inspect.Parameter.empty: + new_parameter = FunctionParameter( + name=parameter_name, + value=parameter_details.default, + in_use=True, + set_by=ValueSource.CODE) + + # If it has a default, don't link it + new_entry = MagnetismParameterContainer(new_parameter, linked=False) + + else: + + new_parameter = FunctionParameter( + name=parameter_name, + value=1.0, + in_use=True, + set_by=ValueSource.DEFAULT) + + # If it does not have a default, + # link it as long as there is a corresponding SLD parameter + + if parameter_name in self._sld_parameters: + new_entry = MagnetismParameterContainer(new_parameter, linked=True) + else: + new_entry = MagnetismParameterContainer(new_parameter, linked=False) + + self._magnetism_parameters[parameter_name] = new_entry + + def clean(self): + """ Remove parameters that are not in use""" + for key in self._sld_parameters: + if not self._sld_parameters[key].in_use: + del self._sld_parameters[key] + + for key in self._magnetism_parameters: + if not self._magnetism_parameters[key].parameter.in_use: + del self._magnetism_parameters[key] + + # For magnetic parameters that are linked to SLDs, check that + # what they are linked to still exists, if not, remove the link flag + for key in self._magnetism_parameters: + if self._magnetism_parameters[key].linked: + if key not in self._sld_parameters: + self._magnetism_parameters[key].linked = False + + def calculation_parameters(self) -> CalculationParameters: + sld_parameters = {key: self._sld_parameters[key].value + for key in self._sld_parameters + if self._sld_parameters[key].in_use} + + # Currently assume no bad linking + magnetic_parameters_linked: Dict[str, FunctionParameter] = \ + {key: self._sld_parameters[key] + if self._magnetism_parameters[key].linked + else self._magnetism_parameters[key].parameter + for key in self._magnetism_parameters} + + magnetism_parameters = {key: magnetic_parameters_linked[key].value + for key in magnetic_parameters_linked + if magnetic_parameters_linked[key].in_use} + + return CalculationParameters( + solvent_sld=self.solvent_sld_parameter.value, + background=self.background_parameter.value, + scale=self.scale_parameter.value, + sld_parameters=sld_parameters, + magnetism_parameters=magnetism_parameters + ) + +def main(): + + def test_function_1(x, y, z, a, b, c=7): pass + def test_function_2(x, y, z, a, d=2, c=5): pass + + param_list = ParameterTableModel() + param_list.update_from_code(test_function_1, None) + param_list.update_from_code(test_function_2, None) + param_list.update_from_code(test_function_2, test_function_1) + + for parameter in param_list.sld_parameters: + print(parameter) + + for parameter in param_list.magnetism_parameters: + print(parameter) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/UI/ParameterTableButtonsUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/UI/ParameterTableButtonsUI.ui new file mode 100644 index 0000000000..da9b953102 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/UI/ParameterTableButtonsUI.ui @@ -0,0 +1,61 @@ + + + ParameterTableButtons + + + + 0 + 0 + 364 + 20 + + + + Form + + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Remove Unused + + + + + + + Scatter + + + + + + + Series + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/ParameterFunctionality/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/CorrelationCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/CorrelationCanvas.py new file mode 100644 index 0000000000..35f3ec2b80 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/CorrelationCanvas.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Optional + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput + + +class CorrelationCanvas(FigureCanvas): + """ Plot window for output from scattering calculations""" + + def __init__(self, parent=None, width=5, height=4, dpi=100): + self.parent = parent + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.add_subplot(111) + + FigureCanvas.__init__(self, self.fig) + + self._data: Optional[ScatteringOutput] = None + + @property + def data(self): + return self._data + + @data.setter + def data(self, scattering_output: ScatteringOutput): + + self._data = scattering_output + + self.axes.cla() + + if self._data.q_space is not None: + if self._data.q_space.correlation_data is not None: + + plot_data = scattering_output.q_space.correlation_data + + r = plot_data.abscissa + rho = plot_data.ordinate + + self.axes.plot(r, rho) + + import numpy as np + + R = 50 + + f1 = lambda r: 1 - (3 / 4) * (r / R) + (1 / 16) * (r / R) ** 3 + def f(x): + out = np.zeros_like(x) + x_in = x[x <= 2 * R] + out[x <= 2 * R] = f1(x_in) + return out + + self.axes.plot(r, f(r)/6) + + + self.draw() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py new file mode 100644 index 0000000000..95a3b67bc2 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/QCanvas.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import numpy as np +from typing import Optional + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringOutput + +def spherical_form_factor(q, r): + rq = r * q + f = (np.sin(rq) - rq * np.cos(rq)) / (rq ** 3) + return f * f + +class QCanvas(FigureCanvas): + """ Plot window for output from scattering calculations""" + + def __init__(self, parent=None, width=5, height=4, dpi=100): + self.parent = parent + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.add_subplot(111) + + FigureCanvas.__init__(self, self.fig) + + self._data: Optional[ScatteringOutput] = None + + @property + def data(self): + return self._data + + @data.setter + def data(self, scattering_output: ScatteringOutput): + + # print("Setting QPlot Data") + + self._data = scattering_output + self.axes.cla() + + if self._data.q_space is not None: + # print(self._data.q_space) + plot_data = self._data.q_space + + q_sample = plot_data.abscissa + q_values = q_sample() + i_values = plot_data.ordinate + + if q_sample.is_log: + self.axes.loglog(q_values, i_values) + # + # self.axes.axvline(0.07) + # self.axes.axvline(0.13) + # self.axes.axvline(0.19) + # self.axes.axvline(0.25) + # self.axes.axvline(0.30) + + # For comparisons: TODO: REMOVE + # thing = spherical_form_factor(q_values, 50) + # self.axes.loglog(q_values, thing*np.max(i_values)/np.max(thing)) + # It works, NICE! + else: + self.axes.semilogy(q_values, i_values) + + + + self.draw() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py new file mode 100644 index 0000000000..bbaf5ed0da --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/RDFCanvas.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Optional + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput + + +class RDFCanvas(FigureCanvas): + """ Plot window for output from scattering calculations""" + + def __init__(self, parent=None, width=5, height=4, dpi=100): + self.parent = parent + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.add_subplot(111) + + FigureCanvas.__init__(self, self.fig) + + self._data: Optional[ScatteringOutput] = None + + @property + def data(self): + return self._data + + @data.setter + def data(self, scattering_output: ScatteringOutput): + + self._data = scattering_output + + self.axes.cla() + + if self._data.radial_distribution is not None: + + self.axes.plot(self._data.radial_distribution[0], self._data.radial_distribution[1]) + + self.draw() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/SamplingDistributionCanvas.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/SamplingDistributionCanvas.py new file mode 100644 index 0000000000..6c061b4eb0 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/SamplingDistributionCanvas.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Optional + +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from sas.qtgui.Perspectives.ParticleEditor.old_calculations import ScatteringOutput + + +class SamplingDistributionCanvas(FigureCanvas): + """ Plot window for output from scattering calculations""" + + def __init__(self, parent=None, width=5, height=4, dpi=100): + self.parent = parent + self.fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = self.fig.add_subplot(111) + + FigureCanvas.__init__(self, self.fig) + + self._data: Optional[ScatteringOutput] = None + + @property + def data(self): + return self._data + + @data.setter + def data(self, scattering_output: ScatteringOutput): + + self._data = scattering_output + + self.axes.cla() + + for datum in self._data.sampling_distributions: + + self.axes.plot(datum.bin_edges, datum.counts, label=datum.name) + + self.axes.legend() + + self.draw() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/Plots/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/Plots/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py new file mode 100644 index 0000000000..a4562c5ef4 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/PythonViewer.py @@ -0,0 +1,62 @@ + +from PySide6 import QtWidgets, QtGui +from PySide6.QtCore import Qt, Signal, QObject +from PySide6.QtGui import QFont + + +from sas.qtgui.Perspectives.ParticleEditor.syntax_highlight import PythonHighlighter + +from sas.qtgui.Perspectives.ParticleEditor.defaults import default_text +class PythonViewer(QtWidgets.QTextEdit): + """ Python text editor window""" + + build_trigger = Signal() + + def __init__(self, parent=None): + super().__init__() + + # System independent monospace font + f = QFont("unexistent") + f.setStyleHint(QFont.Monospace) + f.setPointSize(10) + f.setWeight(QFont.Weight(700)) + self.setFont(f) + + self.code_highlighter = PythonHighlighter(self.document()) + + self.setText(default_text) + + + def keyPressEvent(self, e): + """ Itercepted key press event""" + if e.key() == Qt.Key_Tab: + + if e.modifiers() == Qt.ShiftModifier: + # TODO: Multiline adjust, tab and shift-tab + pass + + # Swap out tabs for four spaces + self.textCursor().insertText(" ") + return + + if e.key() == Qt.Key_Return and e.modifiers() == Qt.ShiftModifier: + self.build_trigger.emit() + + else: + super().keyPressEvent(e) + + def insertFromMimeData(self, source): + """ Keep own highlighting""" + self.insertPlainText(source.text()) + +def main(): + """ Demo/testing window""" + app = QtWidgets.QApplication([]) + viewer = PythonViewer() + + viewer.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/RadiusSelection.py b/src/sas/qtgui/Perspectives/ParticleEditor/RadiusSelection.py new file mode 100644 index 0000000000..f5cde4932c --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/RadiusSelection.py @@ -0,0 +1,18 @@ +from typing import Optional + +from PySide6 import QtWidgets + +from sas.qtgui.Perspectives.ParticleEditor.UI.RadiusSelectionUI import Ui_RadiusSelection + +class RadiusSelection(QtWidgets.QWidget, Ui_RadiusSelection): + def __init__(self, text: Optional[str]=None, parent=None): + super().__init__() + + self.setupUi(self) + + if text is not None: + self.label.setText(text) + + + def radius(self): + return float(self.radiusField.value()) \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/SLDMagnetismOption.py b/src/sas/qtgui/Perspectives/ParticleEditor/SLDMagnetismOption.py new file mode 100644 index 0000000000..90bda19e05 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/SLDMagnetismOption.py @@ -0,0 +1,8 @@ +from PySide6 import QtWidgets +from sas.qtgui.Perspectives.ParticleEditor.UI.SLDMagOptionUI import Ui_SLDMagnetismOption + +class SLDMagnetismOption(QtWidgets.QWidget, Ui_SLDMagnetismOption): + def __init__(self, parent=None): + super().__init__() + + self.setupUi(self) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/AxisButtonsUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/AxisButtonsUI.ui new file mode 100644 index 0000000000..d742ad1035 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/AxisButtonsUI.ui @@ -0,0 +1,72 @@ + + + AxisSelection + + + + 0 + 0 + 100 + 19 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 30 + 0 + + + + X + + + + + + + + 30 + 0 + + + + Y + + + + + + + + 30 + 0 + + + + Z + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui new file mode 100644 index 0000000000..1a137e0686 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/CodeToolBarUI.ui @@ -0,0 +1,77 @@ + + + CodeToolBar + + + + 0 + 0 + 460 + 20 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Load + + + + + + + Save + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + <html><head/><body><p>Build the current code</p><p>Shortcut: shift-Enter</p></body></html> + + + Build + + + + + + + Scatter + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui new file mode 100644 index 0000000000..bc0dd39ce1 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/DesignWindowUI.ui @@ -0,0 +1,502 @@ + + + DesignWindow + + + + 0 + 0 + 992 + 477 + + + + Form + + + + + + + + + 0 + + + + Definition + + + + + Parameters + + + + + + + + + + Ensemble + + + + + + 0 + + + + + 10 + + + + + 10 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 10 + + + 10 + + + + + Structure Factor + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + Orientational Distribution + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Structure Factor Parameters + + + Qt::AlignCenter + + + + + + + 1 + + + 6 + + + false + + + + + + + + + + + + + + + + + + + Calculation + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 0 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + Q Max + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Ang<sup>-1</sup> + + + + + + + 10 + + + 10000 + + + 10 + + + 200 + + + + + + + Logaritmic + + + true + + + + + + + Neutron Polarisation + + + + + + + 0.5 + + + + + + + Q Samples + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + 1 + + + + + + + 0 + + + + + + + 0 + + + + + + + + + Check sampling boundary for SLD continuity + + + true + + + + + + + + + + Qt::AlignCenter + + + + + + + + + + + + false + + + 0.100000000000000 + + + 100000.000000000000000 + + + 100.000000000000000 + + + + + + + Get From 'Definition' Tab + + + true + + + + + + + + + Sample Radius + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Q Min + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Sample Points + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Sample Method + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Random Seed + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + 0 + + + + + + + Fix Seed + + + + + + + + + + + 100000000 + + + 1000 + + + 100000 + + + + + + + + + + + + + + + + Ang<sup>-1</sup> + + + + + + + 0.0005 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Fitting + + + + + Q Space + + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/PlaneButtonsUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/PlaneButtonsUI.ui new file mode 100644 index 0000000000..23bacb1601 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/PlaneButtonsUI.ui @@ -0,0 +1,72 @@ + + + PlaneSelection + + + + 0 + 0 + 100 + 19 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 30 + 0 + + + + XY + + + + + + + + 30 + 0 + + + + YZ + + + + + + + + 30 + 0 + + + + XZ + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui new file mode 100644 index 0000000000..dddf1e7151 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/RadiusSelectionUI.ui @@ -0,0 +1,86 @@ + + + RadiusSelection + + + + 0 + 0 + 265 + 20 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Radius + + + + + + + 0.100000000000000 + + + 100000.000000000000000 + + + 100.000000000000000 + + + + + + + Å + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/SLDMagOptionUI.ui b/src/sas/qtgui/Perspectives/ParticleEditor/UI/SLDMagOptionUI.ui new file mode 100644 index 0000000000..1d2d24de5c --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/SLDMagOptionUI.ui @@ -0,0 +1,50 @@ + + + SLDMagnetismOption + + + + 0 + 0 + 104 + 16 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + SLD + + + true + + + + + + + Magnetism + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/download-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/download-icon.png new file mode 100644 index 0000000000..8e83a48250 Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/download-icon.png differ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/hammer-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/hammer-icon.png new file mode 100644 index 0000000000..af93d23923 Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/hammer-icon.png differ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc new file mode 100644 index 0000000000..624ba0a01a --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/icons.qrc @@ -0,0 +1,9 @@ + + + download-icon.png + upload-icon.png + save-icon.png + hammer-icon.png + scatter-icon.png + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/save-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/save-icon.png new file mode 100644 index 0000000000..d84dd60913 Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/save-icon.png differ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/scatter-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/scatter-icon.png new file mode 100644 index 0000000000..1034d66ae3 Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/scatter-icon.png differ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/upload-icon.png b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/upload-icon.png new file mode 100644 index 0000000000..5de23c53c4 Binary files /dev/null and b/src/sas/qtgui/Perspectives/ParticleEditor/UI/icons/upload-icon.png differ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/ViewerButtons.py b/src/sas/qtgui/Perspectives/ParticleEditor/ViewerButtons.py new file mode 100644 index 0000000000..5d24ea38fe --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/ViewerButtons.py @@ -0,0 +1,39 @@ +from typing import Callable + +from PySide6 import QtWidgets +from sas.qtgui.Perspectives.ParticleEditor.UI.AxisButtonsUI import Ui_AxisSelection +from sas.qtgui.Perspectives.ParticleEditor.UI.PlaneButtonsUI import Ui_PlaneSelection + +x_angles = (90, 0) +y_angles = (0, 90) +z_angles = (0, 0) + + +class PlaneButtons(QtWidgets.QWidget, Ui_PlaneSelection): + """ XY, XZ, YZ plane selection buttons, sets angles """ + + def __init__(self, set_angles_function: Callable[[float, float], None]): + super().__init__() + self.setupUi(self) + + self.setAngles = set_angles_function + + self.selectXY.clicked.connect(lambda: self.setAngles(*z_angles)) + self.selectYZ.clicked.connect(lambda: self.setAngles(*x_angles)) + self.selectXZ.clicked.connect(lambda: self.setAngles(*y_angles)) + + + +class AxisButtons(QtWidgets.QWidget, Ui_AxisSelection): + """ X, Y, Z axis selection buttons, sets angles """ + + def __init__(self, set_angles_function: Callable[[float, float], None]): + super().__init__() + self.setupUi(self) + + self.setAngles = set_angles_function + + self.selectX.clicked.connect(lambda: self.setAngles(*x_angles)) + self.selectY.clicked.connect(lambda: self.setAngles(*y_angles)) + self.selectZ.clicked.connect(lambda: self.setAngles(*z_angles)) + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py new file mode 100644 index 0000000000..02c60e81bb --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/boundary_check.py @@ -0,0 +1,40 @@ +import numpy as np + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ScatteringCalculation + + +def check_sld_continuity_at_boundary(calculation: ScatteringCalculation, tol=1e-9): + """ Checks the continuity of the SLD at the sampling boundary + + :returns: True if boundary conditions are good + """ + + expected_sld = calculation.parameter_settings.solvent_sld + + x, y, z = calculation.spatial_sampling_method.bounding_surface_check_points() + + a, b, c = calculation.particle_definition.sld.to_cartesian_conversion(x, y, z) + + parameters = calculation.parameter_settings.sld_parameters + slds = calculation.particle_definition.sld.sld_function(a, b, c, **parameters) + + return np.all(np.abs(slds - expected_sld) < tol) + + +def check_mag_zero_at_boundary(calculation: ScatteringCalculation, tol=1e-9): + """ Checks the magnetism vector is zero at the sampling boundary + + :returns: True if boundary conditions are good""" + + if calculation.particle_definition.magnetism is None: + return True + + x, y, z = calculation.spatial_sampling_method.bounding_surface_check_points() + + a, b, c = calculation.particle_definition.sld.to_cartesian_conversion(x, y, z) + + parameters = calculation.parameter_settings.sld_parameters + mag_vectors = calculation.particle_definition.sld.sld_function(a, b, c, **parameters) + + # TODO: Double check this is the right axis, should be quite obvious if it isn't + return np.all(np.sum(mag_vectors**2, axis=1) < tol**2) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py new file mode 100644 index 0000000000..15259c6f0d --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/calculate.py @@ -0,0 +1,68 @@ +import time + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import \ + ScatteringCalculation, QSpaceScattering, ScatteringOutput +from sas.qtgui.Perspectives.ParticleEditor.calculations.fq import scattering_via_fq +from sas.qtgui.Perspectives.ParticleEditor.calculations.boundary_check import ( + check_sld_continuity_at_boundary, check_mag_zero_at_boundary) + +class SLDBoundaryMismatch(Exception): + pass + +class MagBoundaryNonZero(Exception): + pass + + +def calculate_scattering(calculation: ScatteringCalculation) -> ScatteringOutput: + + start_time = time.time() + + # If required, check that SLD/Mag at the boundary of the sample volume matches the rest of the space + if calculation.bounding_surface_sld_check: + if not check_sld_continuity_at_boundary(calculation): + raise SLDBoundaryMismatch("SLD at sampling boundary does not match solvent SLD") + + if not check_mag_zero_at_boundary(calculation): + raise MagBoundaryNonZero("Magnetism is non-zero at sampling boundary") + + # Perform the calculation + sld_def = calculation.particle_definition.sld + mag_def = calculation.particle_definition.magnetism + params = calculation.parameter_settings + spatial_dist = calculation.spatial_sampling_method + q_dist = calculation.q_sampling + angular_dist = calculation.angular_sampling + + scattering = scattering_via_fq( + sld_definition=sld_def, + magnetism_definition=mag_def, + parameters=params, + point_generator=spatial_dist, + q_sample=q_dist, + angular_distribution=angular_dist) + + q_data = QSpaceScattering(q_dist, scattering) + + output = ScatteringOutput( + q_space=q_data, + calculation_time=time.time() - start_time, + seed_used=None) + + return output + + + + + + + + + + + + + + + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py new file mode 100644 index 0000000000..6d3d7e9d68 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye.py @@ -0,0 +1,122 @@ +from typing import Optional, Tuple +import time + +import numpy as np +from scipy.interpolate import interp1d +from scipy.special import jv as bessel +from scipy.spatial.distance import cdist + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + ScatteringCalculation, ScatteringOutput, OrientationalDistribution, SamplingDistribution, + QSpaceScattering, QSpaceCalcDatum, RealSpaceScattering, + SLDDefinition, MagnetismDefinition, SpatialSample, QSample, CalculationParameters) + +from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk, pairwise_chunk_iterator +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution + +from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism + +def debye( + sld_definition: SLDDefinition, + magnetism_definition: Optional[MagnetismDefinition], + parameters: CalculationParameters, + point_generator: SpatialDistribution, + q_sample: QSample, + minor_chunk_size=1000, + preallocate=True): + + q = q_sample() + + output = np.zeros_like(q) + + + # First chunking layer, chunks for dealing with VERY large sample densities + # Uses less memory at the cost of using more processing power + for (x1_large, y1_large, z1_large), (x2_large, y2_large, z2_large) in SingleChunk(point_generator): + sld1 = run_sld(sld_definition, parameters, x1_large, y1_large, z1_large) + sld2 = run_sld(sld_definition, parameters, x2_large, y2_large, z2_large) + + if magnetism_definition is None: + + sld1_total_large = sld1 + sld2_total_large = sld2 + + else: + raise NotImplementedError("Magnetism not implemented yet") + # TODO: implement magnetism + + + + # TODO: Preallocation, do we want it + if preallocate: + r_data_1 = np.zeros((minor_chunk_size, minor_chunk_size)) + r_data_2 = np.zeros((minor_chunk_size, minor_chunk_size)) + rq_data = np.zeros((minor_chunk_size**2, q_sample.n_points)) + + for one, two in pairwise_chunk_iterator( + left_data=(x1_large, y1_large, z1_large, sld1_total_large), + right_data=(x2_large, y2_large, z2_large, sld2_total_large), + chunk_size=minor_chunk_size): + + (x1, y1, z1, sld_total_1) = one + (x2, y2, z2, sld_total_2) = two + + # Optimised calculation of euclidean distance, use pre-allocated memory + + if preallocate: + n1 = len(x1) + n2 = len(x2) + + r_data_1[:n1, :n2] = np.subtract.outer(x1, x2) + r_data_1 **= 2 + + r_data_2[:n1, :n2] = np.subtract.outer(y1, y2) + r_data_1[:n1, :n2] += r_data_2[:n1, :n2] ** 2 + + r_data_2[:n1, :n2] = np.subtract.outer(z1, z2) + r_data_1[:n1, :n2] += r_data_2[:n1, :n2] ** 2 + + np.sqrt(r_data_1, out=r_data_1) # in place sqrt + + r_data_2[:n1, :n2] = np.multiply.outer(sld_total_1, sld_total_2) + + # Build a table of r times q values + + rq_data[:(n1 * n2), :] = np.multiply.outer(r_data_1[:n1, :n2].reshape(-1), q) + + # Calculate sinc part + rq_data[:(n1 * n2), :] = np.sinc(rq_data[:(n1 * n2), :]) + + + # Multiply by paired density + rq_data[:(n1 * n2), :] *= r_data_2[:n1, :n2].reshape(-1, 1) + + + # Multiply by r squared + np.multiply(r_data_1, r_data_1, out=r_data_1) + rq_data[:(n1 * n2), :] *= r_data_1[:n1, :n2].reshape(-1, 1) + + # Add to q data + output += np.sum(rq_data, axis=0) + + else: + + # Non optimised + r_squared = np.subtract.outer(x1, x2) ** 2 + \ + np.subtract.outer(y1, y2) ** 2 + \ + np.subtract.outer(z1, z2) ** 2 + + # print(r_squared.shape) + + correl = sld_total_1.reshape(1, -1) * sld_total_2.reshape(-1, 1) + correl = correl.reshape(-1, 1) + + rq = np.sqrt(r_squared.reshape(-1, 1)) * q.reshape(1,-1) + + # print(rq.shape) + + output += np.sum(r_squared.reshape(-1, 1) * correl * np.sinc(rq), axis=0) + # output += np.sum(r_squared.reshape(-1, 1) * correl * np.sinc(rq), axis=0) + + + return output \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py new file mode 100644 index 0000000000..a4badafed1 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/debye_benchmark.py @@ -0,0 +1,57 @@ +import numpy as np +import time +import matplotlib.pyplot as plt + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import SLDDefinition, CalculationParameters, QSample +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid + +from sas.qtgui.Perspectives.ParticleEditor.calculations.debye import debye + +def sld(x, y, z): + """ Cube sld """ + out = np.ones_like(x) + inds = np.logical_and( + np.logical_and( + np.abs(x) > 50, + np.abs(y) > 50), + np.abs(z) > 50) + out[inds] = 0 + return out + +def transform(x,y,z): + return x,y,z + +sld_def = SLDDefinition(sld_function=sld, to_cartesian_conversion=transform) + +calc_params = CalculationParameters( + solvent_sld=0.0, + background=0.0, + scale=1.0, + sld_parameters={}, + magnetism_parameters={}) + +point_generator = Grid(100, 10_000) + +q = QSample(1e-3, 1, 101, True) + +for chunk_size in [1000]: + for preallocate in [False, True]: + + print("Chunk size %i%s"%(chunk_size, ", preallocate" if preallocate else "")) + + start_time = time.time() + + output = debye( + sld_definition=sld_def, + magnetism_definition=None, + parameters=calc_params, + point_generator=point_generator, + q_sample=q, + minor_chunk_size=chunk_size, + preallocate=preallocate) + + print(time.time() - start_time) + + plt.loglog(q(), output) + +plt.show() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py new file mode 100644 index 0000000000..f94404e42a --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/fq.py @@ -0,0 +1,54 @@ +from typing import Optional, Tuple +import time + +import numpy as np +from scipy.interpolate import interp1d +from scipy.special import jv as bessel +from scipy.spatial.distance import cdist + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import ( + SLDDefinition, MagnetismDefinition, AngularDistribution, QSample, CalculationParameters) + +from sas.qtgui.Perspectives.ParticleEditor.sampling.chunking import SingleChunk, pairwise_chunk_iterator +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution, PointGeneratorStepper + +from sas.qtgui.Perspectives.ParticleEditor.calculations.run_function import run_sld, run_magnetism + +def scattering_via_fq( + sld_definition: SLDDefinition, + magnetism_definition: Optional[MagnetismDefinition], + parameters: CalculationParameters, + point_generator: SpatialDistribution, + q_sample: QSample, + angular_distribution: AngularDistribution, + chunk_size=1_000_000) -> np.ndarray: + + q_magnitudes = q_sample() + + direction_vectors, direction_weights = angular_distribution.sample_points_and_weights() + fq = np.zeros((angular_distribution.n_points, q_sample.n_points), dtype=complex) # Dictionary for fq for all angles + + for x, y, z in PointGeneratorStepper(point_generator, chunk_size): + + sld = run_sld(sld_definition, parameters, x, y, z).reshape(-1, 1) + + # TODO: Magnetism + + for direction_index, direction_vector in enumerate(direction_vectors): + + projected_distance = x*direction_vector[0] + y*direction_vector[1] + z*direction_vector[2] + + i_r_dot_q = np.multiply.outer(projected_distance, 1j*q_magnitudes) + + if direction_index in fq: + fq[direction_index, :] = np.sum(sld*np.exp(i_r_dot_q), axis=0) + else: + fq[direction_index, :] += np.sum(sld*np.exp(i_r_dot_q), axis=0) + + f_squared = fq.real**2 + fq.imag**2 + f_squared *= direction_weights.reshape(-1,1) + + return np.sum(f_squared, axis=0) + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py new file mode 100644 index 0000000000..4f4225340c --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/calculations/run_function.py @@ -0,0 +1,34 @@ +""" Helper functions that run SLD and magnetism functions """ +import numpy as np + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import (SLDDefinition, MagnetismDefinition, CalculationParameters) + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 + + +def run_sld(sld_definition: SLDDefinition, parameters: CalculationParameters, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray: + """ Evaluate the SLD function from the definition object at specified coordinates """ + + sld_function = sld_definition.sld_function + coordinate_transform = sld_definition.to_cartesian_conversion + + a, b, c = coordinate_transform(x, y, z) + + solvent_sld = parameters.solvent_sld # Hopefully the function can see this, but TODO: take py file environment with us from editor + parameter_dict = parameters.sld_parameters.copy() + + return sld_function(a, b, c, **parameter_dict) - solvent_sld + + +def run_magnetism(magnetism_definition: MagnetismDefinition, parameters: CalculationParameters, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> VectorComponents3: + """ Evaluate the magnetism function from the definition at specified coordinates """ + + magnetism_function = magnetism_definition.magnetism_function + coordinate_transform = magnetism_definition.to_cartesian_conversion + + a, b, c = coordinate_transform(x, y, z) + + solvent_sld = parameters.solvent_sld # Hopefully the function can see this, but TODO: take py file environment with us from editor + parameter_dict = parameters.sld_parameters.copy() + + return magnetism_function(a, b, c, **parameter_dict) diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py new file mode 100644 index 0000000000..c55ce7f1e1 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/calculation.py @@ -0,0 +1,142 @@ +from typing import Optional, Callable, Tuple, Protocol, List +import numpy as np +from enum import Enum +from dataclasses import dataclass +from abc import ABC, abstractmethod + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import ( + SLDFunction, MagnetismFunction, CoordinateSystemTransform) +from sas.qtgui.Perspectives.ParticleEditor.datamodel.parameters import CalculationParameters + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 + + +class QSample: + """ Representation of Q Space sampling """ + def __init__(self, start, end, n_points, is_log): + self.start = start + self.end = end + self.n_points = n_points + self.is_log = is_log + + def __repr__(self): + return f"QSampling({self.start}, {self.end}, n={self.n_points}, is_log={self.is_log})" + + def __call__(self): + if self.is_log: + start_log = np.log(self.start) + end_log = np.log(self.end) + return np.exp(np.linspace(start_log, end_log, self.n_points)) + else: + return np.linspace(self.start, self.end, self.n_points) + + +class SpatialDistribution(ABC): + """ Base class for point generators """ + + def __init__(self, radius: float, n_points: int, n_desired_points): + self.radius = radius + self.n_desired_points = n_desired_points + self.n_points = n_points + + @property + def info(self): + """ Information to be displayed in the settings window next to the point number input """ + return "" + + @abstractmethod + def generate(self, start_index: int, end_index: int) -> VectorComponents3: + """ Generate points from start_index up to end_index """ + + @abstractmethod + def _bounding_surface_check_points(self) -> np.ndarray: + """ Points used to check that the SLD/magnetism vector are zero outside the sample space""" + + def bounding_surface_check_points(self) -> VectorComponents3: + pts = self._bounding_surface_check_points() + return pts[:, 0], pts[:, 1], pts[:, 2] + +class AngularDistribution(ABC): + """ Base class for angular distributions """ + + @property + @abstractmethod + def n_points(self) -> int: + """ Number of sample points """ + + @staticmethod + @abstractmethod + def name() -> str: + """ Name of this distribution """ + + + @staticmethod + @abstractmethod + def parameters() -> List[Tuple[str, str, type]]: + """ List of keyword arguments to constructor, names for GUI, and the type of value""" + + @abstractmethod + def sample_points_and_weights(self) -> Tuple[np.ndarray, np.ndarray]: + """ Get sample q vector directions and associated weights""" + + +@dataclass +class SLDDefinition: + """ Definition of the SLD scalar field""" + sld_function: SLDFunction + to_cartesian_conversion: CoordinateSystemTransform + + +@dataclass +class MagnetismDefinition: + """ Definition of the magnetism vector fields""" + magnetism_function: MagnetismFunction + to_cartesian_conversion: CoordinateSystemTransform + + +@dataclass +class ParticleDefinition: + """ Object containing the functions that define a particle""" + sld: SLDDefinition + magnetism: Optional[MagnetismDefinition] + + +@dataclass +class ScatteringCalculation: + """ Specification for a scattering calculation """ + q_sampling: QSample + angular_sampling: AngularDistribution + spatial_sampling_method: SpatialDistribution + particle_definition: ParticleDefinition + parameter_settings: CalculationParameters + polarisation_vector: Optional[np.ndarray] + seed: Optional[int] + bounding_surface_sld_check: bool + bin_count = 1_000 + sample_chunk_size_hint: int = 100_000 + + +@dataclass +class SamplingDistribution: + name: str + bin_edges: np.ndarray + counts: np.ndarray + +@dataclass +class QSpaceScattering: + abscissa: QSample + ordinate: np.ndarray + +@dataclass +class RealSpaceScattering: + abscissa: np.ndarray + ordinate: np.ndarray + + +@dataclass +class ScatteringOutput: + q_space: Optional[QSpaceScattering] + calculation_time: float + seed_used: Optional[int] + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py new file mode 100644 index 0000000000..b926e8b357 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/parameters.py @@ -0,0 +1,94 @@ +from enum import Enum +from typing import NamedTuple + +class ValueSource(Enum): + """ Item that decribes where the current parameter came from""" + DEFAULT = 0 + CODE = 1 + MANUAL = 2 + FIT = 3 + +class Parameter: + """ Base class for parameter descriptions """ + is_function_parameter = False + + def __init__(self, name: str, value: float): + self.name = name + self.value = value + + @property + def in_use(self): + return True + + @in_use.setter + def in_use(self, value): + raise Exception("in_use is fixed for this parameter, you should not be trying to set it") + + def __repr__(self): + in_use_string = "used" if self.in_use else "not used" + return f"FunctionParameter({self.name}, {self.value}, {in_use_string})" + +class FunctionParameter(Parameter): + is_function_parameter = True + + def __init__(self, name: str, value: float, in_use: bool, set_by: ValueSource): + """ Representation of an input parameter to the sld + + The set_by variable describes what the last thing to set it was, + this allows it to be updated in a sensible way - it should + only have its value changed by the code if it was set by the code, + or if it has been set to a default value because nothing specified it. + """ + super().__init__(name, value) + self._in_use = in_use + self.set_by = set_by + @property + def in_use(self): + return self._in_use + + @in_use.setter + def in_use(self, value): + self._in_use = value + + def __repr__(self): + in_use_string = "used" if self.in_use else "not used" + return f"FunctionParameter({self.name}, {self.value}, {in_use_string}, {self.set_by})" + +# +# Parameters that exist for all calculations +# + + +class SolventSLD(Parameter): + """ Parameter representing the solvent SLD, always present""" + def __init__(self): + super().__init__("Solvent SLD", 0.0) + + +class Background(Parameter): + """ Parameter representing the background intensity, always present""" + def __init__(self): + super().__init__("Background Intensity", 0.0001) + + +class Scale(Parameter): + """ Parameter representing the scaling, always present""" + def __init__(self): + super().__init__("Intensity Scaling", 1.0) + + +class MagnetismParameterContainer(NamedTuple): + """ Entry describing a magnetism parameter, keeps track of whether it should be linked to an SLD parameter""" + parameter: FunctionParameter + linked: bool + + +class CalculationParameters(NamedTuple): + """ Object containing the parameters for a simulation""" + solvent_sld: float + background: float + scale: float + sld_parameters: dict + magnetism_parameters: dict + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py new file mode 100644 index 0000000000..f01a846f56 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/datamodel/types.py @@ -0,0 +1,22 @@ +from typing import Tuple, Protocol +import numpy as np + +# 3D vector output as numpy arrays +VectorComponents3 = Tuple[np.ndarray, np.ndarray, np.ndarray] + + +class SLDFunction(Protocol): + """ Type of functions that can represent SLD profiles""" + def __call__(self, a: np.ndarray, b: np.ndarray, c: np.ndarray, **kwargs: float) -> np.ndarray: ... + + +class MagnetismFunction(Protocol): + """ Type of functions that can represent magnetism profiles""" + + def __call__(self, a: np.ndarray, b: np.ndarray, c: np.ndarray, **kwargs: float) -> VectorComponents3: ... + + +class CoordinateSystemTransform(Protocol): + """ Type of functions that can represent a coordinate transform""" + + def __call__(self, x: np.ndarray, y: np.ndarray, z: np.ndarray) -> VectorComponents3: ... \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py b/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py new file mode 100644 index 0000000000..e59cdb3384 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/debug/check_point_samplers.py @@ -0,0 +1,20 @@ +import matplotlib.pyplot as plt + +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import Grid + + + +fig = plt.figure("Grid plot") +ax = fig.add_subplot(projection='3d') + +gen = Grid(100, 250) + +n_total = gen.n_points + +x,y,z = gen.generate(0, n_total//3) +ax.scatter(x,y,z,color='b') + +x,y,z = gen.generate(n_total//3, n_total) +ax.scatter(x,y,z,color='r') + +plt.show() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py new file mode 100644 index 0000000000..f359a2e7dc --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/defaults.py @@ -0,0 +1,58 @@ +import numpy as np + + +def sld(r, theta, phi): + inside = r + 20*np.sin(6*theta)*np.cos(3*phi) < 50 + out = np.zeros_like(r) + out[inside] = 1.0 + return out + + +default_text = '''""" + +Here's a new perspective. It calculates the scattering based on real-space description of a particle. + +Basically, define your SLD as a function of either cartesian or polar coordinates and click scatter + + def sld(x,y,z) + def sld(r,theta,phi) + +The display on the right shows your particle, both as a total projected density (top) and as a slice (bottom). + +This is a minimal working system. Currently magnetism doesn't work, neither do extra parameters for your functions, +nor structure factors, nor fitting, nor 2D plots. + +Here's a simple example: """ + +def sld_cube(x,y,z): + """ A cube with 100Ang side length""" + + return rect(0.02*x)*rect(0.02*y)*rect(0.02*z) + + +def sld_sphere(r, theta, phi): + """ Sphere r=50Ang""" + + return rect(0.02*r) + +sld = sld_sphere + +''' + +""" Press shift-return to build and update views + Click scatter to show the scattering profile""" +# +# # TODO: REMOVE +# default_text = """ +# +# def sld(r,theta,phi): +# return rect(r/50) +# """ + +# +# # TODO: REMOVE +# default_text = """ +# +# def sld(r,theta,phi): +# return rect(r/50) - 0.5*rect(r/40) +# """ diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py new file mode 100644 index 0000000000..d02ccc0b2c --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/function_processor.py @@ -0,0 +1,189 @@ +from typing import Callable + +from io import StringIO + +import inspect +from contextlib import redirect_stdout +import traceback + +import numpy as np + +from sas.qtgui.Perspectives.ParticleEditor.helper_functions import rect, step + +class FunctionDefinitionFailed(Exception): + def __init__(self, *args): + super() + +# Valid parameterisataions of the sld function, +# because of how things are checked, the names of +# the fist parameter currently has to be distinct. +# things could be changed to make it otherwise, but +# at the moment this seems reasonble + +parameter_sets = [["x", "y", "z"], ["r", "theta", "phi"]] + + +def cartesian_converter(x,y,z): + """ Converter from calculation coordinates to function definition + + For functions specified in cartesian coordinates + """ + return x,y,z + + +def spherical_converter(x,y,z): + """ Converter from calculation coordinates to function definition + + For functions specified in spherical coordinates + """ + r = np.sqrt(x**2 + y**2 + z**2) + theta = np.arccos(z/r) + phi = np.sign(y)*np.arccos(x / np.sqrt(x**2 + y**2)) + + return r, theta, phi + +parameter_converters = [cartesian_converter, spherical_converter] + +def default_callback(string: str): + """ Just for default""" + print(string) + + + +# +# Main processor +# + +def process_code(input_text: str, + solvent_sld: float = 0.0, + text_callback: Callable[[str], None] = default_callback, + warning_callback: Callable[[str], None] = default_callback, + error_callback: Callable[[str], None] = default_callback): + + """ Process the code for generating functions that specify sld/magnetism + + """ + + new_locals = {} + new_globals = {"np": np, "solvent_sld": solvent_sld, "step": step, "rect": rect} + + stdout_output = StringIO() + with redirect_stdout(stdout_output): + try: + exec(input_text, new_globals, new_locals) # TODO: provide access to solvent SLD somehow + + text_callback(stdout_output.getvalue()) + + except Exception: + + text_callback(stdout_output.getvalue()) + error_callback(traceback.format_exc()) + + return None, None, None, None + # print(ev) + # print(new_globals) + # print(new_locals) + + # look for function + if "sld" not in new_locals: + raise FunctionDefinitionFailed("No function called 'sld' found") + + sld_function = new_locals["sld"] + + if not isinstance(sld_function, Callable): + raise FunctionDefinitionFailed("sld object exists, but is not Callable") + + # Check for acceptable signatures + sig = inspect.signature(sld_function) + + params = list(sig.parameters.items()) + + # check for first parameter being equal to one of the acceptable options + # this assumes that there are only + parameter_set_index = -1 + for index, parameter_set in enumerate(parameter_sets): + if params[0][0] == parameter_set[0]: + parameter_set_index = index + + if parameter_set_index < 0: + s = " or ".join([("("+", ".join(v) + ")") for v in parameter_sets]) + raise FunctionDefinitionFailed("sld function not correctly parameterised, first three parameters must be "+s) + + parameter_set = parameter_sets[parameter_set_index] + + for i in range(3): + if params[i][0] != parameter_set[i]: + s = ", ".join(parameter_set) + raise FunctionDefinitionFailed(f"Parameter {i+1} should be {parameter_set[i]} for ({s}) parameterisation") + + converter = parameter_converters[parameter_set_index] + + # + # gather parameters + # + + remaining_parameter_names = [x[0] for x in params[3:]] + + return sld_function, converter, remaining_parameter_names, params[3:] + +def main(): + + test_text_valid_sld_xyz = """ + + print("test print string") + + def sld(x,y,z,p1,p2,p3): + print(x,y,z) + + """ + + test_text_valid_sld_radial = """ + def sld(r,theta,phi,p1,p2,p3): + print(x,y,z) + + """ + + + test_text_invalid_start_sld = """ + def sld(theta,phi,p1,p2,p3): + print(x,y,z) + + """ + + test_text_invalid_rest_sld = """ + def sld(r,p1,p2,p3): + print(x,y,z) + + """ + + test_sld_class = """ + + class SLD: + def __call__(self,x,y,z): + print(x,y,z) + + sld = SLD() + + """ + + test_bad_class = """ + + class SLD: + def __call__(self,x,y,q): + print(x,y,z) + + sld = SLD() + + """ + + + x = process_code(test_text_valid_sld_xyz) + # x = process_text(test_text_valid_sld_radial) + # x = process_text(test_text_invalid_start_sld) + # x = process_text(test_text_invalid_rest_sld) + # x = process_text(test_bad_class) + + print(x) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py b/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py new file mode 100644 index 0000000000..d32689edc6 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/helper_functions.py @@ -0,0 +1,24 @@ +""" + +Functions that get automatically included in the build window + +""" + + +import numpy as np + + +def step(x: np.ndarray): + """ Step function, 0 if input < 0, 1 if input >= 0""" + out = np.ones_like(x) + out[x < 0] = 0.0 + return out + + +def rect(x: np.ndarray): + """ Rectangle function, zero if |input| > 1, 1 otherwise""" + out = np.zeros_like(x) + out[np.abs(x) <= 1] = 1.0 + return out + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/__init__.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py new file mode 100644 index 0000000000..6d69de8cbf --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/angles.py @@ -0,0 +1,69 @@ + +""" + +Different methods for sampling the angular distribution of q vectors + +A list of the different methods available can be found at the bottom of the code and needs to be updated if new ones +are added. + + +""" + + +from typing import List, Tuple +import numpy as np + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import AngularDistribution +from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic, GeodesicDivisions + + +class ZDelta(AngularDistribution): + """ Perfectly oriented sample """ + + @staticmethod + def name(): + return "Oriented" + + def sample_points_and_weights(self): + return np.array([[0.0, 0.0, -1.0]]), np.array([1.0]) + + @property + def n_points(self): + return 1 + + @staticmethod + def parameters(): + return [] + + def __repr__(self): + return f"ZDelta()" + + +class Uniform(AngularDistribution): + """ Spherically averaged sample """ + + def __init__(self, geodesic_divisions: int): + self.divisions = geodesic_divisions + self._n_points = Geodesic.points_for_division_amount(geodesic_divisions) + + @staticmethod + def name(): + return "Unoriented" + + def sample_points_and_weights(self) -> Tuple[np.ndarray, np.ndarray]: + return Geodesic.by_divisions(self.divisions) + + @property + def n_points(self) -> int: + return self._n_points + + @staticmethod + def parameters() -> List[Tuple[str, str, type]]: + return [("geodesic_divisions", "Angular Samples", GeodesicDivisions)] + + def __repr__(self): + return f"Uniform({self.divisions})" + + +angular_sampling_methods = [ZDelta, Uniform] + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py new file mode 100644 index 0000000000..9bc4adec0f --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/chunking.py @@ -0,0 +1,126 @@ + + +""" + +Functions designed to help avoid using too much memory, chunks up the pairwise distributions + +Something like this + + + + First Point + + 1 2 3 | 4 5 6 | 7 8 9 | 0 + 1 | | | +S | | | +e 2 CHUNK 1 | CHUNK 2 | CHUNK 3 | CHUNK 4 +c | | | +o 3 | | | +n ---------------+-----------+-----------+------------ +d 4 | | | + | | | +P 5 CHUNK 5 | CHUNK 6 | CHUNK 7 | CHUNK 8 +o | | | +i 6 | | | +n ---------------+-----------+-----------+------------ +t 7 | | | + | | | + 8 CHUNK 9 | CHUNK 10 | CHUNK 11 | CHUNK 12 + | | | + 9 | | | + ---------------+-----------+-----------+------------ + 0 | | | + CHUNK 13 | CHUNK 14 | CHUNK 15 | CHUNK 16 + | | | +""" + + +from typing import Tuple, Sequence, Any + +import math + +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import SpatialDistribution +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 + +from abc import ABC, abstractmethod + + +class Chunker(ABC): + def __init__(self, point_generator: SpatialDistribution): + self.point_generator = point_generator + + def __iter__(self): + return self._iterator() + + @abstractmethod + def _iterator(self) -> Tuple[VectorComponents3, VectorComponents3]: + """ Python generator function that yields chunks """ + + +class Chunks(Chunker): + """ Class that takes a point generator, and produces all pairwise combinations in chunks + + This trades off speed for space. + """ + + # TODO + + +class SingleChunk(Chunker): + """ Chunker that doesn't chunk """ + + def _iterator(self): + points = self.point_generator.generate(0, self.point_generator.n_points) + yield points, points + + +class InputLengthMismatch(Exception): + pass + +def sublist_lengths(data: Sequence[Sequence[Any]]) -> int: + """ Return the length of the """ + iterator = iter(data) + len_0 = len(next(iterator)) + if not all(len(x) == len_0 for x in iterator): + raise InputLengthMismatch("Input lists do not have the same length") + + return len_0 + +def pairwise_chunk_iterator( + left_data: Sequence[Sequence[Any]], + right_data: Sequence[Sequence[Any]], + chunk_size: int) -> Tuple[Sequence[Sequence[Any]], Sequence[Sequence[Any]]]: + + """ Generator to do chunking as described in the module docs above""" + + left_len = sublist_lengths(left_data) + right_len = sublist_lengths(right_data) + + # Ceiling using divmod (because ceil on normal division isn't robust) + n_left, rem = divmod(left_len, chunk_size) + if rem > 0: + n_left += 1 + + n_right, rem = divmod(right_len, chunk_size) + if rem > 0: + n_right += 1 + + + # Iterate through pairs of points + for i in range(n_left): + + left_index_1 = i*chunk_size + left_index_2 = min(((i+1)*chunk_size, left_len)) + + left_chunk = tuple(datum[left_index_1:left_index_2] for datum in left_data) + + for j in range(n_right): + + right_index_1 = j*chunk_size + right_index_2 = min(((j+1)*chunk_size, right_len)) + + right_chunk = tuple(datum[right_index_1:right_index_2] for datum in right_data) + + # print([i, left_index_1, left_index_2], [j, right_index_1, right_index_2]) + + yield left_chunk, right_chunk diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/geodesic.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/geodesic.py new file mode 100644 index 0000000000..56c00999c6 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/geodesic.py @@ -0,0 +1,316 @@ +from typing import Tuple +from collections import defaultdict + +import numpy as np +from scipy.spatial import ConvexHull + +_ico_ring_h = np.sqrt(1 / 5) +_ico_ring_r = np.sqrt(4 / 5) + +class Geodesic(): + """ Points arranged pretty uniformly and regularly on a sphere""" + + # + # Need to define an icosahedron, upon which everything else is based + # + + _base_vertices = \ + [(0.0, 0.0, 1.0)] + \ + [(_ico_ring_r * np.cos(angle), _ico_ring_r * np.sin(angle), _ico_ring_h) for angle in + 2 * np.pi * np.arange(5) / 5] + \ + [(_ico_ring_r * np.cos(angle), _ico_ring_r * np.sin(angle), -_ico_ring_h) for angle in + 2 * np.pi * (np.arange(5) + 0.5) / 5] + \ + [(0.0, 0.0, -1.0)] + + _base_edges = [ + (0, 1), # Top converging + (0, 2), + (0, 3), + (0, 4), + (0, 5), + (1, 2), # Top radial + (2, 3), + (3, 4), + (4, 5), + (5, 1), # Middle diagonals, advanced + (1, 6), + (2, 7), + (3, 8), + (4, 9), + (5, 10), + (1, 10), # Middle diagonals, delayed + (2, 6), + (3, 7), + (4, 8), + (5, 9), + (6, 7), # Bottom radial + (7, 8), + (8, 9), + (9, 10), + (10, 6), + (6, 11), # Bottom converging + (7, 11), + (8, 11), + (9, 11), + (10, 11), + ] + + _base_triangles = [ + (0, 1, 2), # Top cap + (0, 2, 3), + (0, 3, 4), + (0, 4, 5), + (0, 5, 1), + (2, 1, 6), # Top middle ring + (3, 2, 7), + (4, 3, 8), + (5, 4, 9), + (1, 5, 10), + (6, 10, 1), # Bottom middle ring + (7, 6, 2), + (8, 7, 3), + (9, 8, 4), + (10, 9, 5), + (6, 7, 11), # Bottom cap + (7, 8, 11), + (8, 9, 11), + (9, 10, 11), + (10, 6, 11) + ] + + _cache = {} + + + @staticmethod + def points_for_division_amount(n_divisions): + """ Get the number of points on the sphere for a given number of geodesic divisions + by which I mean how many sections is each edge of the initial icosahedron split into + (not how many times it is divided in half, which might be what you think given some other + geodesic generation methods) + + Icosahedron counts as 1 division + + """ + + return 10*n_divisions*n_divisions + 2 + + + + @staticmethod + def minimal_divisions_for_points(n_points): + """ What division number should I use if I want at least n_points points on my geodesic + + rounded (ciel) inverse of points_for_division_amount + """ + + n_ish = np.sqrt((n_points - 2)/10) + + return int(max([1.0, np.ceil(n_ish)])) + + @staticmethod + def by_point_count(self, n_points) -> Tuple[np.ndarray, np.ndarray]: + """ Get point sample on a unit geodesic sphere, *at least* n_points will be returned + + Weights of each point are calculated by fractional spherical area of dual polyhedron, and total weight = 4pi + """ + + return self.by_divisions(self.minimal_divisions_for_points(n_points)) + + @staticmethod + def by_divisions(n_divisions) -> Tuple[np.ndarray, np.ndarray]: + + """ Get point sample on a unit geodesic sphere, points are creating by dividing each + face of an icosahedron into smaller triangles so that each edge is split into n_divisions pieces + + Weights of each point are calculated by fractional spherical area of dual polyhedron, and total weight = 4pi + """ + + # Check cache for pre-existing data + if n_divisions in Geodesic._cache: + return Geodesic._cache[n_divisions] + + + # + # Main bit: We have to treat faces, edges and vertices individually to avoid duplicate points + # + + # Original vertices will become new vertices + points = [np.array(p) for p in Geodesic._base_vertices] + + # Iterate over edges + for i, j in Geodesic._base_edges: + p1 = np.array(Geodesic._base_vertices[i]) + p2 = np.array(Geodesic._base_vertices[j]) + + delta = (p2 - p1) / n_divisions + + for a in range(1, n_divisions): + new_point = p1 + a*delta + new_point /= np.sqrt(np.sum(new_point ** 2)) + + points.append(new_point) + + + # Iterate over faces need to, and add divsions + for i, j, k in Geodesic._base_triangles: + p1 = np.array(Geodesic._base_vertices[i]) + p2 = np.array(Geodesic._base_vertices[j]) + p3 = np.array(Geodesic._base_vertices[k]) + + d1 = p1 - p3 + d2 = p2 - p3 + + # Division of the shape, just fill in the inside + for a_plus_b in range(1, n_divisions): + for a in range(1, a_plus_b): + + b = a_plus_b - a + + new_point = p3 + d1*(a / n_divisions) + d2*(b / n_divisions) + new_point /= np.sqrt(np.sum(new_point**2)) + + points.append(new_point) + + points = np.array(points) + + weights = Geodesic._calculate_weights(points) + + + # Cache the data + Geodesic._cache[n_divisions] = (points, weights) + + return points, weights + + @staticmethod + def _calculate_weights(points) -> np.ndarray: + """ + Calculate the anular area associated with each point + """ + + z = np.array([0.0, 0.0, 1.0]) # Used in area calculation + + # Calculate convex hull to get the triangles, because it's easy + hull = ConvexHull(points) + + centroids = [] + + # Dictionary to track which centroids are associated with each point + point_to_centroid = defaultdict(list) + + # calculate centroid, lets not check for correct hull data, as nothing is user + # facing, and has a reliably triangular and convex input + for centroid_index, simplex in enumerate(hull.simplices): + p1 = points[simplex[0], :] + p2 = points[simplex[1], :] + p3 = points[simplex[2], :] + + centroid = p1 + p2 + p3 + centroid /= np.sqrt(np.sum(centroid**2)) + + centroids.append(centroid) + + point_to_centroid[simplex[0]].append(centroid_index) + point_to_centroid[simplex[1]].append(centroid_index) + point_to_centroid[simplex[2]].append(centroid_index) + + # Find the area of the spherical polygon associated with each point + # 1) get the associated centroids + # 2) order them to allow the identification of triangles + # 3) for each triangle [point, centroid_i, centroid_i+1], calculate the area + + areas = [] + for point_index, point in enumerate(points): + + # 1) Get centroids + centroid_indices = point_to_centroid[point_index] + centroid_points = [centroids[i] for i in centroid_indices] + + n_centroids = len(centroid_indices) + + + # 2) Order them around the point + rotation = Geodesic._rotation_matrix_to_z_vector(point) + + centroid_points = [np.matmul(rotation, centroid) for centroid in centroid_points] + + centroid_points = sorted(centroid_points, key=lambda x: np.arctan2(x[0], x[1])) # Any angle in x,y will do + + + # 3) Calculate areas + area = 0 + for i in range(n_centroids): + p1 = centroid_points[i] + p2 = centroid_points[(i+1)%n_centroids] + + area += 2 * np.abs( + np.arctan2( + np.cross(p1, p2)[2], + 1 + np.dot(p1, p2) + p1[2] + p2[2])) + + areas.append(area) + + return np.array(areas) + + + @staticmethod + def _rotation_matrix_to_z_vector(point_on_sphere: np.ndarray) -> np.ndarray: + """ Calculate *a* rotation matrix that moves a given point onto the z axis""" + + # Find the rotation around the x axis + y = point_on_sphere[1] + z = point_on_sphere[2] + + x_angle = np.arctan2(y, z) + + c = np.cos(x_angle) + s = np.sin(x_angle) + + m1 = np.array([ + [1, 0, 0], + [0, c, -s], + [0, s, c]]) + + # apply rotation to point + new_point = np.matmul(m1, point_on_sphere) + + # find the rotation about the y axis for this new point + x = new_point[0] + z = new_point[2] + + y_angle = np.arctan2(x, z) + + c = np.cos(y_angle) + s = np.sin(y_angle) + + m2 = np.array([ + [c, 0, -s], + [0, 1, 0], + [s, 0, c]]) + + return np.matmul(m2, m1) + + +class GeodesicDivisions: + """ Use this so that the GUI program gives geodesic divisions box, rather than a plain int one""" + + +if __name__ == "__main__": + + """ Shows a point sample""" + + import matplotlib.pyplot as plt + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + points, weights = Geodesic.by_divisions(4) + ax.scatter(points[:,0], points[:,1], points[:,2]) + + ax.set_xlim3d([-1.1, 1.1]) + ax.set_ylim3d([-1.1, 1.1]) + ax.set_zlim3d([-1.1, 1.1]) + + ax.set_box_aspect([1,1,1]) + ax.set_proj_type('ortho') + + plt.show() \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py new file mode 100644 index 0000000000..b07f41eb35 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/sampling/points.py @@ -0,0 +1,110 @@ +""" + +Instances of the spatial sampler + +""" + +import math +import numpy as np + +from collections import defaultdict + + +from sas.qtgui.Perspectives.ParticleEditor.datamodel.types import VectorComponents3 +from sas.qtgui.Perspectives.ParticleEditor.datamodel.calculation import SpatialDistribution + +class BoundedByCube(SpatialDistribution): + + _boundary_base_points = np.array([ + [-1, 0, 0], + [ 1, 0, 0], + [ 0,-1, 0], + [ 0, 1, 0], + [ 0, 0,-1], + [ 0, 0, 1], + [ 1, 1, 1], + [ 1, 1,-1], + [ 1,-1, 1], + [ 1,-1,-1], + [-1, 1, 1], + [-1, 1,-1], + [-1,-1, 1], + [-1,-1,-1], + ], dtype=float) + def _bounding_surface_check_points(self) -> VectorComponents3: + return BoundedByCube._boundary_base_points * self.radius + + +class Grid(BoundedByCube): + """ Generate points on a grid within a cube with side length 2*radius """ + def __init__(self, radius: float, desired_points: int): + self.desired_points = desired_points + self.n_points_per_axis = math.ceil(desired_points**(1/3)) + + super().__init__(radius, n_points=self.n_points_per_axis**3, n_desired_points=desired_points) + + @property + def info(self): + return f"{self.n_points_per_axis}x{self.n_points_per_axis}x{self.n_points_per_axis} = {self.n_points}" + + def generate(self, start_index: int, end_index: int) -> VectorComponents3: + point_indices = np.arange(start_index, end_index) + + z_inds = point_indices % self.n_points_per_axis + y_inds = (point_indices // self.n_points_per_axis) % self.n_points_per_axis + x_inds = (point_indices // (self.n_points_per_axis**2)) % self.n_points_per_axis + + return ( + (((x_inds + 0.5) / self.n_points_per_axis) - 0.5) * self.radius, + (((y_inds + 0.5) / self.n_points_per_axis) - 0.5) * self.radius, + (((z_inds + 0.5) / self.n_points_per_axis) - 0.5) * self.radius) + + + +class RandomCube(BoundedByCube): + """ Generate random points in a cube with side length 2*radius""" + def __init__(self, radius: float, desired_points: int, seed=None): + + super().__init__(radius, n_points=desired_points, n_desired_points=desired_points) + + self._seed_rng = np.random.default_rng(seed) + + # Accessing this will generate random seeds if they don't exist and store them to be accessed if they do + self.seeds = defaultdict(lambda: self._seed_rng.integers(0, 0x7fff_ffff_ffff_ffff)) + + def generate(self, start_index: int, end_index: int) -> VectorComponents3: + # This method of tracking seeds only works if the same start_indices are used each time points + # in a region are requested - i.e. if they're chunks form a grid + + n_points = end_index - start_index + seed = self.seeds[start_index] + + rng = np.random.default_rng(seed=seed) + + xyz = rng.random(size=(n_points, 3)) + + xyz -= 0.5 + xyz *= 2*self.radius + + return xyz[:, 0], xyz[:, 1], xyz[:, 2] + + +class PointGeneratorStepper: + """ Generate batches of step_size points from a PointGenerator instance""" + + def __init__(self, point_generator: SpatialDistribution, step_size: int): + self.point_generator = point_generator + self.step_size = step_size + + def _iterator(self): + n_sections, remainder = divmod(self.point_generator.n_points, self.step_size) + + for i in range(n_sections): + yield self.point_generator.generate(i*self.step_size, (i+1)*self.step_size) + + if remainder != 0: + yield self.point_generator.generate(n_sections*self.step_size, self.point_generator.n_points) + + def __iter__(self): + return self._iterator() + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py new file mode 100644 index 0000000000..84e15f3b46 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/syntax_highlight.py @@ -0,0 +1,218 @@ +""" + + +Modified from: art1415926535/PyQt5-syntax-highlighting on github + +It's not great, should all really be implemented as a finite state machine with a stack + +""" + +import sys +from PySide6.QtCore import QRegularExpression, QRegularExpressionMatchIterator +from PySide6.QtGui import QColor, QTextCharFormat, QFont, QSyntaxHighlighter + + +def format(color, style=''): + """ + Return a QTextCharFormat with the given attributes. + """ + _color = QColor() + if type(color) is not str: + _color.setRgb(color[0], color[1], color[2]) + else: + _color.setNamedColor(color) + + _format = QTextCharFormat() + _format.setForeground(_color) + if 'bold' in style: + _format.setFontWeight(QFont.Bold) + if 'italic' in style: + _format.setFontItalic(True) + + return _format + + +# Syntax styles that can be shared by all languages + +STYLES = { + 'keyword': format([200, 120, 50], 'bold'), + 'operator': format([150, 150, 150]), + 'brace': format('darkGray'), + 'defclass': format([220, 220, 255], 'bold'), + 'string': format([20, 110, 100]), + 'string2': format([30, 120, 110]), + 'comment': format([128, 128, 128]), + 'self': format([150, 85, 140], 'italic'), + 'numbers': format([100, 150, 190]), + 'special': format([90, 80, 200], 'bold') +} + + +class PythonHighlighter(QSyntaxHighlighter): + """Syntax highlighter for the Python language. + """ + # Python keywords + + + keywords = [ + 'and', 'assert', 'break', 'class', 'continue', 'def', + 'del', 'elif', 'else', 'except', 'exec', 'finally', + 'for', 'from', 'global', 'if', 'import', 'in', + 'is', 'lambda', 'not', 'or', 'pass', 'print', + 'raise', 'return', 'try', 'while', 'yield', + 'None', 'True', 'False', + ] + + special = [ + 'sld', 'solvent_sld', 'magnetism' + ] + + # Python operators + operators = [ + '=', + # Comparison + '==', '!=', '<', '<=', '>', '>=', + # Arithmetic + '\+', '-', '\*', '/', '//', '\%', '\*\*', + # In-place + '\+=', '-=', '\*=', '/=', '\%=', + # Bitwise + '\^', '\|', '\&', '\~', '>>', '<<', + ] + + # Python braces + braces = [ + '\{', '\}', '\(', '\)', '\[', '\]', + ] + + def __init__(self, document): + QSyntaxHighlighter.__init__(self, document) + + # Multi-line strings (expression, flag, style) + # FIXME: The triple-quotes in these two lines will mess up the + # syntax highlighting from this point onward + self.tri_single = (QRegularExpression("'''"), 1, STYLES['string2']) + self.tri_double = (QRegularExpression('"""'), 2, STYLES['string2']) + + rules = [] + + # Keyword, operator, and brace rules + rules += [(r'\b%s\b' % w, 0, STYLES['keyword']) + for w in PythonHighlighter.keywords] + rules += [(r'%s' % o, 0, STYLES['operator']) + for o in PythonHighlighter.operators] + rules += [(r'%s' % b, 0, STYLES['brace']) + for b in PythonHighlighter.braces] + + # All other rules + rules += [ + # 'self' + (r'\bself\b', 0, STYLES['self']), + + # Double-quoted string, possibly containing escape sequences + (r'[rf]?"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']), + # Single-quoted string, possibly containing escape sequences + (r"[rf]?'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), + + # From '#' until a newline + (r'#[^\n]*', 0, STYLES['comment']), + + # Numeric literals + (r'\b[+-]?[0-9]+[lL]?\b', 0, STYLES['numbers']), + (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, STYLES['numbers']), + (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, STYLES['numbers']), + ] + + rules += [(r'\b%s\b' % w, 0, STYLES['special']) + for w in PythonHighlighter.special] + + # Build a QRegExp for each pattern + self.rules = [(QRegularExpression(pat), index, fmt) + for (pat, index, fmt) in rules] + + + def highlightBlock(self, text): + """Apply syntax highlighting to the given block of text. + """ + + # Go through each of the rules and each of the matches setting the highlighting + for expression, nth, format in self.rules: + matchIterator = QRegularExpressionMatchIterator(expression.globalMatch(text)) + while matchIterator.hasNext(): + match = matchIterator.next() + index = match.capturedStart() + length = match.capturedLength() + self.setFormat(index, length, format) + + # def and class + # + # # 'def' followed by an identifier + # (r'\bdef\b\s*(\w+)', 1, STYLES['defclass']), + # # 'class' followed by an identifier + # (r'\bclass\b\s*(\w+)', 1, STYLES['defclass']), + + # Multiblock comments + + # Find all the tripple quotes + + doubleMatchIterator = QRegularExpressionMatchIterator(self.tri_double[0].globalMatch(text)) + singleMatchIterator = QRegularExpressionMatchIterator(self.tri_single[0].globalMatch(text)) + + doubleMatches = [] + while doubleMatchIterator.hasNext(): + doubleMatches.append(( + self.tri_double[1], + doubleMatchIterator.next(), + self.tri_double[2])) + + singleMatches = [] + while singleMatchIterator.hasNext(): + singleMatches.append(( + self.tri_single[1], + singleMatchIterator.next(), + self.tri_single[2])) + + allMatches = sorted( + singleMatches + doubleMatches, + key=lambda x: x[1].capturedStart()) + + # Step through the matches + + state = self.previousBlockState() + start_index = 0 + + for in_state, match, style in allMatches: + + if state == in_state: + # Comment end + + state = -1 + index = match.capturedStart() + length = match.capturedLength() + end_index = index + length + + self.setFormat(start_index, end_index, style) + + start_index = end_index + + elif state == -1: + # Comment start + + state = in_state + start_index = match.capturedStart() + + + # format rest of block + end_index = len(text) + if state != -1: + if len(allMatches) > 0: + self.setFormat(start_index, end_index, allMatches[-1][2]) + else: + if state == self.tri_single[1]: + self.setFormat(0, end_index, self.tri_single[2]) + elif state == self.tri_double[1]: + self.setFormat(0, end_index, self.tri_double[2]) + + # set block state + self.setCurrentBlockState(state) + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py new file mode 100644 index 0000000000..ae56ce5f80 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_angles.py @@ -0,0 +1,54 @@ +import pytest +from pytest import mark +import numpy as np + +from sas.qtgui.Perspectives.ParticleEditor.sampling.geodesic import Geodesic + +@mark.parametrize("n_divisions", [1,2,3,4,5]) +def test_point_number_prediction(n_divisions): + """ Geodesic point number function should give the number of points the generating function returns""" + geodesic_points, _ = Geodesic.by_divisions(n_divisions) + + assert geodesic_points.shape[0] == Geodesic.points_for_division_amount(n_divisions) + + +@mark.parametrize("n_divisions", [1,2,3,4,5]) +def test_inverse_point_calc_exact(n_divisions): + """ Check the calculation of divisions for point number works, when it is an exact input""" + assert n_divisions == Geodesic.minimal_divisions_for_points(Geodesic.points_for_division_amount(n_divisions)) + + +@mark.parametrize("n_divisions", [1,2,3,4,5]) +def test_inverse_point_calc_minimally_greater(n_divisions): + """ Check the calculation of divisions rounds correctly (up) when it is one more than an exact input""" + assert n_divisions + 1 == Geodesic.minimal_divisions_for_points(Geodesic.points_for_division_amount(n_divisions) + 1) + + +@mark.parametrize("n_divisions", [1,2,3,4,5]) +def test_inverse_point_calc_maximally_greater(n_divisions): + """ Check the calculation of divisions rounds correctly (up) when it is one less than the next exact input""" + assert n_divisions + 1 == Geodesic.minimal_divisions_for_points(Geodesic.points_for_division_amount(n_divisions + 1) - 1) + + +@mark.parametrize("vector", [ + [ 1, 2, 3], + [ 7, 2, 0], + [-1, 0, 0], + [ 0, 1, 0], + [ 0, 0, 1]]) +def test_rotation_to_z(vector): + """ Check the method that rotates vectors to the z axis""" + m = Geodesic._rotation_matrix_to_z_vector(np.array(vector)) + + v_test = np.matmul(m, vector) + + assert np.abs(v_test[0]) < 1e-9 + assert np.abs(v_test[1]) < 1e-9 + + +@mark.parametrize("n_divisions", [1,2,3,4,5,6]) +def test_total_weights(n_divisions): + """ Total weights should add to 4pi""" + points, weights = Geodesic.by_divisions(n_divisions) + + assert abs(sum(weights) - 4*np.pi) < 1e-9 diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py new file mode 100644 index 0000000000..61c17d40e1 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/tests/test_point_generator.py @@ -0,0 +1,32 @@ + +from pytest import mark + +from sas.qtgui.Perspectives.ParticleEditor.sampling.points import ( + Grid, + RandomCube, + PointGeneratorStepper) + + + +@mark.parametrize("splits", [42, 100, 381,999]) +@mark.parametrize("npoints", [100,1000,8001]) +def test_with_grid(npoints, splits): + point_generator = Grid(100, npoints) + n_measured = 0 + for x, y, z in PointGeneratorStepper(point_generator, splits): + n_measured += len(x) + + assert n_measured == point_generator.n_points # desired points may not be the same as n_points + +@mark.parametrize("splits", [42, 100, 381, 999]) +@mark.parametrize("npoints", [100, 1000, 8001]) +def test_with_grid(npoints, splits): + point_generator = RandomCube(100, npoints) + n_measured = 0 + for x, y, z in PointGeneratorStepper(point_generator, splits): + n_measured += len(x) + + assert n_measured == npoints + + + diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/util.py b/src/sas/qtgui/Perspectives/ParticleEditor/util.py new file mode 100644 index 0000000000..63af287358 --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/util.py @@ -0,0 +1,90 @@ +def format_time_estimate(est_time_seconds): + """ Get easily understandable string for a computational time estimate""" + # This is some silly code, but eh, why not + # + # I'm not even sure this is bad code, it's quite readable, + # easily modifiable, relatively fast, and it is optimised for + # smaller numbers, which is the bias we expect in the input + + est_time = est_time_seconds * 1_000_000 + time_units = "microsecond" + + if est_time < 1: + # anything shorter than a microsecond, return as a decimal + return "%.3g microseconds" % est_time + + # Everything else as an integer in nice units + if est_time > 1000: + est_time /= 1000 + time_units = "millisecond" + + if est_time > 1000: + est_time /= 1000 + time_units = "second" + + if est_time > 60: + est_time /= 60 + time_units = "minute" + + if est_time > 60: + est_time /= 60 + time_units = "hour" + + if est_time > 24: + est_time /= 24 + time_units = "day" + + if est_time > 7: + est_time /= 7 + time_units = "week" + + if est_time > 30 / 7: + est_time /= 30 / 7 + time_units = "month" + + if est_time > 365 / 30: + est_time /= 365 / 30 + time_units = "year" + + if est_time > 100: + est_time /= 100 + time_units = ("century", "centuries") + + if est_time > 10: + est_time /= 10 + time_units = ("millennium", "millennia") + + if est_time > 1000: + est_time /= 1000 + time_units = ("megaannum", "megaanna") + + if est_time > 230: + est_time /= 230 + time_units = "galactic year" + + if est_time > 13787 / 230: + est_time /= 13787 / 230 + time_units = "universe age" + + rounded = int(est_time) + if isinstance(time_units, str): + if rounded == 1: + unit = time_units + else: + unit = time_units + "s" + else: + if rounded == 1: + unit = time_units[0] + else: + unit = time_units[1] + + return f"{rounded} {unit}" + + +if __name__ == "__main__": + + # time format demo + t = 1e-9 + for i in range(100): + t *= 2 + print(format_time_estimate(t)) \ No newline at end of file diff --git a/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py new file mode 100644 index 0000000000..b24c56c3aa --- /dev/null +++ b/src/sas/qtgui/Perspectives/ParticleEditor/vectorise.py @@ -0,0 +1,96 @@ +import traceback +from typing import List, Union, Callable +import numpy as np + +test_n = 7 + +def clean_traceback(trace: str): + """ Tracebacks from vectorise contain potentially confusing information from the + vectorisation infrastructure. Clean it up and replace empty filename with something else""" + + + parts = trace.split('File ""')[1:] + + return "".join(["Input data" + part for part in parts]) + + +def vectorise_sld(fun: Callable, + warning_callback: Callable[[str], None], + error_callback: Callable[[str], None], + *args, **kwargs): + """ Check whether an SLD function can handle numpy arrays properly, + if not, create a wrapper that that can""" + + # Basically, the issue is with if statements, + # and we're looking for a ValueError with certain text + + input_values = np.zeros((test_n,)) + + try: + output = fun(input_values, input_values, input_values, *args, **kwargs) + + if not isinstance(output, np.ndarray): + return None + + elif output.shape != (test_n,): + return None + + else: + return fun + + except ValueError as ve: + # Check for a string that signifies a problem with parallelisation + if ve.args[0].startswith("The truth value of an array"): + # check it works with basic values + try: + fun(0, 0, 0, *args, **kwargs) + + + def vectorised(x,y,z,*args,**kwargs): + out = np.zeros_like(x) + for i, (xi,yi,zi) in enumerate(zip(x,y,z)): + out[i] = fun(xi, yi, zi, *args, **kwargs) + return out + + warning_callback("The specified SLD function does not handle vector values of coordinates, " + "a vectorised version has been created, but is probably **much** slower than " + "one that uses numpy (np.) functions. See the vectorisation example for " + "more details.") + + return vectorised + + except: + error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc())) + + else: + error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc())) + + except Exception: + error_callback("Function raises error when executed:\n"+clean_traceback(traceback.format_exc())) + +def vectorise_magnetism(fun: Callable, warning_callback: Callable[[str], None], *args, **kwargs): + """ Check whether a magnetism function can handle numpy arrays properly, + if not, create a wrapper that that can""" + pass + +def main(): + + def vector_sld(x, y, z): + return x+y+z + + def non_vector_sld(x,y,z): + if x > y: + return 1 + else: + return 0 + + def bad_sld(x,y,z): + return 7 + + print(vectorise_sld(vector_sld, print, print)) + print(vectorise_sld(non_vector_sld, print, print)) + print(vectorise_sld(bad_sld, print, print)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py index 2828b3a54c..9b942a8af2 100644 --- a/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/BoxSlicer.py @@ -19,6 +19,7 @@ class BoxInteractor(BaseInteractor, SlicerModel): function of Q_x and BoxInteractorY averages all the points from -x to +x as a function of Q_y """ + def __init__(self, base, axes, item=None, color='black', zorder=3): BaseInteractor.__init__(self, base, axes, color=color) SlicerModel.__init__(self) @@ -244,7 +245,7 @@ def setParams(self, params): self.horizontal_lines.update(x=self.x, y=self.y) self.vertical_lines.update(x=self.x, y=self.y) - self._post_data(nbins=None) + self._post_data() self.draw() def draw(self): @@ -261,6 +262,7 @@ class HorizontalLines(BaseInteractor): on the x direction. The two lines move symmetrically (in opposite directions). It also defines the x and -x position of a box. """ + def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5): """ """ @@ -371,6 +373,7 @@ class VerticalLines(BaseInteractor): on the y direction. The two lines move symmetrically (in opposite directions). It also defines the y and -y position of a box. """ + def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5): """ """ @@ -481,12 +484,13 @@ class BoxInteractorX(BoxInteractor): averaged together to provide a 1D array in Qx (to be plotted as a function of Qx) """ + def __init__(self, base, axes, item=None, color='black', zorder=3): BoxInteractor.__init__(self, base, axes, item=item, color=color) self.base = base - self._post_data() + super()._post_data() - def _post_data(self): + def _post_data(self, new_slab=None, nbins=None, direction=None): """ Post data creating by averaging in Qx direction """ @@ -514,12 +518,13 @@ class BoxInteractorY(BoxInteractor): averaged together to provide a 1D array in Qy (to be plotted as a function of Qy) """ + def __init__(self, base, axes, item=None, color='black', zorder=3): BoxInteractor.__init__(self, base, axes, item=item, color=color) self.base = base - self._post_data() + super()._post_data() - def _post_data(self): + def _post_data(self, new_slab=None, nbins=None, direction=None): """ Post data creating by averaging in Qy direction """ diff --git a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py index af873dd8e5..bd6ee37d22 100644 --- a/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/SectorSlicer.py @@ -380,13 +380,13 @@ def update(self, phi=None, delta=None, mline=None, else: self.phi = numpy.fabs(self.phi) if side: - self.theta = mline.theta + self.phi + self.theta = mline.alpha + self.phi if mline is not None: if delta != 0: self.theta2 = mline + delta else: - self.theta2 = mline.theta + self.theta2 = mline.alpha if delta == 0: theta3 = self.theta + delta else: diff --git a/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py index 66355f012b..ac76911f24 100644 --- a/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py +++ b/src/sas/qtgui/Plotting/Slicers/WedgeSlicer.py @@ -9,6 +9,7 @@ from sas.qtgui.Plotting.Slicers.RadiusInteractor import RadiusInteractor from sas.qtgui.Plotting.Slicers.SectorSlicer import LineInteractor + class WedgeInteractor(BaseInteractor, SlicerModel): """ This WedgeInteractor is a cross between the SectorInteractor and the @@ -29,6 +30,7 @@ class WedgeInteractor(BaseInteractor, SlicerModel): AnnulusSlicer) and WedgeInteractorQ averages all phi points at constant Q (as for the SectorSlicer). """ + def __init__(self, base, axes, item=None, color='black', zorder=3): BaseInteractor.__init__(self, base, axes, color=color) @@ -115,7 +117,7 @@ def update(self): self.phi = self.radial_lines.phi self.inner_arc.update(phi=self.phi) self.outer_arc.update(phi=self.phi) - if self.central_line.has_move: + if self.central_line.has_move: self.central_line.update() self.theta = self.central_line.theta self.inner_arc.update(theta=self.theta) @@ -201,7 +203,6 @@ def _post_data(self, wedge_type=None, nbins=None): new_plot.xaxis("\\rm{Q}", 'A^{-1}') new_plot.yaxis("\\rm{Intensity} ", "cm^{-1}") - new_plot.id = str(self.averager.__name__) + self.data.name new_plot.group_id = new_plot.id new_plot.is_data = True @@ -322,18 +323,20 @@ def draw(self): """ self.base.draw() + class WedgeInteractorQ(WedgeInteractor): """ Average in Q direction. The data for all phi at a constant Q are averaged together to provide a 1D array in Q (to be plotted as a function of Q) """ + def __init__(self, base, axes, item=None, color='black', zorder=3): WedgeInteractor.__init__(self, base, axes, item=item, color=color) self.base = base - self._post_data() + super()._post_data() - def _post_data(self): + def _post_data(self, new_sector=None, nbins=None): from sasdata.data_util.new_manipulations import WedgeQ super()._post_data(WedgeQ) @@ -344,12 +347,13 @@ class WedgeInteractorPhi(WedgeInteractor): averaged together to provide a 1D array in phi (to be plotted as a function of phi) """ + def __init__(self, base, axes, item=None, color='black', zorder=3): WedgeInteractor.__init__(self, base, axes, item=item, color=color) self.base = base - self._post_data() + super()._post_data() - def _post_data(self): + def _post_data(self, new_sector=None, nbins=None): from sasdata.data_util.new_manipulations import WedgePhi super()._post_data(WedgePhi) diff --git a/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py b/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py index 47494b92c6..cf3016ba6a 100644 --- a/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py +++ b/src/sas/qtgui/Plotting/UnitTesting/PlotterTest.py @@ -162,7 +162,7 @@ def testAddText(self, plotter, mocker): test_text = "Smoke in cabin" test_font = QtGui.QFont("Arial", 16, QtGui.QFont.Bold) test_color = "#00FF00" - plotter.addText.textEdit.setText(test_text) + plotter.addText.codeEditor.setText(test_text) # Return the requested font parameters mocker.patch.object(plotter.addText, 'font', return_value=test_font) @@ -186,7 +186,7 @@ def testOnRemoveText(self, plotter, mocker): # Add some text plotter.plot(self.data) test_text = "Safety instructions" - plotter.addText.textEdit.setText(test_text) + plotter.addText.codeEditor.setText(test_text) # Return OK from the dialog mocker.patch.object(plotter.addText, 'exec_', return_value=QtWidgets.QDialog.Accepted) # Add text to graph diff --git a/src/sas/qtgui/Utilities/GuiUtils.py b/src/sas/qtgui/Utilities/GuiUtils.py index 7166e15c3c..6e35a3842a 100644 --- a/src/sas/qtgui/Utilities/GuiUtils.py +++ b/src/sas/qtgui/Utilities/GuiUtils.py @@ -1136,7 +1136,7 @@ def jdefault(o): objects that can't otherwise be serialized need to be converted """ # tuples and sets (TODO: default JSONEncoder converts tuples to lists, create custom Encoder that preserves tuples) - if isinstance(o, (tuple, set, np.float)): + if isinstance(o, (tuple, set, float)): content = { 'data': list(o) } return add_type(content, type(o)) diff --git a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py index 36114a85fc..54437ca983 100644 --- a/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py +++ b/src/sas/qtgui/Utilities/OrientationViewer/OrientationViewer.py @@ -28,16 +28,17 @@ class OrientationViewer(QtWidgets.QWidget): # Dimensions of scattering cuboid - a = 0.1 - b = 0.4 - c = 1.0 + a = 10 + b = 40 + c = 100 + + screen_scale = 0.01 # Angstroms to screen size arrow_size = 0.2 arrow_color = uniform_coloring(0.9, 0.9, 0.9) ghost_color = uniform_coloring(0.0, 0.6, 0.2) cube_color = uniform_coloring(0.0, 0.8, 0.0) - cuboid_scaling = [a, b, c] n_ghosts_per_perameter = 8 n_q_samples = 128 @@ -53,9 +54,9 @@ class OrientationViewer(QtWidgets.QWidget): @staticmethod def create_ghost(): """ Helper function: Create a ghost cube""" - return Scaling(OrientationViewer.a, - OrientationViewer.b, - OrientationViewer.c, + return Scaling(OrientationViewer.a*OrientationViewer.screen_scale, + OrientationViewer.b*OrientationViewer.screen_scale, + OrientationViewer.c*OrientationViewer.screen_scale, Cube(edge_colors=OrientationViewer.ghost_color)) def __init__(self, parent=None): @@ -130,9 +131,9 @@ def __init__(self, parent=None): self.first_rotation = Rotation(0,0,0,1, - Scaling(OrientationViewer.a, - OrientationViewer.b, - OrientationViewer.c, + Scaling(OrientationViewer.a*OrientationViewer.screen_scale, + OrientationViewer.b*OrientationViewer.screen_scale, + OrientationViewer.c*OrientationViewer.screen_scale, Cube( edge_colors=OrientationViewer.ghost_color, colors=OrientationViewer.cube_color)), @@ -170,7 +171,7 @@ def colormap(self, colormap_name: str): def _set_image_data(self, orientation: Orientation): """ Set the data on the plot""" - data = self.scatering_data(orientation) + data = self.scattering_data(orientation) scaled_data = (np.log(data) - OrientationViewer.log_I_min) / OrientationViewer.log_I_range self.image_plane_data = np.clip(scaled_data, 0, 1) @@ -252,7 +253,7 @@ def polydispersity_sample_count(self, orientation): return (samples * x for x in is_polydisperse) - def scatering_data(self, orientation: Orientation) -> np.ndarray: + def scattering_data(self, orientation: Orientation) -> np.ndarray: # add the orientation parameters to the model parameters @@ -271,9 +272,9 @@ def scatering_data(self, orientation: Orientation) -> np.ndarray: psi_pd=orientation.dpsi, psi_pd_type=OrientationViewer.polydispersity_distribution, psi_pd_n=psi_pd_n, - a=OrientationViewer.a, - b=OrientationViewer.b, - c=OrientationViewer.c, + length_a=OrientationViewer.a, + length_b=OrientationViewer.b, + length_c=OrientationViewer.c, background=np.exp(OrientationViewer.log_I_min)) return np.reshape(data, (OrientationViewer.n_q_samples, OrientationViewer.n_q_samples)) diff --git a/src/sas/qtgui/convertUI.py b/src/sas/qtgui/convertUI.py index bb402af8f7..b15fd593f3 100644 --- a/src/sas/qtgui/convertUI.py +++ b/src/sas/qtgui/convertUI.py @@ -86,6 +86,7 @@ def rebuild_new_ui(force=False): rc_file = 'images.qrc' out_file = 'images_rc.py' + in_file = os.path.join(images_root, rc_file) out_file = os.path.join(ui_root, out_file) @@ -94,6 +95,20 @@ def rebuild_new_ui(force=False): pyrrc(in_file, out_file) + # Icons for Particle Editor + images_root = os.path.join(execute_root, 'Perspectives/ParticleEditor/UI/icons') + out_root = os.path.join(execute_root, 'Perspectives/ParticleEditor/UI') + rc_file = 'icons.qrc' + out_file = 'icons_rc.py' + + in_file = os.path.join(images_root, rc_file) + out_file = os.path.join(out_root, out_file) + + if force or file_in_newer(in_file, out_file): + print("Generating " + out_file + " ...") + pyrrc(in_file, out_file) + + def main(): """ Entry point for running as a script """ diff --git a/src/sas/sascalc/pr/p_invertor.py b/src/sas/sascalc/pr/p_invertor.py index 78e380be4d..8eb23c322a 100644 --- a/src/sas/sascalc/pr/p_invertor.py +++ b/src/sas/sascalc/pr/p_invertor.py @@ -14,11 +14,11 @@ class Pinvertor(object): #q data - x = np.empty(0, dtype=np.float) + x = np.empty(0, dtype=np.float64) #I(q) data - y = np.empty(0, dtype=np.float) + y = np.empty(0, dtype=np.float64) #dI(q) data - err = np.empty(0, dtype=np.float) + err = np.empty(0, dtype=np.float64) #Number of q points npoints = 0 #Number of I(q) points