From 537f235d211bae4130c1a1a5339152e0b8d47562 Mon Sep 17 00:00:00 2001 From: MACarlsen <44873424+MACarlsen@users.noreply.github.com> Date: Tue, 12 May 2026 21:27:24 +0200 Subject: [PATCH 01/17] Add files via upload --- ...gaussian_splatting_diffraction_patterns.png | Bin 0 -> 109669 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/_static/gaussian_splatting_diffraction_patterns.png diff --git a/docs/_static/gaussian_splatting_diffraction_patterns.png b/docs/_static/gaussian_splatting_diffraction_patterns.png new file mode 100644 index 0000000000000000000000000000000000000000..07878e0da0398ff7329de0a569a537b55c268e92 GIT binary patch literal 109669 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

PC~$_{8>+# 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

S9 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

``~T?mDY=x~nB5DCnm7#r+W`wQiT| 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 e-^F0+6zkK( Date: Tue, 12 May 2026 21:29:36 +0200 Subject: [PATCH 02/17] Update README.rst --- README.rst | 300 +---------------------------------------------------- 1 file changed, 2 insertions(+), 298 deletions(-) diff --git a/README.rst b/README.rst index 30031e1..6e51d8f 100644 --- a/README.rst +++ b/README.rst @@ -1,299 +1,3 @@ -.. image:: https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/logo.png?raw=true +.. image:: https://github.com/MACarlsen/xrd_simulator_gs/blob/main/docs/_static/gaussian_splatting_diffraction_patterns.png -.. image:: https://img.shields.io/pypi/pyversions/xrd-simulator.svg? - :target: https://pypi.org/project/xrd-simulator/ - -.. image:: https://github.com/FABLE-3DXRD/xrd_simulator/actions/workflows/python-package-run-tests-linux-py313.yml/badge.svg? - :target: https://github.com/FABLE-3DXRD/xrd_simulator/actions/workflows/python-package-run-tests-linux-py313.yml/badge.svg - -.. image:: https://github.com/FABLE-3DXRD/xrd_simulator/actions/workflows/pages/pages-build-deployment/badge.svg? - :target: https://github.com/FABLE-3DXRD/xrd_simulator/actions/workflows/pages/pages-build-deployment/ - -.. image:: https://badge.fury.io/py/xrd-simulator.svg? - :target: https://pypi.org/project/xrd-simulator/ - -.. image:: https://anaconda.org/conda-forge/vsc-install/badges/platforms.svg? - :target: https://anaconda.org/conda-forge/xrd_simulator/ - -.. image:: https://anaconda.org/conda-forge/xrd_simulator/badges/latest_release_relative_date.svg? - :target: https://anaconda.org/conda-forge/xrd_simulator/ - -=================================================================================================== -Simulate X-ray Diffraction from Polycrystals in 3D. -=================================================================================================== -.. image:: https://img.shields.io/badge/stability-alpha-f4d03f.svg? - :target: https://github.com/FABLE-3DXRD/xrd_simulator/ - - -The **X**-**R** ay **D** iffraction **SIMULATOR** package defines polycrystals as a mesh of tetrahedral single crystals -and simulates diffraction as collected by a 2D discretized detector array while the sample is rocked -around an arbitrary rotation axis. The full journal paper associated to the release of this code can be found here: - -*xrd_simulator: 3D X-ray diffraction simulation software supporting 3D polycrystalline microstructure morphology descriptions -Henningsson, A. & Hall, S. A. (2023). J. Appl. Cryst. 56, 282-292.* -`https://doi.org/10.1107/S1600576722011001`_ - -``xrd_simulator`` was originally developed with the hope to answer questions about measurement optimization in -scanning x-ray diffraction experiments. However, ``xrd_simulator`` can simulate a wide range of experimental -diffraction setups. The essential idea is that the sample and beam topology can be arbitrarily specified, -and their interaction simulated as the sample is rocked. This means that standard "non-powder" experiments -such as `scanning-3dxrd`_ and full-field `3dxrd`_ (or HEDM if you like) can be simulated as well as more advanced -measurement sequences such as helical scans for instance. It is also possible to simulate `powder like`_ -scenarios using orientation density functions as input. - -=================================================================================================== -Introduction -=================================================================================================== -Before reading all the boring documentation (`which is hosted here`_) let's dive into some end to end -examples to get us started on a good flavour. - -The ``xrd_simulator`` is built around four python objects which reflect a diffraction experiment: - - * A **beam** of x-rays (using the ``xrd_simulator.beam`` module) - * A 2D area **detector** (using the ``xrd_simulator.detector`` module) - * A 3D **polycrystal** sample (using the ``xrd_simulator.polycrystal`` module) - * A rigid body sample **motion** (using the ``xrd_simulator.motion`` module) - -Once these objects are defined it is possible to let the **detector** collect scattering of the **polycrystal** -as the sample undergoes the prescribed rigid body **motion** while being illuminated by the xray **beam**. - -Let's go ahead and build ourselves some x-rays: - - .. code:: python - - import numpy as np - from xrd_simulator.beam import Beam - - # The beam of xrays is represented as a convex polyhedron - # We specify the vertices in a numpy array. - beam_vertices = np.array( - [ - [-1e6, -500.0, -500.0], - [-1e6, 500.0, -500.0], - [-1e6, 500.0, 500.0], - [-1e6, -500.0, 500.0], - [1e6, -500.0, -500.0], - [1e6, 500.0, -500.0], - [1e6, 500.0, 500.0], - [1e6, -500.0, 500.0], - ] - ) - - beam = Beam( - beam_vertices, - xray_propagation_direction=np.array([1.0, 0.0, 0.0]), - wavelength=0.28523, - polarization_vector=np.array([0.0, 1.0, 0.0]), - ) - -We will also need to define a detector: - - .. code:: python - - from xrd_simulator.detector import Detector - - # The detector plane is defined by it's corner coordinates det_corner_0,det_corner_1,det_corner_2 - detector = Detector( - det_corner_0=np.array([142938.3, -38400.0, -38400.0]), - det_corner_1=np.array([142938.3, 38400.0, -38400.0]), - det_corner_2=np.array([142938.3, -38400.0, 38400.0]), - pixel_size=(55.0, 55.0), - gaussian_sigma=1.0, - max_gaussian_kernel_radius=5, - ) - -Next we go ahead and produce a sample, to do this we need to first define a mesh that -describes the topology of the sample, in this example we make the sample shaped as a ball: - - .. code:: python - - from xrd_simulator.mesh import TetraMesh - - # xrd_simulator supports several ways to generate a mesh, here we - # generate meshed solid sphere using a level set. - mesh = TetraMesh.generate_mesh_from_levelset( - level_set=lambda x: np.linalg.norm(x) - 768.0, - bounding_radius=769.0, - max_cell_circumradius=550.0, - ) - -Every element in the sample is composed of some material, or "phase", we define the present phases -in a list of ``xrd_simulator.phase.Phase`` objects, in this example only a single phase is present: - - .. code:: python - - from xrd_simulator.phase import Phase - - quartz = Phase( - unit_cell=[4.926, 4.926, 5.4189, 90.0, 90.0, 120.0], - sgname="P3221", # (Quartz) - path_to_cif_file=None, # phases can be defined from crystalographic information files - ) - -The polycrystal sample can now be created. In this example the crystal elements have random orientations -and the strain is uniformly zero in the sample: - - .. code:: python - - from scipy.spatial.transform import Rotation as R - from xrd_simulator.polycrystal import Polycrystal - - orientation = R.random(mesh.number_of_elements).as_matrix() - # element_phase_map assigns each mesh element to a phase (0 = first phase) - element_phase_map = np.zeros(mesh.number_of_elements, dtype=int) - polycrystal = Polycrystal( - mesh, - orientation, - strain=np.zeros((3, 3)), - phases=quartz, - element_phase_map=element_phase_map, - ) - -We may save the polycrystal to disc by using the builtin ``save()`` command as - - .. code:: python - - polycrystal.save('my_polycrystal', save_mesh_as_xdmf=True) - -We can visualize the sample by loading the .xdmf file into your favorite 3D rendering program. -In `paraview`_ the sampled colored by one of its Euler angles looks like this: - -.. image:: https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/example_polycrystal_readme.png?raw=true - :align: center - -We can now define some motion of the sample over which to integrate the diffraction signal: - - .. code:: python - - from xrd_simulator.motion import RigidBodyMotion - - motion = RigidBodyMotion( - rotation_axis=np.array([0, 1 / np.sqrt(2), -1 / np.sqrt(2)]), - rotation_angle=np.radians(0.1), - translation=np.array([123, -153.3, 3.42]), - ) - -Now that we have an experimental setup we may collect diffraction by letting the beam and detector -interact with the sample: - - .. code:: python - - peaks_dict = polycrystal.diffract(beam, motion, detector=detector) - diffraction_pattern, peaks_dict = detector.render( - peaks_dict, frames_to_render=0, method="micro" - ) - -The resulting rendered detector frame will look something like the below. Note that the positions of the diffraction spots may vary as the crystal orientations were randomly generated!: - - .. code:: python - - import matplotlib.pyplot as plt - - fig, ax = plt.subplots(1, 1, figsize=(12, 12)) - # render returns (frames, height, width), take first frame - pattern = ( - diffraction_pattern[0].cpu().numpy() - if hasattr(diffraction_pattern, "cpu") - else diffraction_pattern[0] - ) - ax.imshow(pattern, cmap="gray", vmax=5000) - plt.show() - -.. image:: https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/diffraction_pattern.png?raw=true - :align: center - :width: 95% - -To compute several frames simply change the motion and collect the diffraction again. The sample may be moved before -each computation using the same or another motion. - - .. code:: python - - polycrystal.transform(motion, time=1.0) - peaks_dict = polycrystal.diffract(beam, motion, detector=detector) - -Many more options for experimental setups and intensity rendering exist, have fun experimenting! -The above example code can be found as a `single .py file here.`_ - -====================================== -Installation -====================================== - -Anaconda installation (Linux and Macos) -============================================= -``xrd_simulator`` is distributed on the `conda-forge channel`_ and the preferred way to install -the xrd_simulator package is via `Anaconda`_:: - - conda install -c conda-forge xrd_simulator - conda create -n xrd_simulator - conda activate xrd_simulator - -This is meant to work across OS-systems and requires an `Anaconda`_ installation. - -(The conda-forge feedstock of ``xrd_simulator`` `can be found here.`_) - -Anaconda installation (Windows) -====================================== -``xrd_simulator`` can be installed on Windows via Anaconda. The package now uses `meshpy`_ which provides -better cross-platform support than the previous pygalmesh dependency. - -Pip Installation -====================================== -Pip installation is possible. The package uses `meshpy`_ which generally installs cleanly via pip:: - - pip install xrd-simulator - -Source installation -=============================== -Naturally one may also install from the sources:: - - git clone https://github.com/FABLE-3DXRD/xrd_simulator.git - cd xrd_simulator - python setup.py install - -This will use `meshpy`_ which generally has better cross-platform support than pygalmesh. - -Credits -=============================== -``xrd_simulator`` makes good use of xfab and meshpy for tetrahedral mesh generation. -The source code of related repos can be found here: - -* `https://github.com/FABLE-3DXRD/xfab`_ -* `meshpy`_ - -Citation -=============================== -If you feel that ``xrd_simulator`` was helpful in your research we would love for you to cite us. - -*xrd_simulator: 3D X-ray diffraction simulation software supporting 3D polycrystalline microstructure morphology descriptions -Henningsson, A. & Hall, S. A. (2023). J. Appl. Cryst. 56, 282-292.* -`https://doi.org/10.1107/S1600576722011001`_ - -.. _https://doi.org/10.1107/S1600576722011001: https://doi.org/10.1107/S1600576722011001 - -.. _https://github.com/FABLE-3DXRD/xfab: https://github.com/FABLE-3DXRD/xfab - -.. _https://github.com/marmakoide/miniball: https://github.com/marmakoide/miniball - -.. _Anaconda: https://www.anaconda.com/products/individual - -.. _meshpy: https://github.com/inducer/meshpy - -.. _https://github.com/inducer/meshpy: https://github.com/inducer/meshpy - -.. _scanning-3dxrd: https://doi.org/10.1107/S1600576720001016 - -.. _3dxrd: https://en.wikipedia.org/wiki/3DXRD - -.. _powder like: https://en.wikipedia.org/wiki/Powder_diffraction - -.. _which is hosted here: https://FABLE-3DXRD.github.io/xrd_simulator/ - -.. _which is hosted here: https://FABLE-3DXRD.github.io/xrd_simulator/ - -.. _single .py file here.: https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/examples/readme_tutorial.py - -.. _paraview: https://www.paraview.org/ - -.. _can be found here.: https://github.com/conda-forge/xrd_simulator-feedstock - -.. _conda-forge channel: https://anaconda.org/conda-forge/xrd_simulator +The future is now! From 5a7cd07d298bfa6a683e21e2d59877e47c91af6e Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Thu, 14 May 2026 13:01:38 +0200 Subject: [PATCH 03/17] getting comfortable --- xrd_simulator/detector.py | 7 +++++++ xrd_simulator/gaussian_crystal_model.py | 27 +++++++++++++++++++++++++ xrd_simulator/phase.py | 3 ++- 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 xrd_simulator/gaussian_crystal_model.py diff --git a/xrd_simulator/detector.py b/xrd_simulator/detector.py index b520994..79c8e76 100644 --- a/xrd_simulator/detector.py +++ b/xrd_simulator/detector.py @@ -177,6 +177,13 @@ def __init__( self.polarization_factor = use_polarization self.structure_factor = use_structure_factor + # Mads' favourite parametrization + self.shape = self.pixel_coordinates.shape[:2] + self.ori = self.pixel_coordinates[0,0] + v = torch.tensordot(self.pixel_coordinates - self.pixel_coordinates[0,0], self.ydhat, ((2,),(0,))) + u = torch.tensordot(self.pixel_coordinates - self.pixel_coordinates[0,0], self.zdhat, ((2,),(0,))) + self.W = np.stack([self.zdhat, self.ydhat]) + def save(self, path: str) -> None: """Save detector to disk. diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py new file mode 100644 index 0000000..860fd75 --- /dev/null +++ b/xrd_simulator/gaussian_crystal_model.py @@ -0,0 +1,27 @@ +from scipy.spatial.transform import Rotation as R +import numpy as np +import numpy.typing as npt +import torch +from torch import Tensor +from xrd_simulator.phase import Phase + + +class GaussianGrainish: + """It's not a grain, it's a grain-ish. + """ + + def __init__(self, + phase: Phase, + position: npt.NDArray | Tensor = np.array([0, 0, 0]), + shape_tensor: npt.NDArray | Tensor = np.eye(3), + orientation: npt.NDArray | Tensor = np.eye(3), # 3by3 Rotation matrix. + misorientation_tensor: npt.NDArray | Tensor = 0.0175**2 * np.eye(3), # Default one degree isotropic + strain_tensor: npt.NDArray | Tensor = np.zeros((3, 3,)), + ): + + self.phase = phase + self.position = position + self.shape_tensor = shape_tensor + self.orientation = orientation + self.misorientation_tensor = misorientation_tensor + self.strain_tensor = strain_tensor diff --git a/xrd_simulator/phase.py b/xrd_simulator/phase.py index 8821f60..1cbb8ce 100644 --- a/xrd_simulator/phase.py +++ b/xrd_simulator/phase.py @@ -20,7 +20,7 @@ .. _The .cif file used in the above example can be found here.: https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/examples/quartz.cif?raw=true """ import numpy as np -from xfab import tools, structure +from xfab import tools, structure, sg from xrd_simulator import utils class Phase(object): @@ -68,6 +68,7 @@ class Phase(object): def __init__(self, unit_cell, sgname, path_to_cif_file=None): self.unit_cell = unit_cell self.sgname = sgname + self.rot = sg.sg(sgname=sgname).rot self.miller_indices = None self.structure_factors = None self.path_to_cif_file = path_to_cif_file From 27135c6dc102955f2af099fbf41150a955d6d709 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Thu, 14 May 2026 16:23:34 +0200 Subject: [PATCH 04/17] used xfab wrong --- xrd_simulator/phase.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/xrd_simulator/phase.py b/xrd_simulator/phase.py index 1cbb8ce..01f7f10 100644 --- a/xrd_simulator/phase.py +++ b/xrd_simulator/phase.py @@ -20,7 +20,7 @@ .. _The .cif file used in the above example can be found here.: https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/examples/quartz.cif?raw=true """ import numpy as np -from xfab import tools, structure, sg +from xfab import tools, structure, sg, symmetry from xrd_simulator import utils class Phase(object): @@ -68,7 +68,11 @@ class Phase(object): def __init__(self, unit_cell, sgname, path_to_cif_file=None): self.unit_cell = unit_cell self.sgname = sgname - self.rot = sg.sg(sgname=sgname).rot + + # Warning, this is the pointgroup of the lattice, not of the system. So you cannot deal with all spacegroups this way + # there seems to be no way to get the proper point group in xfab. + self.rot = symmetry.rotations(sg.sg(sgname=sgname).crystal_system) + self.miller_indices = None self.structure_factors = None self.path_to_cif_file = path_to_cif_file From e75db8a5a6e7c141da3b65ef84349ec6dd721c70 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Thu, 14 May 2026 17:16:15 +0200 Subject: [PATCH 05/17] polefigures completed --- xrd_simulator/gaussian_crystal_model.py | 156 ++++++++++++++++++++++++ xrd_simulator/phase.py | 11 +- 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 860fd75..820f1e0 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -3,7 +3,11 @@ import numpy.typing as npt import torch from torch import Tensor + from xrd_simulator.phase import Phase +from xrd_simulator.utils import ensure_torch + +from xfab.tools import form_a_mat class GaussianGrainish: @@ -25,3 +29,155 @@ def __init__(self, self.orientation = orientation self.misorientation_tensor = misorientation_tensor self.strain_tensor = strain_tensor + + + + +levi_cita_symbol = np.zeros((3,3,3)) +levi_cita_symbol[0, 1, 2] = 1 +levi_cita_symbol[1, 2, 0] = 1 +levi_cita_symbol[2, 0, 1] = 1 +levi_cita_symbol[0, 2, 1] = -1 +levi_cita_symbol[1, 0, 2] = -1 +levi_cita_symbol[2, 1, 0] = -1 + +levi_cita_symbol = torch.tensor(levi_cita_symbol) + + +class GaussianPolycrystal: + + def __init__(self, + grain_list: List[GaussianGrainish], + ): + + phases_list = list(set([grain.phase for grain in grain_list])) + n_phases = len(phases_list) + assert n_phases == 1 + + self.n_grains = len(grain_list) + self.phase = phases_list[0] + + self.positions = torch.stack([ensure_torch(grain.position) for grain in grain_list]) + self.shape_concentration_tensors = torch.stack([ensure_torch(np.linalg.inv(grain.shape_tensor)) for grain in grain_list]) + + self.orientaions = torch.stack([ensure_torch(grain.orientation) for grain in grain_list]) + self.misori_concentration_tensors = torch.stack([ensure_torch(np.linalg.inv(grain.misorientation_tensor)) for grain in grain_list]) + + self.strains = torch.stack([ensure_torch(grain.strain_tensor) for grain in grain_list]) + + + def splat_onto_polefigure( + self, + hkl: tuple[int], + ): + + + A = form_a_mat(self.phase.unit_cell) + B = 2 * np.pi * np.linalg.inv(A).T + h = torch.tensor(B @ hkl) + h = h / torch.linalg.norm(h) + + # TODO reduce the number of symmetries evaluated for low-multiplicity peaks + + n_symmetries = len(self.phase.rot) + + volumes = torch.sqrt(torch.linalg.det(self.shape_concentration_tensors)) + p_vectors = torch.einsum('gij,sjk,k->gsi', self.orientaions, ensure_torch(self.phase.rot), h) + + # This is the trick: + pTp = torch.einsum('gsi,gij,gsj->gs', p_vectors, self.misori_concentration_tensors, p_vectors) + inner_part = self.misori_concentration_tensors[:, None, :, :] - torch.einsum( + 'gij,gsj,gsk,gkl->gsil', + self.misori_concentration_tensors, + p_vectors, + p_vectors, + self.misori_concentration_tensors, + ) / pTp[:, :, None, None] + projected_misorientation = torch.einsum( + 'gsj,ijk,gsil,lmn,gsm->gskn', + p_vectors, + levi_cita_symbol, + inner_part, + levi_cita_symbol, + p_vectors, + ) + + scale = 1 / n_symmetries / torch.sum(volumes) * volumes[:, None] * 2 * torch.sqrt(torch.linalg.det(self.misori_concentration_tensors))[:, None] / np.sqrt( pTp ) + + return p_vectors, scale, projected_misorientation + + def render_polefigure( + self, + hkl: tuple[int], + resolution_in_degrees: float = 1.0, + both_hemispheres: bool = False, + max_misorientation: float = 0.1, + ): + + # Make coordinate arrays + if both_hemispheres: + polar, azim = np.meshgrid(np.linspace(0, np.pi, int(180//resolution_in_degrees)+1), + np.linspace(0, 2*np.pi, int(360//resolution_in_degrees)+1)) + else: + polar, azim = np.meshgrid(np.linspace(0, np.pi/2, int(90//resolution_in_degrees)+1), + np.linspace(0, 2*np.pi, int(360//resolution_in_degrees)+1)) + + y_map = torch.tensor(np.stack([ + np.sin(polar) * np.cos(azim), + np.sin(polar) * np.sin(azim), + np.cos(polar) + ], axis=-1)) + + p, scale, T_proj = self.splat_onto_polefigure(hkl) + patch_size = 16 + + f = self.rasterize_on_unitvector_map( + y_map, + p, + scale, + T_proj, + max_angle=3*max_misorientation + (resolution_in_degrees*np.pi/180) * patch_size/2, + ) + + return f, polar, azim + + def rasterize_on_unitvector_map( + self, + y : Tensor, + p : Tensor, + scale : Tensor, + T_proj : Tensor, + max_angle: float, + patch_size: int = 16, + ): + + shape = y.shape[:2] + min_dp = np.cos(max_angle) + n_patches_dim1 = (shape[0]-1)//patch_size+1 + n_patches_dim2 = (shape[1]-1)//patch_size+1 + + # Rasterization + f = torch.zeros(shape) + + for patch_index_1 in range(n_patches_dim1): + for patch_index_2 in range(n_patches_dim2): + + # Figure out what splat lie in this pole figure patch + y_patch = y[patch_size*patch_index_1:patch_size*(patch_index_1+1), + patch_size*patch_index_2:patch_size*(patch_index_2+1)] + patch_mean = torch.mean(y_patch, axis=(0, 1)) + patch_mean_y = patch_mean / torch.linalg.norm(patch_mean) + include_index = torch.abs(torch.einsum('gsj,j->gs', p, patch_mean_y)) > min_dp + # If none, continue + if not torch.any(include_index): + continue + + # Evaluate gaussians + arg = -torch.einsum('pai,xij,paj->xpa', y_patch, T_proj[include_index], y_patch) + vals = torch.exp(arg) * scale[include_index, np.newaxis, np.newaxis] + + f[patch_size*patch_index_1:patch_size*(patch_index_1+1), + patch_size*patch_index_2:patch_size*(patch_index_2+1)]\ + += torch.sum(vals, axis=0) + + return f diff --git a/xrd_simulator/phase.py b/xrd_simulator/phase.py index 01f7f10..f6ddefc 100644 --- a/xrd_simulator/phase.py +++ b/xrd_simulator/phase.py @@ -20,7 +20,8 @@ .. _The .cif file used in the above example can be found here.: https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/examples/quartz.cif?raw=true """ import numpy as np -from xfab import tools, structure, sg, symmetry +from xfab import tools, structure, sg +from xfab.tools import form_a_mat from xrd_simulator import utils class Phase(object): @@ -69,9 +70,11 @@ def __init__(self, unit_cell, sgname, path_to_cif_file=None): self.unit_cell = unit_cell self.sgname = sgname - # Warning, this is the pointgroup of the lattice, not of the system. So you cannot deal with all spacegroups this way - # there seems to be no way to get the proper point group in xfab. - self.rot = symmetry.rotations(sg.sg(sgname=sgname).crystal_system) + # These are rotations in the reference coordinate system where the lattice matrix is upper triangular + A = form_a_mat(unit_cell) + A_inv = np.linalg.inv(A) + sg_obj = sg.sg(sgname=sgname) + self.rot = rots = np.stack([A @ rot_lattce_space @ A_inv for rot_lattce_space in sg_obj.rot]) self.miller_indices = None self.structure_factors = None From 2495d873fe14f5911b7d799e298f0903107a5f7b Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Thu, 14 May 2026 22:43:02 +0200 Subject: [PATCH 06/17] almost done with detector splatting --- xrd_simulator/gaussian_crystal_model.py | 216 +++++++++++++++++++++--- 1 file changed, 188 insertions(+), 28 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 820f1e0..9a4b4fc 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -6,6 +6,7 @@ from xrd_simulator.phase import Phase from xrd_simulator.utils import ensure_torch +from xrd_simulator.detector import Detector from xfab.tools import form_a_mat @@ -31,8 +32,6 @@ def __init__(self, self.strain_tensor = strain_tensor - - levi_cita_symbol = np.zeros((3,3,3)) levi_cita_symbol[0, 1, 2] = 1 levi_cita_symbol[1, 2, 0] = 1 @@ -47,7 +46,7 @@ def __init__(self, class GaussianPolycrystal: def __init__(self, - grain_list: List[GaussianGrainish], + grain_list: list[GaussianGrainish], ): phases_list = list(set([grain.phase for grain in grain_list])) @@ -65,46 +64,167 @@ def __init__(self, self.strains = torch.stack([ensure_torch(grain.strain_tensor) for grain in grain_list]) + def render_detector_frame( + self, + detector: Detector, + xray_propagation_direction: npt.NDArray | Tensor, + wavelength: float, + sample_orientation: npt.NDArray | Tensor = np.eye(3), + sample_rotation_during_exposure: npt.NDArray | Tensor = np.zeros(3), + max_misorientation: float = 0.1, + ): - def splat_onto_polefigure( + + #TODO Rotate detector or sample? + xray_propagation_direction = ensure_torch(xray_propagation_direction) + detector_norm = ensure_torch(np.cross(detector.W[0,:], detector.W[1,:])) + detector_origin = ensure_torch(detector.ori) + W = ensure_torch(detector.W) + # d = torch.dot(detector_norm, detector_origin) / torch.dot(detector_norm, xray_propagation_direction) + pixellengths = torch.tensor([detector.pixel_size_y, detector.pixel_size_z]) + + # TODO generate hkl_list and structurefactors + hkl_list = [(1, 1, 0), (2, 0, 0), (0, 0, 1)] + structurefactors = [1, 1, 1] + + uv_corrds_list = [] + splat_concentration_tensors_list = [] + scalefactors_list = [] + + for hkl, S in zip(hkl_list, structurefactors): + + #Figure out what reflections are in the bragg-condition + A = form_a_mat(self.phase.unit_cell) + B = 2 * np.pi * np.linalg.inv(A).T + h = torch.tensor(B @ hkl) + h_norm = torch.linalg.norm(h) + theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) + + # TODO reduce the number of symmetries evaluated for low-multiplicity peaks + n_symmetries = len(self.phase.rot) + + p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains, self.orientaions, ensure_torch(self.phase.rot), h) + p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) + + dp = torch.einsum('i,gsi->gs', xray_propagation_direction, p_vectors) / p_vectors_norm + does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * max_misorientation * torch.cos(theta_angle_unstrained)) + + oris = torch.tile(self.orientaions[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + misori_tensors = torch.tile(self.misori_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + p_vecs = p_vectors[does_diffract] + + #Subsample polefigure-splats on ring + mean_scattering_directions, partiality, azim_direction, azim_width = self.get_diffraction_arcsegment( + oris, p_vecs, misori_tensors, xray_propagation_direction, wavelength + ) + + #TODO Splat grain realspace shapes + realspace_splat_concentration = 1/20**2*torch.tile(torch.eye(2)[None, :, :], (len(partiality), 1, 1)) + + #TODO Do smearing due to angular divergence. + + # Direct beam is pos + mean_scatt_dir * x, detector is det_ori dot det_norm = 0 + pos = torch.tile(self.positions[:, None, :], (1, n_symmetries, 1,))[does_diffract] + ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) + point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions + uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) + + uv_corrds_list.append(uv_coords) + scalefactors_list.append(partiality) + splat_concentration_tensors_list.append(realspace_splat_concentration) + + f = self.rasterize_on_detector( + torch.concat(uv_corrds_list), + torch.concat(scalefactors_list), + torch.concat(splat_concentration_tensors_list), + detector.shape, + ) + + return f + + def rasterize_on_detector( self, - hkl: tuple[int], + uv_corrds: Tensor, + scale_factors: Tensor, + concentration_tensors: Tensor, + shape: Tuple, + patch_size: int = 16, + splat_max_size: float = 100.0 ): - A = form_a_mat(self.phase.unit_cell) - B = 2 * np.pi * np.linalg.inv(A).T - h = torch.tensor(B @ hkl) - h = h / torch.linalg.norm(h) + u, v = torch.meshgrid(torch.arange(shape[0]), torch.arange(shape[1])) + f = torch.zeros(shape) - # TODO reduce the number of symmetries evaluated for low-multiplicity peaks + patch_size = 16 + n_patches_dim1 = (shape[0]-1)//patch_size+1 + n_patches_dim2 = (shape[1]-1)//patch_size+1 - n_symmetries = len(self.phase.rot) - - volumes = torch.sqrt(torch.linalg.det(self.shape_concentration_tensors)) - p_vectors = torch.einsum('gij,sjk,k->gsi', self.orientaions, ensure_torch(self.phase.rot), h) - - # This is the trick: - pTp = torch.einsum('gsi,gij,gsj->gs', p_vectors, self.misori_concentration_tensors, p_vectors) - inner_part = self.misori_concentration_tensors[:, None, :, :] - torch.einsum( - 'gij,gsj,gsk,gkl->gsil', - self.misori_concentration_tensors, + for patch_index_1 in range(n_patches_dim1): + for patch_index_2 in range(n_patches_dim2): + + patch_slice = (slice(patch_size*patch_index_1, patch_size*(patch_index_1+1)), + slice(patch_size*patch_index_2, patch_size*(patch_index_2+1)),) + + patch_mean_u = patch_size*(patch_index_1+0.5) + patch_mean_v = patch_size*(patch_index_2+0.5) + + include_index = (uv_corrds[:, 0]-patch_mean_u)**2 + (uv_corrds[:, 1]-patch_mean_v)**2 < splat_max_size**2 + if not torch.any(include_index): + continue + + + local_coords = torch.stack([u[patch_slice][None, :, :] - uv_corrds[include_index, 0, None, None], + v[patch_slice][None, :, :] - uv_corrds[include_index, 1, None, None], + ], axis=1) + + f[patch_slice] += torch.sum(scale_factors[include_index, None, None]\ + * torch.exp(- torch.einsum('xiuv,xij,xjuv->xuv' ,local_coords, concentration_tensors[include_index, :, :], local_coords)), axis=0) + + return f + + def get_diffraction_arcsegment(self, oris, p_vectors, misori_tensors, xray_propagation_direction, wavelength): + + # Splat onto poelfigure + p_norm = torch.linalg.norm(p_vectors, axis=-1) + D = torch.einsum('xi,xij,xj->x', p_vectors, misori_tensors, p_vectors)/ p_norm**2 + + inner_part = misori_tensors - torch.einsum( + 'xij,xj,xk,xkl->xil', + misori_tensors, p_vectors, p_vectors, - self.misori_concentration_tensors, - ) / pTp[:, :, None, None] - projected_misorientation = torch.einsum( - 'gsj,ijk,gsil,lmn,gsm->gskn', + misori_tensors, + ) / D[:, None, None] / p_norm[:, None, None]**2 + T_proj = torch.einsum( + 'xj,ijk,xil,lmn,xm->xkn', p_vectors, levi_cita_symbol, inner_part, levi_cita_symbol, p_vectors, - ) + ) / p_norm[:, None, None]**2 + + # Compute point of "exact bragg condition" in the plane Span(k_0, p) + theta_angle = np.asin( p_norm * wavelength / 4 / np.pi ) + dir_scatteringplane_norm = (p_vectors - xray_propagation_direction[None, :] * np.einsum('xi,i->x', p_vectors, xray_propagation_direction)[:, None] ) + dir_scatteringplane_norm = dir_scatteringplane_norm / torch.linalg.norm(dir_scatteringplane_norm, axis=-1)[:, None] + q_0_unit = torch.cos(theta_angle)[:, None] * dir_scatteringplane_norm - torch.sin(theta_angle)[:, None] * xray_propagation_direction[None, :] + dir_scatteringplane_orth = torch.einsum('ijk,j,xk->xi', levi_cita_symbol, xray_propagation_direction, dir_scatteringplane_norm) + + # Compute partiality, mean direction, and azimthal spread + A = torch.einsum('xi,xij,xj->x', q_0_unit, T_proj, q_0_unit,) + B = torch.einsum('xi,xij,xj->x', q_0_unit, T_proj, dir_scatteringplane_orth,) + C = torch.einsum('xi,xij,xj->x', dir_scatteringplane_orth, T_proj, dir_scatteringplane_orth,) - scale = 1 / n_symmetries / torch.sum(volumes) * volumes[:, None] * 2 * torch.sqrt(torch.linalg.det(self.misori_concentration_tensors))[:, None] / np.sqrt( pTp ) + partiality = torch.exp(-A - B**2 / C) * 2 * torch.sqrt( torch.linalg.det(misori_tensors) / D ) - return p_vectors, scale, projected_misorientation + azim_divergence = np.sqrt(1 / C) + azim_offset = B / C + mean_scattering_directions = torch.cos(2*theta_angle)[:, None] * xray_propagation_direction[None, :]\ + + torch.sin(2*theta_angle)[:, None]*(torch.cos(azim_offset)[:, None]*dir_scatteringplane_norm + torch.sin(azim_offset)[:, None]*dir_scatteringplane_orth) + + return mean_scattering_directions, partiality, dir_scatteringplane_orth, azim_divergence def render_polefigure( self, @@ -136,11 +256,51 @@ def render_polefigure( p, scale, T_proj, - max_angle=3*max_misorientation + (resolution_in_degrees*np.pi/180) * patch_size/2, + max_angle= 3*max_misorientation + (resolution_in_degrees*np.pi/180)*patch_size/2, ) return f, polar, azim + def splat_onto_polefigure( + self, + hkl: tuple[int], + ): + + + A = form_a_mat(self.phase.unit_cell) + B = 2 * np.pi * np.linalg.inv(A).T + h = torch.tensor(B @ hkl) + h = h / torch.linalg.norm(h) + + # TODO reduce the number of symmetries evaluated for low-multiplicity peaks + + n_symmetries = len(self.phase.rot) + + volumes = torch.sqrt(torch.linalg.det(self.shape_concentration_tensors)) + p_vectors = torch.einsum('gij,sjk,k->gsi', self.orientaions, ensure_torch(self.phase.rot), h) + + # This is the trick: + pTp = torch.einsum('gsi,gij,gsj->gs', p_vectors, self.misori_concentration_tensors, p_vectors) + inner_part = self.misori_concentration_tensors[:, None, :, :] - torch.einsum( + 'gij,gsj,gsk,gkl->gsil', + self.misori_concentration_tensors, + p_vectors, + p_vectors, + self.misori_concentration_tensors, + ) / pTp[:, :, None, None] + projected_misorientation = torch.einsum( + 'gsj,ijk,gsil,lmn,gsm->gskn', + p_vectors, + levi_cita_symbol, + inner_part, + levi_cita_symbol, + p_vectors, + ) + + scale = 1 / n_symmetries / torch.sum(volumes) * volumes[:, None] * 2 * torch.sqrt(torch.linalg.det(self.misori_concentration_tensors))[:, None] / np.sqrt( pTp ) + + return p_vectors, scale, projected_misorientation + def rasterize_on_unitvector_map( self, y : Tensor, From 78b9ad90fc4298f43cc907307f7013daeebb1aee Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Fri, 15 May 2026 00:01:49 +0200 Subject: [PATCH 07/17] first working version of detector-space splatting --- xrd_simulator/gaussian_crystal_model.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 9a4b4fc..344877e 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -83,7 +83,7 @@ def render_detector_frame( # d = torch.dot(detector_norm, detector_origin) / torch.dot(detector_norm, xray_propagation_direction) pixellengths = torch.tensor([detector.pixel_size_y, detector.pixel_size_z]) - # TODO generate hkl_list and structurefactors + # TODO generate hkl_list and structurefactors with xfab? hkl_list = [(1, 1, 0), (2, 0, 0), (0, 0, 1)] structurefactors = [1, 1, 1] @@ -119,19 +119,23 @@ def render_detector_frame( ) #TODO Splat grain realspace shapes - realspace_splat_concentration = 1/20**2*torch.tile(torch.eye(2)[None, :, :], (len(partiality), 1, 1)) + samplespace_splat_concentration = 1/3**2*torch.tile(torch.eye(2)[None, :, :], (len(partiality), 1, 1)) - #TODO Do smearing due to angular divergence. - # Direct beam is pos + mean_scatt_dir * x, detector is det_ori dot det_norm = 0 pos = torch.tile(self.positions[:, None, :], (1, n_symmetries, 1,))[does_diffract] ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) + #Do smearing due to angular divergence. + azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_direction, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_width[:, None] / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) + azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) + detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(samplespace_splat_concentration) + azimuthal_smearing_tensor) + uv_corrds_list.append(uv_coords) - scalefactors_list.append(partiality) - splat_concentration_tensors_list.append(realspace_splat_concentration) + scalefactors_list.append(S * partiality) + splat_concentration_tensors_list.append(detspace_splat_concentration) + f = self.rasterize_on_detector( torch.concat(uv_corrds_list), @@ -147,7 +151,7 @@ def rasterize_on_detector( uv_corrds: Tensor, scale_factors: Tensor, concentration_tensors: Tensor, - shape: Tuple, + shape: tuple, patch_size: int = 16, splat_max_size: float = 100.0 ): @@ -175,8 +179,8 @@ def rasterize_on_detector( local_coords = torch.stack([u[patch_slice][None, :, :] - uv_corrds[include_index, 0, None, None], - v[patch_slice][None, :, :] - uv_corrds[include_index, 1, None, None], - ], axis=1) + v[patch_slice][None, :, :] - uv_corrds[include_index, 1, None, None], + ], axis=1) f[patch_slice] += torch.sum(scale_factors[include_index, None, None]\ * torch.exp(- torch.einsum('xiuv,xij,xjuv->xuv' ,local_coords, concentration_tensors[include_index, :, :], local_coords)), axis=0) @@ -219,7 +223,7 @@ def get_diffraction_arcsegment(self, oris, p_vectors, misori_tensors, xray_propa partiality = torch.exp(-A - B**2 / C) * 2 * torch.sqrt( torch.linalg.det(misori_tensors) / D ) - azim_divergence = np.sqrt(1 / C) + azim_divergence = np.sqrt(1 / C) * torch.sin(theta_angle) # TODO Check trigonometric function here azim_offset = B / C mean_scattering_directions = torch.cos(2*theta_angle)[:, None] * xray_propagation_direction[None, :]\ + torch.sin(2*theta_angle)[:, None]*(torch.cos(azim_offset)[:, None]*dir_scatteringplane_norm + torch.sin(azim_offset)[:, None]*dir_scatteringplane_orth) From f72309813fe04baa575855728da8619bf12456fd Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Fri, 15 May 2026 17:44:30 +0200 Subject: [PATCH 08/17] tested normalization and sense-of-rotation --- xrd_simulator/gaussian_crystal_model.py | 113 +++++++++++++++++------- 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 344877e..7273836 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -8,7 +8,8 @@ from xrd_simulator.utils import ensure_torch from xrd_simulator.detector import Detector -from xfab.tools import form_a_mat +from xfab.tools import form_a_mat, genhkl_base +from xfab import sg class GaussianGrainish: @@ -74,52 +75,77 @@ def render_detector_frame( max_misorientation: float = 0.1, ): + sample_orientation = ensure_torch(sample_orientation) + sample_rotation_during_exposure = ensure_torch(sample_rotation_during_exposure) - #TODO Rotate detector or sample? - xray_propagation_direction = ensure_torch(xray_propagation_direction) - detector_norm = ensure_torch(np.cross(detector.W[0,:], detector.W[1,:])) - detector_origin = ensure_torch(detector.ori) - W = ensure_torch(detector.W) - # d = torch.dot(detector_norm, detector_origin) / torch.dot(detector_norm, xray_propagation_direction) + #Rotate detector and incident beam by inverse of sample-rotation. + xray_propagation_direction = torch.einsum('ij,i->j', sample_orientation, ensure_torch(xray_propagation_direction) ) + detector_norm = torch.einsum('ij,i->j', sample_orientation, ensure_torch(np.cross(detector.W[0,:], detector.W[1,:]))) + detector_origin = torch.einsum('ij,i->j', sample_orientation, ensure_torch(detector.ori)) + W = torch.einsum('ij,ui->uj', sample_orientation, ensure_torch(detector.W)) pixellengths = torch.tensor([detector.pixel_size_y, detector.pixel_size_z]) - # TODO generate hkl_list and structurefactors with xfab? - hkl_list = [(1, 1, 0), (2, 0, 0), (0, 0, 1)] - structurefactors = [1, 1, 1] + # Simulate sample-rotation by adding a rotation to the grain misorientation + rotation_vector = torch.einsum('ij,i->j', sample_orientation, sample_rotation_during_exposure) + smeared_misorientation_tensors = torch.linalg.inv(torch.linalg.inv(self.misori_concentration_tensors) + torch.outer(rotation_vector, rotation_vector)) + + #Figure out what reflections are in the bragg-condition + A = form_a_mat(self.phase.unit_cell) + B = 2 * np.pi * np.linalg.inv(A).T # TODO Check if the 2 pi is conventional here + + # Generate structure-factors and + max_angle = detector._get_wrapping_cone(xray_propagation_direction, np.mean([0, 0, 0])) + self.phase._setup_diffracting_planes(wavelength=wavelength, min_bragg_angle=0.0, max_bragg_angle=max_angle) #TODO Using private method + sg_obj = sg.sg(sgname=self.phase.sgname) + + hkl_list = genhkl_base( + self.phase.unit_cell, + sg_obj.syscond, + 0.0, np.sin(max_angle) / wavelength, + sg_obj.crystal_system, + sg_obj.Laue, + ) + self.phase._set_structure_factors(hkl_list) #TODO Using private method + structurefactors = ensure_torch(np.sum(self.phase.structure_factors**2, axis=1)) uv_corrds_list = [] splat_concentration_tensors_list = [] scalefactors_list = [] + # Loop over reflection orders for hkl, S in zip(hkl_list, structurefactors): - - #Figure out what reflections are in the bragg-condition - A = form_a_mat(self.phase.unit_cell) - B = 2 * np.pi * np.linalg.inv(A).T - h = torch.tensor(B @ hkl) - h_norm = torch.linalg.norm(h) - theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) # TODO reduce the number of symmetries evaluated for low-multiplicity peaks n_symmetries = len(self.phase.rot) + + # Quick test to discard reflections far from Bragg-condition + h = torch.tensor(B @ hkl) + h_norm = torch.linalg.norm(h) + theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains, self.orientaions, ensure_torch(self.phase.rot), h) p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) - dp = torch.einsum('i,gsi->gs', xray_propagation_direction, p_vectors) / p_vectors_norm does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * max_misorientation * torch.cos(theta_angle_unstrained)) - oris = torch.tile(self.orientaions[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] - misori_tensors = torch.tile(self.misori_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] - p_vecs = p_vectors[does_diffract] + # Select the relevant reflections and flatten grain- and symetry-indexes. + orientations = torch.tile(self.orientaions[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + p_vectors = p_vectors[does_diffract] - #Subsample polefigure-splats on ring + # Do pole-figure part of the calculation mean_scattering_directions, partiality, azim_direction, azim_width = self.get_diffraction_arcsegment( - oris, p_vecs, misori_tensors, xray_propagation_direction, wavelength + orientations, p_vectors, misori_concentration_tensors, xray_propagation_direction, wavelength ) - #TODO Splat grain realspace shapes - samplespace_splat_concentration = 1/3**2*torch.tile(torch.eye(2)[None, :, :], (len(partiality), 1, 1)) + # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) + shape_concentration_tensors = torch.tile(self.shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + detectorspace_grainshape_projections, projected_thicknes_scale_factor = self.splat_grainshapes( + mean_scattering_directions, + shape_concentration_tensors, + W, + pixellengths, + ) # Direct beam is pos + mean_scatt_dir * x, detector is det_ori dot det_norm = 0 pos = torch.tile(self.positions[:, None, :], (1, n_symmetries, 1,))[does_diffract] @@ -130,10 +156,12 @@ def render_detector_frame( #Do smearing due to angular divergence. azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_direction, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_width[:, None] / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) - detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(samplespace_splat_concentration) + azimuthal_smearing_tensor) + detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) + intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. + uv_corrds_list.append(uv_coords) - scalefactors_list.append(S * partiality) + scalefactors_list.append(S * projected_thicknes_scale_factor * partiality * intensity_spread_out_factor) splat_concentration_tensors_list.append(detspace_splat_concentration) @@ -145,6 +173,32 @@ def render_detector_frame( ) return f + + def splat_grainshapes( + self, + mean_scattering_directions: Tensor, + shape_concentration_tensors: Tensor, + W: Tensor, + pixellengths: Tensor, + ): + + # grain_volume = torch.sqrt(1/torch.linalg.det(shape_concentration_tensors)) + dSd = torch.einsum('xi,xij,xj->x', mean_scattering_directions, shape_concentration_tensors, mean_scattering_directions) + + inner_term = shape_concentration_tensors - torch.einsum( + 'xij,xj,xk,xkl->xil', + shape_concentration_tensors, + mean_scattering_directions, + mean_scattering_directions, + shape_concentration_tensors, + ) / dSd[:, None, None] + + W_scaled = W * pixellengths[:, None] + projected_shape_pixelunits = torch.einsum( + 'ui,xij,vj->xuv', W_scaled, inner_term, W_scaled, + ) + + return projected_shape_pixelunits, 1/torch.sqrt(dSd) def rasterize_on_detector( self, @@ -153,7 +207,7 @@ def rasterize_on_detector( concentration_tensors: Tensor, shape: tuple, patch_size: int = 16, - splat_max_size: float = 100.0 + splat_max_size: float = 300.0 ): @@ -221,12 +275,11 @@ def get_diffraction_arcsegment(self, oris, p_vectors, misori_tensors, xray_propa B = torch.einsum('xi,xij,xj->x', q_0_unit, T_proj, dir_scatteringplane_orth,) C = torch.einsum('xi,xij,xj->x', dir_scatteringplane_orth, T_proj, dir_scatteringplane_orth,) - partiality = torch.exp(-A - B**2 / C) * 2 * torch.sqrt( torch.linalg.det(misori_tensors) / D ) - azim_divergence = np.sqrt(1 / C) * torch.sin(theta_angle) # TODO Check trigonometric function here azim_offset = B / C mean_scattering_directions = torch.cos(2*theta_angle)[:, None] * xray_propagation_direction[None, :]\ + torch.sin(2*theta_angle)[:, None]*(torch.cos(azim_offset)[:, None]*dir_scatteringplane_norm + torch.sin(azim_offset)[:, None]*dir_scatteringplane_orth) + partiality = torch.exp(-A + B**2 / C) * 2 * torch.sqrt( torch.linalg.det(misori_tensors) / D ) / torch.sqrt(C) return mean_scattering_directions, partiality, dir_scatteringplane_orth, azim_divergence From 7750f860d677fac0c46f9c3cd0e7e44ec46d3973 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Fri, 15 May 2026 20:46:27 +0200 Subject: [PATCH 09/17] exception for no cif file --- xrd_simulator/gaussian_crystal_model.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 7273836..8250202 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -73,6 +73,7 @@ def render_detector_frame( sample_orientation: npt.NDArray | Tensor = np.eye(3), sample_rotation_during_exposure: npt.NDArray | Tensor = np.zeros(3), max_misorientation: float = 0.1, + max_grain_shape:float = 1000, ): sample_orientation = ensure_torch(sample_orientation) @@ -84,7 +85,8 @@ def render_detector_frame( detector_origin = torch.einsum('ij,i->j', sample_orientation, ensure_torch(detector.ori)) W = torch.einsum('ij,ui->uj', sample_orientation, ensure_torch(detector.W)) pixellengths = torch.tensor([detector.pixel_size_y, detector.pixel_size_z]) - + d = torch.dot(detector_origin, detector_norm) / torch.dot(xray_propagation_direction, detector_norm) + # Simulate sample-rotation by adding a rotation to the grain misorientation rotation_vector = torch.einsum('ij,i->j', sample_orientation, sample_rotation_during_exposure) smeared_misorientation_tensors = torch.linalg.inv(torch.linalg.inv(self.misori_concentration_tensors) + torch.outer(rotation_vector, rotation_vector)) @@ -105,8 +107,12 @@ def render_detector_frame( sg_obj.crystal_system, sg_obj.Laue, ) - self.phase._set_structure_factors(hkl_list) #TODO Using private method - structurefactors = ensure_torch(np.sum(self.phase.structure_factors**2, axis=1)) + + if self.phase.path_to_cif_file is None: + structurefactors = torch.ones(hkl_list.shape[0]) + else: + self.phase._set_structure_factors(hkl_list) #TODO Using private method + structurefactors = ensure_torch(np.sum(self.phase.structure_factors**2, axis=1)) uv_corrds_list = [] splat_concentration_tensors_list = [] @@ -159,17 +165,17 @@ def render_detector_frame( detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. - + # Append to list uv_corrds_list.append(uv_coords) scalefactors_list.append(S * projected_thicknes_scale_factor * partiality * intensity_spread_out_factor) splat_concentration_tensors_list.append(detspace_splat_concentration) - f = self.rasterize_on_detector( torch.concat(uv_corrds_list), torch.concat(scalefactors_list), torch.concat(splat_concentration_tensors_list), detector.shape, + splat_max_size= (d * max_misorientation + max_grain_shape ) / torch.min(pixellengths) ) return f @@ -207,7 +213,8 @@ def rasterize_on_detector( concentration_tensors: Tensor, shape: tuple, patch_size: int = 16, - splat_max_size: float = 300.0 + splat_max_size: float = 50.0 # Todo, come pu with good rule here. + # d times max misorientation + max grainsize ): From 33fc3cbcfadda9d8885c21ed5aa4f8a7c7fd3384 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Sat, 16 May 2026 18:00:05 +0200 Subject: [PATCH 10/17] doctrings and moving functions --- xrd_simulator/detector.py | 74 ++++++++- xrd_simulator/gaussian_crystal_model.py | 210 ++++++++++-------------- xrd_simulator/laue.py | 85 ++++++++++ 3 files changed, 241 insertions(+), 128 deletions(-) diff --git a/xrd_simulator/detector.py b/xrd_simulator/detector.py index 79c8e76..e41376a 100644 --- a/xrd_simulator/detector.py +++ b/xrd_simulator/detector.py @@ -18,6 +18,7 @@ import numpy as np import numpy.typing as npt import torch +from torch import Tensor import torch.nn.functional as F from scipy.special import j1 @@ -176,13 +177,8 @@ def __init__( self.lorentz_factor = use_lorentz self.polarization_factor = use_polarization self.structure_factor = use_structure_factor - - # Mads' favourite parametrization self.shape = self.pixel_coordinates.shape[:2] - self.ori = self.pixel_coordinates[0,0] - v = torch.tensordot(self.pixel_coordinates - self.pixel_coordinates[0,0], self.ydhat, ((2,),(0,))) - u = torch.tensordot(self.pixel_coordinates - self.pixel_coordinates[0,0], self.zdhat, ((2,),(0,))) - self.W = np.stack([self.zdhat, self.ydhat]) + def save(self, path: str) -> None: """Save detector to disk. @@ -404,6 +400,72 @@ def contains(self, zd: torch.Tensor, yd: torch.Tensor) -> torch.Tensor: """ return (zd >= 0) & (zd <= self.zmax) & (yd >= 0) & (yd <= self.ymax) + + def render_gaussian_splats( + self, + uv_corrds: Tensor, + scale_factors: Tensor, + concentration_tensors: Tensor, + patch_size: int = 16, + splat_max_size: float = 50.0 # Todo, come pu with good rule here. + # d times max misorientation + max grainsize + ): + """ Basic 2D Gaussian rasterizer. Each gaussian has the expression: + + .. math:: f = s \exp(-[u-u_0, v-v_0] S [u-u_0, v-v_0]^T). + + Parameters + ---------- + uv_corrds : Tensor + Centroid pixel coordinates, shape ``(N, 2)`` + scale_factors : Tensor + Intensity scale factors, shape ``(N,)`` + concentration_tensors : Tensor + Shape concentration tensors, shape ``(N, 2, 2)`` + shape : tuple + Detector shape as a 2-length tuple + patch_size : int + For evaluation the detector is split into patches of shize `patch_size` by `patch_size`. + splat_max_size : float + Maximum size of a gaussian splat. + + Returns + ------- + detector_image : Tensor + Detector image, shape ``shape``. + """ + + shape = self.shape + u, v = torch.meshgrid(torch.arange(shape[0]), torch.arange(shape[1])) + f = torch.zeros(shape) + + patch_size = 16 + n_patches_dim1 = (shape[0]-1)//patch_size+1 + n_patches_dim2 = (shape[1]-1)//patch_size+1 + + for patch_index_1 in range(n_patches_dim1): + for patch_index_2 in range(n_patches_dim2): + + patch_slice = (slice(patch_size*patch_index_1, patch_size*(patch_index_1+1)), + slice(patch_size*patch_index_2, patch_size*(patch_index_2+1)),) + + patch_mean_u = patch_size*(patch_index_1+0.5) + patch_mean_v = patch_size*(patch_index_2+0.5) + + include_index = (uv_corrds[:, 0]-patch_mean_u)**2 + (uv_corrds[:, 1]-patch_mean_v)**2 < splat_max_size**2 + if not torch.any(include_index): + continue + + + local_coords = torch.stack([u[patch_slice][None, :, :] - uv_corrds[include_index, 0, None, None], + v[patch_slice][None, :, :] - uv_corrds[include_index, 1, None, None], + ], axis=1) + + f[patch_slice] += torch.sum(scale_factors[include_index, None, None]\ + * torch.exp(- torch.einsum('xiuv,xij,xjuv->xuv' ,local_coords, concentration_tensors[include_index, :, :], local_coords)), axis=0) + + return f + # ------------------------------------------------------------------ # 3. Geometry & coordinate helpers # ------------------------------------------------------------------ diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 8250202..4c2f4bb 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -7,6 +7,7 @@ from xrd_simulator.phase import Phase from xrd_simulator.utils import ensure_torch from xrd_simulator.detector import Detector +from xrd_simulator.laue import _get_diffraction_arcsegment from xfab.tools import form_a_mat, genhkl_base from xfab import sg @@ -14,6 +15,25 @@ class GaussianGrainish: """It's not a grain, it's a grain-ish. + + A grain-ish has a 3D-gaussian density distribution and a narrow (<=0.1 rad) 3D + orientation distribution function. + + Attributes + ---------- + phase : Phase + Object representing the crystal structure of the grain. + position : torch.Tensor | np.array + Position of the grain centroid, shape ``(3,)`` + shape_tensor : torch.Tensor | np.array + Covariance tensor of the real-space grain shape. ``(3, 3,)``. + orientation : torch.Tensor | np.array + Orientation of the centroid of the ODF as a rotation matrix, shape ``(3, 3,)`` + misorientation_tensor : torch.Tensor | np.array + Covariance tensor of the grain orientation in the left-hand tangent space. + aka. laboratory coordinates, shape ``(3, 3,)`` + strain_tensor : torch.Tensor | np.array + Strain tensor in laboratory coordinates, shape ``(3, 3,)`` """ def __init__(self, @@ -33,17 +53,6 @@ def __init__(self, self.strain_tensor = strain_tensor -levi_cita_symbol = np.zeros((3,3,3)) -levi_cita_symbol[0, 1, 2] = 1 -levi_cita_symbol[1, 2, 0] = 1 -levi_cita_symbol[2, 0, 1] = 1 -levi_cita_symbol[0, 2, 1] = -1 -levi_cita_symbol[1, 0, 2] = -1 -levi_cita_symbol[2, 1, 0] = -1 - -levi_cita_symbol = torch.tensor(levi_cita_symbol) - - class GaussianPolycrystal: def __init__(self, @@ -79,11 +88,14 @@ def render_detector_frame( sample_orientation = ensure_torch(sample_orientation) sample_rotation_during_exposure = ensure_torch(sample_rotation_during_exposure) + #Rotate detector and incident beam by inverse of sample-rotation. xray_propagation_direction = torch.einsum('ij,i->j', sample_orientation, ensure_torch(xray_propagation_direction) ) - detector_norm = torch.einsum('ij,i->j', sample_orientation, ensure_torch(np.cross(detector.W[0,:], detector.W[1,:]))) - detector_origin = torch.einsum('ij,i->j', sample_orientation, ensure_torch(detector.ori)) - W = torch.einsum('ij,ui->uj', sample_orientation, ensure_torch(detector.W)) + detector_origin = detector.pixel_coordinates[0,0] + W = np.stack([detector.zdhat, detector.ydhat]) + detector_norm = torch.einsum('ij,i->j', sample_orientation, ensure_torch(np.cross(W[0,:], W[1,:]))) + detector_origin = torch.einsum('ij,i->j', sample_orientation, ensure_torch(detector_origin)) + W = torch.einsum('ij,ui->uj', sample_orientation, ensure_torch(W)) pixellengths = torch.tensor([detector.pixel_size_y, detector.pixel_size_z]) d = torch.dot(detector_origin, detector_norm) / torch.dot(xray_propagation_direction, detector_norm) @@ -91,15 +103,14 @@ def render_detector_frame( rotation_vector = torch.einsum('ij,i->j', sample_orientation, sample_rotation_during_exposure) smeared_misorientation_tensors = torch.linalg.inv(torch.linalg.inv(self.misori_concentration_tensors) + torch.outer(rotation_vector, rotation_vector)) - #Figure out what reflections are in the bragg-condition + #Construct some crystal and geometry information. A = form_a_mat(self.phase.unit_cell) B = 2 * np.pi * np.linalg.inv(A).T # TODO Check if the 2 pi is conventional here - - # Generate structure-factors and + max_angle = detector._get_wrapping_cone(xray_propagation_direction, np.mean([0, 0, 0])) self.phase._setup_diffracting_planes(wavelength=wavelength, min_bragg_angle=0.0, max_bragg_angle=max_angle) #TODO Using private method + sg_obj = sg.sg(sgname=self.phase.sgname) - hkl_list = genhkl_base( self.phase.unit_cell, sg_obj.syscond, @@ -107,13 +118,13 @@ def render_detector_frame( sg_obj.crystal_system, sg_obj.Laue, ) - if self.phase.path_to_cif_file is None: structurefactors = torch.ones(hkl_list.shape[0]) else: self.phase._set_structure_factors(hkl_list) #TODO Using private method structurefactors = ensure_torch(np.sum(self.phase.structure_factors**2, axis=1)) + # Initialize lists of 2D gaussian splat parameters uv_corrds_list = [] splat_concentration_tensors_list = [] scalefactors_list = [] @@ -122,64 +133,70 @@ def render_detector_frame( for hkl, S in zip(hkl_list, structurefactors): # TODO reduce the number of symmetries evaluated for low-multiplicity peaks + symmetries = ensure_torch(self.phase.rot) n_symmetries = len(self.phase.rot) - - - # Quick test to discard reflections far from Bragg-condition + + # Cheap test to discard reflections far from Bragg-condition h = torch.tensor(B @ hkl) h_norm = torch.linalg.norm(h) theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) - p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains, self.orientaions, ensure_torch(self.phase.rot), h) + p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains, self.orientaions, symmetries, h) p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) dp = torch.einsum('i,gsi->gs', xray_propagation_direction, p_vectors) / p_vectors_norm does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * max_misorientation * torch.cos(theta_angle_unstrained)) - # Select the relevant reflections and flatten grain- and symetry-indexes. - orientations = torch.tile(self.orientaions[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + # Select the relevant reflections and flatten the grain- and symetry-indexes. misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] p_vectors = p_vectors[does_diffract] + shape_concentration_tensors = torch.tile(self.shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] # Do pole-figure part of the calculation - mean_scattering_directions, partiality, azim_direction, azim_width = self.get_diffraction_arcsegment( - orientations, p_vectors, misori_concentration_tensors, xray_propagation_direction, wavelength + mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( + p_vectors, + misori_concentration_tensors, + xray_propagation_direction, + wavelength, ) # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) - shape_concentration_tensors = torch.tile(self.shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] - detectorspace_grainshape_projections, projected_thicknes_scale_factor = self.splat_grainshapes( + detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( mean_scattering_directions, shape_concentration_tensors, W, pixellengths, ) - # Direct beam is pos + mean_scatt_dir * x, detector is det_ori dot det_norm = 0 + # This part involves propagation from sample to detector. unclear where it belongs + # -------------------------------------------------------------------------------- + # Ray-trace onto detector plane pos = torch.tile(self.positions[:, None, :], (1, n_symmetries, 1,))[does_diffract] ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions - uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) + uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes - #Do smearing due to angular divergence. - azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_direction, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_width[:, None] / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) + # Do smearing due to angular divergence + azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_directions, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_widths[:, None]\ + / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. - + # -------------------------------------------------------------------------------- + # Append to list uv_corrds_list.append(uv_coords) - scalefactors_list.append(S * projected_thicknes_scale_factor * partiality * intensity_spread_out_factor) + scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor) splat_concentration_tensors_list.append(detspace_splat_concentration) - f = self.rasterize_on_detector( + f = detector.render_gaussian_splats( torch.concat(uv_corrds_list), torch.concat(scalefactors_list), torch.concat(splat_concentration_tensors_list), - detector.shape, splat_max_size= (d * max_misorientation + max_grain_shape ) / torch.min(pixellengths) ) return f + def splat_grainshapes( self, mean_scattering_directions: Tensor, @@ -187,7 +204,28 @@ def splat_grainshapes( W: Tensor, pixellengths: Tensor, ): - + """ Project the laoratory space shape-concentration-tensors of a range of grains along a the scattering directions + into 2D detector pixels space. + + Parameters + ---------- + mean_scattering_directions : Tensor + Scattering direction unit vectors, shape ``(N, 3)`` + shape_concentration_tensors : Tensor + Shape concentration tensors in laboratory coordinates, shape ``(N, 3, 3)`` + W : Tensor + Pixel-direction unit vectors stacked, shape ``(2, 3)`` + pixellengths : Tensor + Pixel lengths, shape ``(2, 3)`` + + Returns + ------- + projected_shape_pixelunits : Tensor + Concentarion tensor of the projected grainshape in detector pixel units, shape ``(N, 2, 2)`` + projected_thicknes_scale_factors : Tensor + Intensity scaling factor due the projected thickness of the grain, shape ``(N,)`` + """ + # grain_volume = torch.sqrt(1/torch.linalg.det(shape_concentration_tensors)) dSd = torch.einsum('xi,xij,xj->x', mean_scattering_directions, shape_concentration_tensors, mean_scattering_directions) @@ -205,91 +243,10 @@ def splat_grainshapes( ) return projected_shape_pixelunits, 1/torch.sqrt(dSd) - - def rasterize_on_detector( - self, - uv_corrds: Tensor, - scale_factors: Tensor, - concentration_tensors: Tensor, - shape: tuple, - patch_size: int = 16, - splat_max_size: float = 50.0 # Todo, come pu with good rule here. - # d times max misorientation + max grainsize - ): - - - u, v = torch.meshgrid(torch.arange(shape[0]), torch.arange(shape[1])) - f = torch.zeros(shape) - - patch_size = 16 - n_patches_dim1 = (shape[0]-1)//patch_size+1 - n_patches_dim2 = (shape[1]-1)//patch_size+1 - - for patch_index_1 in range(n_patches_dim1): - for patch_index_2 in range(n_patches_dim2): - - patch_slice = (slice(patch_size*patch_index_1, patch_size*(patch_index_1+1)), - slice(patch_size*patch_index_2, patch_size*(patch_index_2+1)),) - - patch_mean_u = patch_size*(patch_index_1+0.5) - patch_mean_v = patch_size*(patch_index_2+0.5) - - include_index = (uv_corrds[:, 0]-patch_mean_u)**2 + (uv_corrds[:, 1]-patch_mean_v)**2 < splat_max_size**2 - if not torch.any(include_index): - continue - - - local_coords = torch.stack([u[patch_slice][None, :, :] - uv_corrds[include_index, 0, None, None], - v[patch_slice][None, :, :] - uv_corrds[include_index, 1, None, None], - ], axis=1) - - f[patch_slice] += torch.sum(scale_factors[include_index, None, None]\ - * torch.exp(- torch.einsum('xiuv,xij,xjuv->xuv' ,local_coords, concentration_tensors[include_index, :, :], local_coords)), axis=0) - - return f - - def get_diffraction_arcsegment(self, oris, p_vectors, misori_tensors, xray_propagation_direction, wavelength): - - # Splat onto poelfigure - p_norm = torch.linalg.norm(p_vectors, axis=-1) - D = torch.einsum('xi,xij,xj->x', p_vectors, misori_tensors, p_vectors)/ p_norm**2 - - inner_part = misori_tensors - torch.einsum( - 'xij,xj,xk,xkl->xil', - misori_tensors, - p_vectors, - p_vectors, - misori_tensors, - ) / D[:, None, None] / p_norm[:, None, None]**2 - T_proj = torch.einsum( - 'xj,ijk,xil,lmn,xm->xkn', - p_vectors, - levi_cita_symbol, - inner_part, - levi_cita_symbol, - p_vectors, - ) / p_norm[:, None, None]**2 - - # Compute point of "exact bragg condition" in the plane Span(k_0, p) - theta_angle = np.asin( p_norm * wavelength / 4 / np.pi ) - dir_scatteringplane_norm = (p_vectors - xray_propagation_direction[None, :] * np.einsum('xi,i->x', p_vectors, xray_propagation_direction)[:, None] ) - dir_scatteringplane_norm = dir_scatteringplane_norm / torch.linalg.norm(dir_scatteringplane_norm, axis=-1)[:, None] - q_0_unit = torch.cos(theta_angle)[:, None] * dir_scatteringplane_norm - torch.sin(theta_angle)[:, None] * xray_propagation_direction[None, :] - dir_scatteringplane_orth = torch.einsum('ijk,j,xk->xi', levi_cita_symbol, xray_propagation_direction, dir_scatteringplane_norm) - - # Compute partiality, mean direction, and azimthal spread - A = torch.einsum('xi,xij,xj->x', q_0_unit, T_proj, q_0_unit,) - B = torch.einsum('xi,xij,xj->x', q_0_unit, T_proj, dir_scatteringplane_orth,) - C = torch.einsum('xi,xij,xj->x', dir_scatteringplane_orth, T_proj, dir_scatteringplane_orth,) - - azim_divergence = np.sqrt(1 / C) * torch.sin(theta_angle) # TODO Check trigonometric function here - azim_offset = B / C - mean_scattering_directions = torch.cos(2*theta_angle)[:, None] * xray_propagation_direction[None, :]\ - + torch.sin(2*theta_angle)[:, None]*(torch.cos(azim_offset)[:, None]*dir_scatteringplane_norm + torch.sin(azim_offset)[:, None]*dir_scatteringplane_orth) - partiality = torch.exp(-A + B**2 / C) * 2 * torch.sqrt( torch.linalg.det(misori_tensors) / D ) / torch.sqrt(C) - - return mean_scattering_directions, partiality, dir_scatteringplane_orth, azim_divergence + # ------------------------------------------------------------------------------------------ + # The methods below here are for computing polefigures, not needed for diffraction patterns. + # ------------------------------------------------------------------------------------------ def render_polefigure( self, hkl: tuple[int], @@ -336,6 +293,15 @@ def splat_onto_polefigure( h = torch.tensor(B @ hkl) h = h / torch.linalg.norm(h) + levi_cita_symbol = np.zeros((3,3,3)) + levi_cita_symbol[0, 1, 2] = 1 + levi_cita_symbol[1, 2, 0] = 1 + levi_cita_symbol[2, 0, 1] = 1 + levi_cita_symbol[0, 2, 1] = -1 + levi_cita_symbol[1, 0, 2] = -1 + levi_cita_symbol[2, 1, 0] = -1 + levi_cita_symbol = torch.tensor(levi_cita_symbol) + # TODO reduce the number of symmetries evaluated for low-multiplicity peaks n_symmetries = len(self.phase.rot) diff --git a/xrd_simulator/laue.py b/xrd_simulator/laue.py index 9881447..8ab7d20 100644 --- a/xrd_simulator/laue.py +++ b/xrd_simulator/laue.py @@ -7,6 +7,7 @@ import numpy as np import torch +from torch import Tensor torch.set_default_dtype(torch.float64) from xrd_simulator.utils import ensure_torch @@ -155,6 +156,90 @@ def _find_solutions_to_tangens_half_angle_equation( return grains, planes, times, G +_levi_cita_symbol = np.zeros((3,3,3)) +_levi_cita_symbol[0, 1, 2] = 1 +_levi_cita_symbol[1, 2, 0] = 1 +_levi_cita_symbol[2, 0, 1] = 1 +_levi_cita_symbol[0, 2, 1] = -1 +_levi_cita_symbol[1, 0, 2] = -1 +_levi_cita_symbol[2, 1, 0] = -1 +_levi_cita_symbol = torch.tensor(_levi_cita_symbol) + + +def _get_diffraction_arcsegment( + p_vectors: Tensor, + T: Tensor, + xray_propagation_direction: Tensor, + wavelength: Tensor, + ): + """ Given a range of orientation-concentration-tensors and reflection-information, compute the propeties + of the scattered beam. + + Parameters + ---------- + p_vectors : Tensor + Lattice vectors in lattice reference frame (not hkl-tuples), shape ``(N, 3)`` + Uses the convention with + .. math:: |h| = 4 \pi\sin\theta / \lambda + T : Tensor + Lab-space orientation concentration tensors, shape ``(N, 3, 3)`` + xray_propagation_direction : Tensor + Incident x-ray propagation direction unit vector, shape ``(3,)`` + wavelength : float + + + Returns + ------- + mean_scattering_directions : Tensor + Propagation direction unit vectors of the center of the scattered beams, shape ``(N, 3,)`` + partialities : Tenor + Intensity of the scattered beams per unit-volume sample, shape ``(N,)`` + dir_scatteringplane_orth : Tensor + Dispersion direction unit vectors of the scattered beams, shape ``(N, 3,)`` + azimuthal_divergence : Tensor + Divergence of the scattered beams in radians, shape ``(N,)`` + """ + + # Splat onto poelfigure + p_norm = torch.linalg.norm(p_vectors, axis=-1) + D = torch.einsum('xi,xij,xj->x', p_vectors, T, p_vectors)/ p_norm**2 + + inner_part = T - torch.einsum( + 'xij,xj,xk,xkl->xil', + T, + p_vectors, + p_vectors, + T, + ) / D[:, None, None] / p_norm[:, None, None]**2 + T_proj = torch.einsum( + 'xj,ijk,xil,lmn,xm->xkn', + p_vectors, + _levi_cita_symbol, + inner_part, + _levi_cita_symbol, + p_vectors, + ) / p_norm[:, None, None]**2 + + # Compute point of "exact bragg condition" in the plane Span(k_0, p) + theta_angle = np.asin( p_norm * wavelength / 4 / np.pi ) + dir_scatteringplane_norm = (p_vectors - xray_propagation_direction[None, :] * np.einsum('xi,i->x', p_vectors, xray_propagation_direction)[:, None] ) + dir_scatteringplane_norm = dir_scatteringplane_norm / torch.linalg.norm(dir_scatteringplane_norm, axis=-1)[:, None] + q_0_unit = torch.cos(theta_angle)[:, None] * dir_scatteringplane_norm - torch.sin(theta_angle)[:, None] * xray_propagation_direction[None, :] + dir_scatteringplane_orth = torch.einsum('ijk,j,xk->xi', _levi_cita_symbol, xray_propagation_direction, dir_scatteringplane_norm) + + # Compute partiality, mean direction, and azimthal spread + A = torch.einsum('xi,xij,xj->x', q_0_unit, T_proj, q_0_unit,) + B = torch.einsum('xi,xij,xj->x', q_0_unit, T_proj, dir_scatteringplane_orth,) + C = torch.einsum('xi,xij,xj->x', dir_scatteringplane_orth, T_proj, dir_scatteringplane_orth,) + + azimuthal_divergence = np.sqrt(1 / C) * torch.sin( 2 * theta_angle ) + azim_offset = B / C + mean_scattering_directions = torch.cos(2*theta_angle)[:, None] * xray_propagation_direction[None, :]\ + + torch.sin(2*theta_angle)[:, None]*(torch.cos(azim_offset)[:, None]*dir_scatteringplane_norm + torch.sin(azim_offset)[:, None]*dir_scatteringplane_orth) + partialities = torch.exp(-A + B**2 / C) * 2 * torch.sqrt( torch.linalg.det(T) / D ) / torch.sqrt(C) + + return mean_scattering_directions, partialities, dir_scatteringplane_orth, azimuthal_divergence + # ============================================================================== # DEPRECATED METHODS - TO BE REMOVED IN FUTURE VERSION # ============================================================================== From c692df0099f40f17a4f35316806d57fcc7b592e2 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Sat, 16 May 2026 18:01:30 +0200 Subject: [PATCH 11/17] hotfix --- xrd_simulator/detector.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/xrd_simulator/detector.py b/xrd_simulator/detector.py index e41376a..66388ca 100644 --- a/xrd_simulator/detector.py +++ b/xrd_simulator/detector.py @@ -422,8 +422,6 @@ def render_gaussian_splats( Intensity scale factors, shape ``(N,)`` concentration_tensors : Tensor Shape concentration tensors, shape ``(N, 2, 2)`` - shape : tuple - Detector shape as a 2-length tuple patch_size : int For evaluation the detector is split into patches of shize `patch_size` by `patch_size`. splat_max_size : float @@ -432,7 +430,7 @@ def render_gaussian_splats( Returns ------- detector_image : Tensor - Detector image, shape ``shape``. + Detector image, shape ``self.shape``. """ shape = self.shape From c0aabd0491cbbd7ae2043b4ebad68901803cedee Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Sat, 16 May 2026 22:17:26 +0200 Subject: [PATCH 12/17] xfab uses warren's upper triangular B matrices... --- xrd_simulator/gaussian_crystal_model.py | 121 ++++++++++++------------ 1 file changed, 62 insertions(+), 59 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 4c2f4bb..ec0b0c3 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -9,7 +9,7 @@ from xrd_simulator.detector import Detector from xrd_simulator.laue import _get_diffraction_arcsegment -from xfab.tools import form_a_mat, genhkl_base +from xfab.tools import form_b_mat, genhkl_base from xfab import sg @@ -104,8 +104,9 @@ def render_detector_frame( smeared_misorientation_tensors = torch.linalg.inv(torch.linalg.inv(self.misori_concentration_tensors) + torch.outer(rotation_vector, rotation_vector)) #Construct some crystal and geometry information. - A = form_a_mat(self.phase.unit_cell) - B = 2 * np.pi * np.linalg.inv(A).T # TODO Check if the 2 pi is conventional here + # A = form_a_mat(self.phase.unit_cell) + # B = 2 * np.pi * np.linalg.inv(A).T # TODO Check if the 2 pi is conventional here + B = form_b_mat(self.phase.unit_cell) max_angle = detector._get_wrapping_cone(xray_propagation_direction, np.mean([0, 0, 0])) self.phase._setup_diffracting_planes(wavelength=wavelength, min_bragg_angle=0.0, max_bragg_angle=max_angle) #TODO Using private method @@ -131,61 +132,62 @@ def render_detector_frame( # Loop over reflection orders for hkl, S in zip(hkl_list, structurefactors): + for friedel_sign in [1, -1]: - # TODO reduce the number of symmetries evaluated for low-multiplicity peaks - symmetries = ensure_torch(self.phase.rot) - n_symmetries = len(self.phase.rot) - - # Cheap test to discard reflections far from Bragg-condition - h = torch.tensor(B @ hkl) - h_norm = torch.linalg.norm(h) - theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) - p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains, self.orientaions, symmetries, h) - p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) - dp = torch.einsum('i,gsi->gs', xray_propagation_direction, p_vectors) / p_vectors_norm - does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * max_misorientation * torch.cos(theta_angle_unstrained)) - - # Select the relevant reflections and flatten the grain- and symetry-indexes. - misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] - p_vectors = p_vectors[does_diffract] - shape_concentration_tensors = torch.tile(self.shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] - - # Do pole-figure part of the calculation - mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( - p_vectors, - misori_concentration_tensors, - xray_propagation_direction, - wavelength, - ) - - # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) - detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( - mean_scattering_directions, - shape_concentration_tensors, - W, - pixellengths, - ) - - # This part involves propagation from sample to detector. unclear where it belongs - # -------------------------------------------------------------------------------- - # Ray-trace onto detector plane - pos = torch.tile(self.positions[:, None, :], (1, n_symmetries, 1,))[does_diffract] - ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) - point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions - uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes - - # Do smearing due to angular divergence - azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_directions, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_widths[:, None]\ - / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) - azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) - detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) - intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. - # -------------------------------------------------------------------------------- - - # Append to list - uv_corrds_list.append(uv_coords) - scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor) - splat_concentration_tensors_list.append(detspace_splat_concentration) + # TODO reduce the number of symmetries evaluated for low-multiplicity peaks + symmetries = ensure_torch(self.phase.rot) + n_symmetries = len(self.phase.rot) + + # Cheap test to discard reflections far from Bragg-condition + h = friedel_sign * torch.tensor(B @ hkl) + h_norm = torch.linalg.norm(h) + theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) + p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains, self.orientaions, symmetries, h) + p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) + dp = torch.einsum('i,gsi->gs', xray_propagation_direction, p_vectors) / p_vectors_norm + does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * max_misorientation * torch.cos(theta_angle_unstrained)) + + # Select the relevant reflections and flatten the grain- and symetry-indexes. + misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + p_vectors = p_vectors[does_diffract] + shape_concentration_tensors = torch.tile(self.shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + + # Do pole-figure part of the calculation + mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( + p_vectors, + misori_concentration_tensors, + xray_propagation_direction, + wavelength, + ) + + # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) + detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( + mean_scattering_directions, + shape_concentration_tensors, + W, + pixellengths, + ) + + # This part involves propagation from sample to detector. unclear where it belongs + # -------------------------------------------------------------------------------- + # Ray-trace onto detector plane + pos = torch.tile(self.positions[:, None, :], (1, n_symmetries, 1,))[does_diffract] + ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) + point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions + uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes + + # Do smearing due to angular divergence + azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_directions, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_widths[:, None]\ + / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) + azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) + detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) + intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. + # -------------------------------------------------------------------------------- + + # Append to list + uv_corrds_list.append(uv_coords) + scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor) + splat_concentration_tensors_list.append(detspace_splat_concentration) f = detector.render_gaussian_splats( torch.concat(uv_corrds_list), @@ -288,8 +290,9 @@ def splat_onto_polefigure( ): - A = form_a_mat(self.phase.unit_cell) - B = 2 * np.pi * np.linalg.inv(A).T + # A = form_a_mat(self.phase.unit_cell) + # B = 2 * np.pi * np.linalg.inv(A).T + B = form_b_mat(self.phase.unit_cell) h = torch.tensor(B @ hkl) h = h / torch.linalg.norm(h) From 7fec52c1fbb6772c8fc620d7c4bc3a1bd4d6f29f Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Tue, 26 May 2026 16:16:20 +0200 Subject: [PATCH 13/17] Gaussian beam and intersection with gaussian grains --- xrd_simulator/beam.py | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/xrd_simulator/beam.py b/xrd_simulator/beam.py index 8724621..17b4b60 100644 --- a/xrd_simulator/beam.py +++ b/xrd_simulator/beam.py @@ -448,3 +448,79 @@ def _get_proximity_intervals( all_intersections.append(merged_intersection) return all_intersections + + +class GaussianBeam: + + def __init__( + self, + beam_centroid_position, + xray_propagation_direction, + wavelength, + long_axis_width=None, + long_axis_direction=None, + short_axis_width=None, + short_axis_direction=None, + ): + + # Convert to torch tensors first, then normalize using torch operations + self.xray_dir = ensure_torch(xray_propagation_direction) + self.xray_dir = self.xray_dir / torch.linalg.norm(self.xray_dir) + self.beam_center = ensure_torch(beam_centroid_position) + self.beam_center = self.beam_center - torch.dot(self.xray_dir, self.beam_center) * self.xray_dir + self.wavelength = wavelength + + # If no shape information is given, assume wide-field illumination + if long_axis_width is None: + self.beam_concentration_tensor = torch.zeros((3, 3,)) + self.max_width = np.inf + + # If only one width is given, assume circular beam. + elif long_axis_direction is None and short_axis_width is None and short_axis_direction is None: + + self.beam_concentration_tensor = (torch.eye(3) - torch.outer(self.xray_dir, self.xray_dir)) * 1 / long_axis_width**2 + self.max_width = long_axis_width + + # If all parameters are given, full 2D beamshape + else: + raise NotImplementedError + + def _intersect( + self, + positions, + shape_concentration_tensors, + rotation, + translation, + max_grain_size, + ): + + # Rotate beam by inverse sample rotation + xray_propagation_direction = torch.einsum('ij,i->j', rotation, self.xray_dir ) + beam_center = torch.einsum('ij,i->j', rotation, self.beam_center - translation ) #TODO Check how to compose translation and rotations + beam_concentration_tensor = torch.einsum('ij,ik,kl->il', rotation, self.beam_concentration_tensor, rotation) + + # Filter by distance to beam + a_minus_p = positions - beam_center[None, :] + proj_onto_line = torch.einsum('gi,i->g', a_minus_p, xray_propagation_direction)[:, None] * xray_propagation_direction[None, :] + distance_from_beam = torch.linalg.norm(a_minus_p - proj_onto_line, axis=1) + grains_hit_indexvector = distance_from_beam < 3 * (self.max_width + max_grain_size) + + # Compute the overlap of the beam with each grain + intersection_shape_concentration_tensors = beam_concentration_tensor[None, :, :] + shape_concentration_tensors[grains_hit_indexvector, :, :] + B_plus_S_inv = torch.linalg.inv(intersection_shape_concentration_tensors) + B_b_plus_S_c = torch.einsum('ij,j->i', beam_concentration_tensor, beam_center)[None, :]\ + + torch.einsum('gij,gj->gi', shape_concentration_tensors[grains_hit_indexvector, :, :], positions[grains_hit_indexvector, :]) + intersection_positions = torch.einsum('gij,gj->gi', B_plus_S_inv, B_b_plus_S_c) + + # Beam intensity term. + #TODO This can maybe be simplified. + bBb = torch.einsum('i,ij,j', beam_center, beam_concentration_tensor, beam_center) + cSc = torch.einsum( + 'gi,gij,gj->g', positions[grains_hit_indexvector, :], + shape_concentration_tensors[grains_hit_indexvector, :, :], + positions[grains_hit_indexvector, :] + ) + C = torch.einsum('gi,gij,gj->g', B_b_plus_S_c, B_plus_S_inv, B_b_plus_S_c) + beam_intensity_factors = torch.exp(C - bBb - cSc) + + return intersection_positions, intersection_shape_concentration_tensors, beam_intensity_factors, grains_hit_indexvector From 953b36d28f5374d609ea1e30f0885f7e55045301 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Tue, 26 May 2026 20:10:51 +0200 Subject: [PATCH 14/17] beam intersection implemented --- xrd_simulator/beam.py | 2 +- xrd_simulator/gaussian_crystal_model.py | 52 ++++++++++++++++--------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/xrd_simulator/beam.py b/xrd_simulator/beam.py index 17b4b60..dfb5317 100644 --- a/xrd_simulator/beam.py +++ b/xrd_simulator/beam.py @@ -497,7 +497,7 @@ def _intersect( # Rotate beam by inverse sample rotation xray_propagation_direction = torch.einsum('ij,i->j', rotation, self.xray_dir ) beam_center = torch.einsum('ij,i->j', rotation, self.beam_center - translation ) #TODO Check how to compose translation and rotations - beam_concentration_tensor = torch.einsum('ij,ik,kl->il', rotation, self.beam_concentration_tensor, rotation) + beam_concentration_tensor = torch.einsum('ij,ik,kl', rotation, self.beam_concentration_tensor, rotation) # Filter by distance to beam a_minus_p = positions - beam_center[None, :] diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index ec0b0c3..51b642c 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -8,6 +8,7 @@ from xrd_simulator.utils import ensure_torch from xrd_simulator.detector import Detector from xrd_simulator.laue import _get_diffraction_arcsegment +from xrd_simulator.beam import GaussianBeam from xfab.tools import form_b_mat, genhkl_base from xfab import sg @@ -52,11 +53,12 @@ def __init__(self, self.misorientation_tensor = misorientation_tensor self.strain_tensor = strain_tensor - class GaussianPolycrystal: def __init__(self, grain_list: list[GaussianGrainish], + max_grain_size: float = 1000.0, + max_misorientation: float = 0.1, ): phases_list = list(set([grain.phase for grain in grain_list])) @@ -64,6 +66,8 @@ def __init__(self, assert n_phases == 1 self.n_grains = len(grain_list) + self.max_grain_size = max_grain_size + self.max_misorientation = max_misorientation self.phase = phases_list[0] self.positions = torch.stack([ensure_torch(grain.position) for grain in grain_list]) @@ -76,18 +80,17 @@ def __init__(self, def render_detector_frame( self, + beam: GaussianBeam, detector: Detector, - xray_propagation_direction: npt.NDArray | Tensor, - wavelength: float, sample_orientation: npt.NDArray | Tensor = np.eye(3), + sample_translation: npt.NDArray | Tensor = np.zeros(3), sample_rotation_during_exposure: npt.NDArray | Tensor = np.zeros(3), - max_misorientation: float = 0.1, - max_grain_shape:float = 1000, ): + xray_propagation_direction = beam.xray_dir + wavelength = beam.wavelength sample_orientation = ensure_torch(sample_orientation) sample_rotation_during_exposure = ensure_torch(sample_rotation_during_exposure) - #Rotate detector and incident beam by inverse of sample-rotation. xray_propagation_direction = torch.einsum('ij,i->j', sample_orientation, ensure_torch(xray_propagation_direction) ) @@ -99,13 +102,22 @@ def render_detector_frame( pixellengths = torch.tensor([detector.pixel_size_y, detector.pixel_size_z]) d = torch.dot(detector_origin, detector_norm) / torch.dot(xray_propagation_direction, detector_norm) + #Compute intersection of grains and beam + intersection_pos, intersection_shape_concentration_tensors, beam_intensity_factors, grains_hit\ + = beam._intersect( + ensure_torch(self.positions), + ensure_torch(self.shape_concentration_tensors), + sample_orientation, + sample_translation, + self.max_grain_size, + ) + # Simulate sample-rotation by adding a rotation to the grain misorientation + sample_rotation_during_exposure = ensure_torch(sample_rotation_during_exposure) rotation_vector = torch.einsum('ij,i->j', sample_orientation, sample_rotation_during_exposure) - smeared_misorientation_tensors = torch.linalg.inv(torch.linalg.inv(self.misori_concentration_tensors) + torch.outer(rotation_vector, rotation_vector)) + smeared_misorientation_tensors = torch.linalg.inv(torch.linalg.inv(self.misori_concentration_tensors[grains_hit]) + torch.outer(rotation_vector, rotation_vector)) #Construct some crystal and geometry information. - # A = form_a_mat(self.phase.unit_cell) - # B = 2 * np.pi * np.linalg.inv(A).T # TODO Check if the 2 pi is conventional here B = form_b_mat(self.phase.unit_cell) max_angle = detector._get_wrapping_cone(xray_propagation_direction, np.mean([0, 0, 0])) @@ -134,7 +146,8 @@ def render_detector_frame( for hkl, S in zip(hkl_list, structurefactors): for friedel_sign in [1, -1]: - # TODO reduce the number of symmetries evaluated for low-multiplicity peaks + #TODO reduce the number of symmetries evaluated for low-multiplicity peaks + #TODO Check if structure factors include multiplicity symmetries = ensure_torch(self.phase.rot) n_symmetries = len(self.phase.rot) @@ -142,15 +155,15 @@ def render_detector_frame( h = friedel_sign * torch.tensor(B @ hkl) h_norm = torch.linalg.norm(h) theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) - p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains, self.orientaions, symmetries, h) + p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains[grains_hit], self.orientaions[grains_hit], symmetries, h) p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) dp = torch.einsum('i,gsi->gs', xray_propagation_direction, p_vectors) / p_vectors_norm - does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * max_misorientation * torch.cos(theta_angle_unstrained)) + does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) * torch.cos(theta_angle_unstrained)) # Select the relevant reflections and flatten the grain- and symetry-indexes. - misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[grains_hit, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] p_vectors = p_vectors[does_diffract] - shape_concentration_tensors = torch.tile(self.shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + shape_concentration_tensors = torch.tile(intersection_shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] # Do pole-figure part of the calculation mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( @@ -169,9 +182,9 @@ def render_detector_frame( ) # This part involves propagation from sample to detector. unclear where it belongs - # -------------------------------------------------------------------------------- + # ---------------------------------------------------------------------------------------------------------- # Ray-trace onto detector plane - pos = torch.tile(self.positions[:, None, :], (1, n_symmetries, 1,))[does_diffract] + pos = torch.tile(intersection_pos[:, None, :], (1, n_symmetries, 1,))[does_diffract] ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes @@ -182,18 +195,19 @@ def render_detector_frame( azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. - # -------------------------------------------------------------------------------- + # ---------------------------------------------------------------------------------------------------------- # Append to list uv_corrds_list.append(uv_coords) - scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor) + beam_intensity_factors_insideloop = torch.tile(beam_intensity_factors[:, None], (1, n_symmetries))[does_diffract] + scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor * beam_intensity_factors_insideloop) splat_concentration_tensors_list.append(detspace_splat_concentration) f = detector.render_gaussian_splats( torch.concat(uv_corrds_list), torch.concat(scalefactors_list), torch.concat(splat_concentration_tensors_list), - splat_max_size= (d * max_misorientation + max_grain_shape ) / torch.min(pixellengths) + splat_max_size= (d * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) + self.max_grain_size ) / torch.min(pixellengths) ) return f From 2a41b11d135e3bd0ab67de4941c76b94e175b9d7 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Sat, 30 May 2026 14:58:42 +0200 Subject: [PATCH 15/17] Rotate and translate sample --- xrd_simulator/detector.py | 6 +- xrd_simulator/gaussian_crystal_model.py | 73 +++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/xrd_simulator/detector.py b/xrd_simulator/detector.py index 66388ca..702f955 100644 --- a/xrd_simulator/detector.py +++ b/xrd_simulator/detector.py @@ -406,9 +406,8 @@ def render_gaussian_splats( uv_corrds: Tensor, scale_factors: Tensor, concentration_tensors: Tensor, - patch_size: int = 16, - splat_max_size: float = 50.0 # Todo, come pu with good rule here. - # d times max misorientation + max grainsize + patch_size: int = 64, + splat_max_size: float = 50.0, ): """ Basic 2D Gaussian rasterizer. Each gaussian has the expression: @@ -437,7 +436,6 @@ def render_gaussian_splats( u, v = torch.meshgrid(torch.arange(shape[0]), torch.arange(shape[1])) f = torch.zeros(shape) - patch_size = 16 n_patches_dim1 = (shape[0]-1)//patch_size+1 n_patches_dim2 = (shape[1]-1)//patch_size+1 diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 51b642c..bbc3db3 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -9,11 +9,14 @@ from xrd_simulator.detector import Detector from xrd_simulator.laue import _get_diffraction_arcsegment from xrd_simulator.beam import GaussianBeam +from xrd_simulator.motion import RigidBodyMotion from xfab.tools import form_b_mat, genhkl_base from xfab import sg +import time + class GaussianGrainish: """It's not a grain, it's a grain-ish. @@ -85,8 +88,12 @@ def render_detector_frame( sample_orientation: npt.NDArray | Tensor = np.eye(3), sample_translation: npt.NDArray | Tensor = np.zeros(3), sample_rotation_during_exposure: npt.NDArray | Tensor = np.zeros(3), + timing=False ): + if timing: + t0 = time.time() + xray_propagation_direction = beam.xray_dir wavelength = beam.wavelength sample_orientation = ensure_torch(sample_orientation) @@ -112,6 +119,7 @@ def render_detector_frame( self.max_grain_size, ) + # Simulate sample-rotation by adding a rotation to the grain misorientation sample_rotation_during_exposure = ensure_torch(sample_rotation_during_exposure) rotation_vector = torch.einsum('ij,i->j', sample_orientation, sample_rotation_during_exposure) @@ -137,6 +145,7 @@ def render_detector_frame( self.phase._set_structure_factors(hkl_list) #TODO Using private method structurefactors = ensure_torch(np.sum(self.phase.structure_factors**2, axis=1)) + # Initialize lists of 2D gaussian splat parameters uv_corrds_list = [] splat_concentration_tensors_list = [] @@ -161,10 +170,12 @@ def render_detector_frame( does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) * torch.cos(theta_angle_unstrained)) # Select the relevant reflections and flatten the grain- and symetry-indexes. - misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[grains_hit, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] p_vectors = p_vectors[does_diffract] shape_concentration_tensors = torch.tile(intersection_shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] + + # Do pole-figure part of the calculation mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( p_vectors, @@ -173,6 +184,8 @@ def render_detector_frame( wavelength, ) + + # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( mean_scattering_directions, @@ -203,6 +216,10 @@ def render_detector_frame( scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor * beam_intensity_factors_insideloop) splat_concentration_tensors_list.append(detspace_splat_concentration) + if timing: + print(f'Splatting took {time.time()-t0}') + t0 = time.time() + f = detector.render_gaussian_splats( torch.concat(uv_corrds_list), torch.concat(scalefactors_list), @@ -210,15 +227,19 @@ def render_detector_frame( splat_max_size= (d * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) + self.max_grain_size ) / torch.min(pixellengths) ) + if timing: + print(f'Rasterization took {time.time()-t0}') + t0 = time.time() + return f def splat_grainshapes( - self, - mean_scattering_directions: Tensor, - shape_concentration_tensors: Tensor, - W: Tensor, - pixellengths: Tensor, + self, + mean_scattering_directions: Tensor, + shape_concentration_tensors: Tensor, + W: Tensor, + pixellengths: Tensor, ): """ Project the laoratory space shape-concentration-tensors of a range of grains along a the scattering directions into 2D detector pixels space. @@ -260,6 +281,46 @@ def splat_grainshapes( return projected_shape_pixelunits, 1/torch.sqrt(dSd) + + def transform( + self, + rigid_body_motion : RigidBodyMotion, + time : float = 1.0, + ): + """Transform the polycrystal by performing a rigid body motion. + + This updates all the sample-information in-place. + + Parameters + ---------- + rigid_body_motion : RigidBodyMotion + Rigid body motion object describing the polycrystal transformation + as a function of time on the domain ``time=[0, 1]``. + time : float + Time between ``[0, 1]`` at which to call the rigid body motion. + """ + + # Get rotation matrix and translation vector. + Rot_mat = rigid_body_motion.rotator.get_rotation_matrix( + rigid_body_motion.rotation_angle * time + ) + translation_vector = rigid_body_motion.translation * time + + # Rotate vectors: + self.positions = torch.einsum('ij,gj->gi', Rot_mat, self.positions-self.origin[None,:])+self.origin[None,:] + + # Rotate compose rotations + self.orientaions = torch.einsum('ij,gjk->gik', Rot_mat, self.orientaions) + + #Rotate tensors + self.shape_concentration_tensors = torch.einsum('ij,gjk,lk ->gil', Rot_mat, self.shape_concentration_tensors, Rot_mat) + self.misori_concentration_tensors = torch.einsum('ij,gjk,lk ->gil', Rot_mat, self.misori_concentration_tensors, Rot_mat) + self.strains = torch.einsum('ij,gjk,lk ->gil', Rot_mat, self.strains, Rot_mat) + + #Translate + self.positions = self.positions + translation_vector[None, :] + + # ------------------------------------------------------------------------------------------ # The methods below here are for computing polefigures, not needed for diffraction patterns. # ------------------------------------------------------------------------------------------ From 9dca9da15897146c84c6ec73753f92178a8e1249 Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Sun, 31 May 2026 10:58:22 +0200 Subject: [PATCH 16/17] Use same symmetry handling as main workflow --- xrd_simulator/gaussian_crystal_model.py | 147 +++++++++++------------- xrd_simulator/polycrystal.py | 2 +- 2 files changed, 67 insertions(+), 82 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index bbc3db3..3b21369 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -119,7 +119,6 @@ def render_detector_frame( self.max_grain_size, ) - # Simulate sample-rotation by adding a rotation to the grain misorientation sample_rotation_during_exposure = ensure_torch(sample_rotation_during_exposure) rotation_vector = torch.einsum('ij,i->j', sample_orientation, sample_rotation_during_exposure) @@ -127,94 +126,80 @@ def render_detector_frame( #Construct some crystal and geometry information. B = form_b_mat(self.phase.unit_cell) - max_angle = detector._get_wrapping_cone(xray_propagation_direction, np.mean([0, 0, 0])) self.phase._setup_diffracting_planes(wavelength=wavelength, min_bragg_angle=0.0, max_bragg_angle=max_angle) #TODO Using private method - sg_obj = sg.sg(sgname=self.phase.sgname) - hkl_list = genhkl_base( - self.phase.unit_cell, - sg_obj.syscond, - 0.0, np.sin(max_angle) / wavelength, - sg_obj.crystal_system, - sg_obj.Laue, - ) - if self.phase.path_to_cif_file is None: - structurefactors = torch.ones(hkl_list.shape[0]) + # Get miller indicies and structure factors + miller_indices = self.phase.miller_indices + if self.phase.structure_factors is not None: + structure_factors = torch.sum( + ensure_torch(self.phase.structure_factors) ** 2, axis=1 + ) + miller_indices = miller_indices[structure_factors > 1e-6] + structure_factors = structure_factors[structure_factors > 1e-6] else: - self.phase._set_structure_factors(hkl_list) #TODO Using private method - structurefactors = ensure_torch(np.sum(self.phase.structure_factors**2, axis=1)) - - + # If no structure factors provided, use uniform intensity (all ones) + structure_factors = torch.ones(miller_indices.shape[0]) + # Initialize lists of 2D gaussian splat parameters uv_corrds_list = [] splat_concentration_tensors_list = [] scalefactors_list = [] - # Loop over reflection orders - for hkl, S in zip(hkl_list, structurefactors): - for friedel_sign in [1, -1]: - - #TODO reduce the number of symmetries evaluated for low-multiplicity peaks - #TODO Check if structure factors include multiplicity - symmetries = ensure_torch(self.phase.rot) - n_symmetries = len(self.phase.rot) - - # Cheap test to discard reflections far from Bragg-condition - h = friedel_sign * torch.tensor(B @ hkl) - h_norm = torch.linalg.norm(h) - theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) - p_vectors = torch.einsum('ghi,gij,sjk,k->gsh', torch.eye(3)[None,:,:] - self.strains[grains_hit], self.orientaions[grains_hit], symmetries, h) - p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) - dp = torch.einsum('i,gsi->gs', xray_propagation_direction, p_vectors) / p_vectors_norm - does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) * torch.cos(theta_angle_unstrained)) - - # Select the relevant reflections and flatten the grain- and symetry-indexes. - misori_concentration_tensors = torch.tile(smeared_misorientation_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] - p_vectors = p_vectors[does_diffract] - shape_concentration_tensors = torch.tile(intersection_shape_concentration_tensors[:, None, :, :], (1, n_symmetries, 1, 1))[does_diffract] - - - - # Do pole-figure part of the calculation - mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( - p_vectors, - misori_concentration_tensors, - xray_propagation_direction, - wavelength, - ) - - - - # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) - detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( - mean_scattering_directions, - shape_concentration_tensors, - W, - pixellengths, - ) - - # This part involves propagation from sample to detector. unclear where it belongs - # ---------------------------------------------------------------------------------------------------------- - # Ray-trace onto detector plane - pos = torch.tile(intersection_pos[:, None, :], (1, n_symmetries, 1,))[does_diffract] - ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) - point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions - uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes - - # Do smearing due to angular divergence - azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_directions, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_widths[:, None]\ - / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) - azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) - detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) - intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. - # ---------------------------------------------------------------------------------------------------------- - - # Append to list - uv_corrds_list.append(uv_coords) - beam_intensity_factors_insideloop = torch.tile(beam_intensity_factors[:, None], (1, n_symmetries))[does_diffract] - scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor * beam_intensity_factors_insideloop) - splat_concentration_tensors_list.append(detspace_splat_concentration) + # Loop over hkl tuples + for hkl, S in zip(miller_indices, structure_factors): + + # Cheap test to discard reflections far from Bragg-condition + h = torch.tensor(B @ hkl) + h_norm = torch.linalg.norm(h) + theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) + p_vectors = torch.einsum('ghi,gij,j->gh', torch.eye(3)[None,:,:] - self.strains[grains_hit], self.orientaions[grains_hit], h) + p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) + dp = torch.einsum('i,gi->g', xray_propagation_direction, p_vectors) / p_vectors_norm + does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) * torch.cos(theta_angle_unstrained)) + + # Select the relevant reflections and flatten the grain- and symetry-indexes. + misori_concentration_tensors = smeared_misorientation_tensors[does_diffract] + p_vectors = p_vectors[does_diffract] + shape_concentration_tensors = intersection_shape_concentration_tensors[does_diffract] + + # Do pole-figure part of the calculation + mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( + p_vectors, + misori_concentration_tensors, + xray_propagation_direction, + wavelength, + ) + + # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) + detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( + mean_scattering_directions, + shape_concentration_tensors, + W, + pixellengths, + ) + + # This part involves propagation from sample to detector. unclear where it belongs + # ---------------------------------------------------------------------------------------------------------- + # Ray-trace onto detector plane + pos = intersection_pos[does_diffract] + ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) + point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions + uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes + + # Do smearing due to angular divergence + azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_directions, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_widths[:, None]\ + / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) + azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) + detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) + intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. + # ---------------------------------------------------------------------------------------------------------- + + # Append to list + uv_corrds_list.append(uv_coords) + beam_intensity_factors_insideloop = beam_intensity_factors[does_diffract] + scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor * beam_intensity_factors_insideloop) + splat_concentration_tensors_list.append(detspace_splat_concentration) if timing: print(f'Splatting took {time.time()-t0}') @@ -224,7 +209,7 @@ def render_detector_frame( torch.concat(uv_corrds_list), torch.concat(scalefactors_list), torch.concat(splat_concentration_tensors_list), - splat_max_size= (d * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) + self.max_grain_size ) / torch.min(pixellengths) + splat_max_size= (d * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) + self.max_grain_size ) / torch.min(pixellengths) * 1.41 ) if timing: diff --git a/xrd_simulator/polycrystal.py b/xrd_simulator/polycrystal.py index 2458a2d..1ab0d40 100644 --- a/xrd_simulator/polycrystal.py +++ b/xrd_simulator/polycrystal.py @@ -280,7 +280,7 @@ def _compute_peaks(self, beam, rigid_body_motion, verbose=True): peaks = torch.empty( (0, 10) - ) # We create a dataframe to store all the relevant values for each individual reflection inr an organized manner + ) # We create a dataframe to store all the relevant values for each individual reflection in an organized manner # For each phase of the sample, we compute all reflections at once in a vectorized manner for i, phase in enumerate(phases): From 377908543ebf7dbf5d8954870b6412d3d811223e Mon Sep 17 00:00:00 2001 From: MACarlsen Date: Sun, 31 May 2026 15:19:37 +0200 Subject: [PATCH 17/17] vectorized hkl loop --- xrd_simulator/gaussian_crystal_model.py | 130 ++++++++++++------------ 1 file changed, 66 insertions(+), 64 deletions(-) diff --git a/xrd_simulator/gaussian_crystal_model.py b/xrd_simulator/gaussian_crystal_model.py index 3b21369..3d5865f 100644 --- a/xrd_simulator/gaussian_crystal_model.py +++ b/xrd_simulator/gaussian_crystal_model.py @@ -125,12 +125,12 @@ def render_detector_frame( smeared_misorientation_tensors = torch.linalg.inv(torch.linalg.inv(self.misori_concentration_tensors[grains_hit]) + torch.outer(rotation_vector, rotation_vector)) #Construct some crystal and geometry information. - B = form_b_mat(self.phase.unit_cell) + B = torch.Tensor(form_b_mat(self.phase.unit_cell)) max_angle = detector._get_wrapping_cone(xray_propagation_direction, np.mean([0, 0, 0])) self.phase._setup_diffracting_planes(wavelength=wavelength, min_bragg_angle=0.0, max_bragg_angle=max_angle) #TODO Using private method # Get miller indicies and structure factors - miller_indices = self.phase.miller_indices + miller_indices = torch.Tensor(self.phase.miller_indices) if self.phase.structure_factors is not None: structure_factors = torch.sum( ensure_torch(self.phase.structure_factors) ** 2, axis=1 @@ -141,74 +141,76 @@ def render_detector_frame( # If no structure factors provided, use uniform intensity (all ones) structure_factors = torch.ones(miller_indices.shape[0]) - # Initialize lists of 2D gaussian splat parameters - uv_corrds_list = [] - splat_concentration_tensors_list = [] - scalefactors_list = [] - - # Loop over hkl tuples - for hkl, S in zip(miller_indices, structure_factors): - - # Cheap test to discard reflections far from Bragg-condition - h = torch.tensor(B @ hkl) - h_norm = torch.linalg.norm(h) - theta_angle_unstrained = np.asin( h_norm * wavelength / 4 / np.pi ) - p_vectors = torch.einsum('ghi,gij,j->gh', torch.eye(3)[None,:,:] - self.strains[grains_hit], self.orientaions[grains_hit], h) - p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) - dp = torch.einsum('i,gi->g', xray_propagation_direction, p_vectors) / p_vectors_norm - does_diffract = torch.abs( dp + torch.sin(theta_angle_unstrained) ) < torch.abs(3 * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) * torch.cos(theta_angle_unstrained)) - - # Select the relevant reflections and flatten the grain- and symetry-indexes. - misori_concentration_tensors = smeared_misorientation_tensors[does_diffract] - p_vectors = p_vectors[does_diffract] - shape_concentration_tensors = intersection_shape_concentration_tensors[does_diffract] - - # Do pole-figure part of the calculation - mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( - p_vectors, - misori_concentration_tensors, - xray_propagation_direction, - wavelength, - ) + # Test to find the reflections close to the bragg condition + h = torch.einsum('ij,hj->hi', B, miller_indices) + p_vectors = torch.einsum('ghi,gij,kj->gkh', torch.eye(3)[None,:,:] - self.strains[grains_hit], self.orientaions[grains_hit], h) + p_vectors_norm = torch.linalg.norm(p_vectors, axis=-1) + theta_angle = torch.asin( p_vectors_norm * wavelength / 4 / np.pi ) + dp = torch.einsum('i,ghi->gh', xray_propagation_direction, p_vectors) / p_vectors_norm + does_diffract = torch.abs( dp + torch.sin(theta_angle) ) / torch.cos(theta_angle)\ + < torch.abs(6 * (self.max_misorientation + torch.linalg.norm(sample_rotation_during_exposure))) + grain_does_diffract, hkl_does_diffract = torch.where(does_diffract) + + # Select the relevant reflections and flatten the grain- and symetry-indexes. + misori_concentration_tensors = smeared_misorientation_tensors[grain_does_diffract] + p_vectors = p_vectors[does_diffract] + shape_concentration_tensors = intersection_shape_concentration_tensors[grain_does_diffract] - # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) - detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( - mean_scattering_directions, - shape_concentration_tensors, - W, - pixellengths, - ) + if timing: + print(f'Bragg-condition filterin took {time.time()-t0}') + t0 = time.time() + + # Do pole-figure part of the calculation + mean_scattering_directions, partialities, azim_directions, azim_widths = _get_diffraction_arcsegment( + p_vectors, + misori_concentration_tensors, + xray_propagation_direction, + wavelength, + ) + + if timing: + print(f'Reciprocal space part took {time.time()-t0}') + t0 = time.time() + + # Splat grain realspace shapes (Consider using the non-strained non-azimuthally shifted directions to simplify gradients later) + detectorspace_grainshape_projections, projected_thicknes_scale_factors = self.splat_grainshapes( + mean_scattering_directions, + shape_concentration_tensors, + W, + pixellengths, + ) + + if timing: + print(f'Realspace proj took {time.time()-t0}') + t0 = time.time() - # This part involves propagation from sample to detector. unclear where it belongs - # ---------------------------------------------------------------------------------------------------------- - # Ray-trace onto detector plane - pos = intersection_pos[does_diffract] - ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) - point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions - uv_coords = torch.einsum('xi, vi, v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes - - # Do smearing due to angular divergence - azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_directions, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_widths[:, None]\ - / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) - azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) - detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) - intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. - # ---------------------------------------------------------------------------------------------------------- - - # Append to list - uv_corrds_list.append(uv_coords) - beam_intensity_factors_insideloop = beam_intensity_factors[does_diffract] - scalefactors_list.append(S * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor * beam_intensity_factors_insideloop) - splat_concentration_tensors_list.append(detspace_splat_concentration) + # This part involves propagation from sample to detector. unclear where it belongs + # ---------------------------------------------------------------------------------------------------------- + # Ray-trace onto detector plane + pos = intersection_pos[grain_does_diffract] + ray_lengths = torch.einsum('xi,i->x', detector_origin[None, :] - pos, detector_norm) / torch.einsum('xi,i->x', mean_scattering_directions, detector_norm) + point_of_detector_intersection = pos + ray_lengths[:,None] * mean_scattering_directions + uv_coords = torch.einsum('xi,vi,v->xv',point_of_detector_intersection - detector_origin[None, :], W, 1/pixellengths) #TODO Tests with un-equal pixel sizes + + # Do smearing due to angular divergence + azimuthal_direction_uv = torch.einsum('xi,ui->xu', azim_directions, W) / pixellengths[None, :] * ray_lengths[:, None] * azim_widths[:, None]\ + / (1 - torch.einsum('xi,ui->xu', mean_scattering_directions, W)**2) + azimuthal_smearing_tensor = torch.einsum('xu,xv->xuv',azimuthal_direction_uv, azimuthal_direction_uv) + detspace_splat_concentration = torch.linalg.inv( torch.linalg.inv(detectorspace_grainshape_projections) + azimuthal_smearing_tensor) + intensity_spread_out_factor = torch.sqrt( torch.linalg.det(detspace_splat_concentration) / torch.linalg.det(detectorspace_grainshape_projections) ) #TODO This was (organically) vibe-coded. Check on paper if there is a simplification. + # ---------------------------------------------------------------------------------------------------------- + + scalefactors = structure_factors[hkl_does_diffract] * projected_thicknes_scale_factors * partialities * intensity_spread_out_factor * beam_intensity_factors[grain_does_diffract] + does_diffract = scalefactors > 1e-10 * torch.max(scalefactors) if timing: - print(f'Splatting took {time.time()-t0}') + print(f'Raytracing took {time.time()-t0}') t0 = time.time() f = detector.render_gaussian_splats( - torch.concat(uv_corrds_list), - torch.concat(scalefactors_list), - torch.concat(splat_concentration_tensors_list), + uv_coords[does_diffract], + scalefactors[does_diffract], + detspace_splat_concentration[does_diffract], splat_max_size= (d * (self.max_misorientation+torch.linalg.norm(sample_rotation_during_exposure)) + self.max_grain_size ) / torch.min(pixellengths) * 1.41 ) @@ -292,7 +294,7 @@ def transform( translation_vector = rigid_body_motion.translation * time # Rotate vectors: - self.positions = torch.einsum('ij,gj->gi', Rot_mat, self.positions-self.origin[None,:])+self.origin[None,:] + self.positions = torch.einsum('ij,gj->gi', Rot_mat, self.positions-rigid_body_motion.origin[None,:])+rigid_body_motion.origin[None,:] # Rotate compose rotations self.orientaions = torch.einsum('ij,gjk->gik', Rot_mat, self.orientaions)