Crystallography#

Anri’s crystallography is a set of plain functions on arrays. A structure with atoms is a Dans_Diffraction.Crystal, which also reads the CIF; everything else needs only the lattice parameters and the space-group number.

[1]:
import anri.utils
anri.utils.setup()  # before any JAX computation: don't grab most of the GPU memory at start-up

import Dans_Diffraction
import numpy as np

import anri.crystal
[2]:
xtl = Dans_Diffraction.Crystal("../../../tests/data/cif/Fe.cif")
lpars = anri.crystal.lattice_parameters(xtl)
sg = anri.crystal.space_group(xtl)
lpars, sg
[2]:
(array([ 2.8630355,  2.8630355,  2.8630355, 90.       , 90.       ,
        90.       ]),
 229)

The B matrix (with \(g = U B h\), no \(2\pi\)). B_matrix evaluates the JAX definition, anri.crystal.lpars_to_B, in float64, so there is one definition that can also be jitted and differentiated:

[3]:
B = anri.crystal.B_matrix(lpars)
B
[3]:
array([[ 3.49279637e-01, -5.61684495e-17, -5.61684495e-17],
       [ 0.00000000e+00,  3.49279637e-01, -2.13872095e-17],
       [ 0.00000000e+00,  0.00000000e+00,  3.49279637e-01]])

Now the reflections up to a \(d^*\) limit, with the space group’s systematic absences removed. The wavelength gives their \(2\theta\), and caps \(d^*\) at \(2/\lambda\). They come sorted by \(d^*\):

[4]:
dsmax = 1.0
wavelength = 0.18
refl = anri.crystal.reflections(lpars, sg, wavelength, dsmax)
refl["hkl"][:8], refl["ds"][:8], refl["tth"][:8]
[4]:
(array([[-1, -1,  0],
        [-1,  0, -1],
        [-1,  0,  1],
        [-1,  1,  0],
        [ 0, -1, -1],
        [ 0, -1,  1],
        [ 0,  1, -1],
        [ 0,  1,  1]]),
 array([0.493956, 0.493956, 0.493956, 0.493956, 0.493956, 0.493956,
        0.493956, 0.493956]),
 array([5.09596643, 5.09596643, 5.09596643, 5.09596643, 5.09596643,
        5.09596643, 5.09596643, 5.09596643]))

Grouping them into rings of equal \(d^*\) gives each reflection a ring index, and each ring its \(d^*\); counting the members gives the multiplicities:

[5]:
ring, ring_ds = anri.crystal.rings(refl["ds"], tol=1e-4)
ring_tth = np.degrees(2 * np.arcsin(ring_ds * wavelength / 2))
ring_mult = np.bincount(ring)
ring_ds, ring_tth, ring_mult
[5]:
(array([0.493956  , 0.69855927, 0.85555689, 0.987912  ]),
 array([ 5.09596643,  7.20916425,  8.83230631, 10.20204589]),
 array([12,  6, 24, 12]))
[6]:
refl["hkl"][ring == 1]  # the {200} ring
[6]:
array([[-2,  0,  0],
       [ 0, -2,  0],
       [ 0,  0, -2],
       [ 0,  0,  2],
       [ 0,  2,  0],
       [ 2,  0,  0]])

Because we have a structure with atoms, we can compute \(|F|^2\) for each reflection, with Debye-Waller factors and anomalous dispersion at this wavelength. This is where reflections that the space group allows but the atom positions extinguish (e.g. {222} in diamond) show up, as \(|F|^2 \approx 0\):

[7]:
F2 = anri.crystal.structure_factors(xtl, refl["hkl"], wavelength)
F2_ring = np.array([F2[ring == r].mean() for r in range(len(ring_ds))])
F2_ring
/tmp/ipykernel_4785/689639548.py:1: UserWarning: No isotropic thermal factors (U_iso or B_iso) for Fe: intensities have no Debye-Waller attenuation.
  F2 = anri.crystal.structure_factors(xtl, refl["hkl"], wavelength)
[7]:
array([1393.51724348,  955.07679029,  710.99911354,  560.11383862])

The space group’s operations, as 4 x 4 matrices on fractional coordinates, and the rotations of its Laue group in the Cartesian crystal frame (for misorientations and orientation grids):

[8]:
S = anri.crystal.symmetry_matrices(sg)
ops = anri.crystal.laue_rotations(S, B)
S.shape, ops.shape
[8]:
((96, 4, 4), (24, 3, 3))

We can confirm the rings against ImageD11 too:

[9]:
from ImageD11.unitcell import unitcell

uc = unitcell(lpars, sg)
uc.makerings(dsmax, tol=1e-4)
print(np.abs(ring_ds - np.array(uc.ringds)).max())
print(all(sorted(map(tuple, refl["hkl"][ring == i].tolist())) == sorted(uc.ringhkls[ds]) for i, ds in enumerate(uc.ringds)))
2.220446049250313e-16
True