braggcalculator 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- braggcalculator/__init__.py +5 -0
- braggcalculator/_version.py +1 -0
- braggcalculator/backends/__init__.py +8 -0
- braggcalculator/backends/numpy_backend.py +93 -0
- braggcalculator/backends/torch_backend.py +98 -0
- braggcalculator/core.py +341 -0
- braggcalculator/factors.py +124 -0
- braggcalculator/hkl.py +119 -0
- braggcalculator/io.py +35 -0
- braggcalculator/profiles.py +52 -0
- braggcalculator/py.typed +1 -0
- braggcalculator/renderer.py +45 -0
- braggcalculator/results.py +29 -0
- braggcalculator/structure_factor.py +108 -0
- braggcalculator/symmetry.py +110 -0
- braggcalculator-0.1.0.dist-info/METADATA +207 -0
- braggcalculator-0.1.0.dist-info/RECORD +19 -0
- braggcalculator-0.1.0.dist-info/WHEEL +4 -0
- braggcalculator-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NumpyBackend:
|
|
5
|
+
"""Small numerical interface shared by the diffraction kernels.
|
|
6
|
+
|
|
7
|
+
Double precision is intentional. Systematic absences are cancellations of
|
|
8
|
+
complex-valued sums and single precision produces visible ghost peaks for
|
|
9
|
+
larger cells.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
complex64 = np.complex64
|
|
13
|
+
complex128 = np.complex128
|
|
14
|
+
float32 = np.float32
|
|
15
|
+
float64 = np.float64
|
|
16
|
+
int64 = np.int64
|
|
17
|
+
bool = np.bool_
|
|
18
|
+
is_torch = False
|
|
19
|
+
|
|
20
|
+
def __init__(self, dtype=np.float64):
|
|
21
|
+
self.dtype = dtype
|
|
22
|
+
|
|
23
|
+
def asarray(self, x, dtype=None):
|
|
24
|
+
return np.asarray(x, dtype=dtype)
|
|
25
|
+
|
|
26
|
+
def zeros(self, shape, dtype=None):
|
|
27
|
+
return np.zeros(shape, dtype=dtype)
|
|
28
|
+
|
|
29
|
+
def ones(self, shape, dtype=None):
|
|
30
|
+
return np.ones(shape, dtype=dtype)
|
|
31
|
+
|
|
32
|
+
def exp(self, x):
|
|
33
|
+
return np.exp(x)
|
|
34
|
+
|
|
35
|
+
def pi(self):
|
|
36
|
+
return np.pi
|
|
37
|
+
|
|
38
|
+
def sin(self, x):
|
|
39
|
+
return np.sin(x)
|
|
40
|
+
|
|
41
|
+
def cos(self, x):
|
|
42
|
+
return np.cos(x)
|
|
43
|
+
|
|
44
|
+
def arcsin(self, x):
|
|
45
|
+
return np.arcsin(x)
|
|
46
|
+
|
|
47
|
+
def sqrt(self, x):
|
|
48
|
+
return np.sqrt(x)
|
|
49
|
+
|
|
50
|
+
def clip(self, x, a, b):
|
|
51
|
+
return np.clip(x, a, b)
|
|
52
|
+
|
|
53
|
+
def inverse(self, x):
|
|
54
|
+
return np.linalg.inv(x)
|
|
55
|
+
|
|
56
|
+
def einsum(self, s, *ops):
|
|
57
|
+
return np.einsum(s, *ops)
|
|
58
|
+
|
|
59
|
+
def matmul(self, a, b):
|
|
60
|
+
return np.matmul(a, b)
|
|
61
|
+
|
|
62
|
+
def linspace(self, a, b, n):
|
|
63
|
+
return np.linspace(a, b, n, dtype=self.dtype)
|
|
64
|
+
|
|
65
|
+
def concat(self, xs, axis=0):
|
|
66
|
+
return np.concatenate(xs, axis=axis)
|
|
67
|
+
|
|
68
|
+
def conj(self, x):
|
|
69
|
+
return np.conj(x)
|
|
70
|
+
|
|
71
|
+
def real(self, x):
|
|
72
|
+
return np.real(x)
|
|
73
|
+
|
|
74
|
+
def sum(self, x, axis=None):
|
|
75
|
+
return np.sum(x, axis=axis)
|
|
76
|
+
|
|
77
|
+
def max(self, x):
|
|
78
|
+
return np.max(x)
|
|
79
|
+
|
|
80
|
+
def scatter_sum(self, values, indices, size):
|
|
81
|
+
result = np.zeros(size, dtype=values.dtype)
|
|
82
|
+
np.add.at(result, np.asarray(indices, dtype=np.int64), values)
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
def degrees(self, x):
|
|
86
|
+
return np.degrees(x)
|
|
87
|
+
|
|
88
|
+
def q_from_two_theta(self, two_theta, wavelength):
|
|
89
|
+
return 4.0 * np.pi * np.sin(two_theta / 2.0) / wavelength
|
|
90
|
+
|
|
91
|
+
def two_theta_from_q(self, q, wavelength):
|
|
92
|
+
theta = np.arcsin(np.clip(q * wavelength / (4.0 * np.pi), -1.0, 1.0))
|
|
93
|
+
return 2.0 * theta
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TorchBackend:
|
|
5
|
+
"""PyTorch backend for BraggCalculator.
|
|
6
|
+
|
|
7
|
+
Provides the same API as NumpyBackend but backed by torch tensors,
|
|
8
|
+
so operations are GPU-accelerated and autograd-compatible.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
complex64 = torch.complex64
|
|
12
|
+
complex128 = torch.complex128
|
|
13
|
+
float32 = torch.float32
|
|
14
|
+
float64 = torch.float64
|
|
15
|
+
int64 = torch.int64
|
|
16
|
+
bool = torch.bool
|
|
17
|
+
is_torch = True
|
|
18
|
+
|
|
19
|
+
def __init__(self, device: str = "cpu", dtype=torch.float64):
|
|
20
|
+
self.device = torch.device(device)
|
|
21
|
+
self.dtype = dtype
|
|
22
|
+
|
|
23
|
+
def asarray(self, x, dtype=None):
|
|
24
|
+
if isinstance(x, torch.Tensor):
|
|
25
|
+
return x.to(device=self.device, dtype=dtype) if dtype else x.to(self.device)
|
|
26
|
+
return torch.tensor(x, device=self.device, dtype=dtype)
|
|
27
|
+
|
|
28
|
+
def zeros(self, shape, dtype=None):
|
|
29
|
+
return torch.zeros(shape, device=self.device, dtype=dtype)
|
|
30
|
+
|
|
31
|
+
def ones(self, shape, dtype=None):
|
|
32
|
+
return torch.ones(shape, device=self.device, dtype=dtype)
|
|
33
|
+
|
|
34
|
+
def exp(self, x):
|
|
35
|
+
return torch.exp(x)
|
|
36
|
+
|
|
37
|
+
def pi(self):
|
|
38
|
+
return torch.pi
|
|
39
|
+
|
|
40
|
+
def sin(self, x):
|
|
41
|
+
return torch.sin(x)
|
|
42
|
+
|
|
43
|
+
def cos(self, x):
|
|
44
|
+
return torch.cos(x)
|
|
45
|
+
|
|
46
|
+
def arcsin(self, x):
|
|
47
|
+
return torch.arcsin(x)
|
|
48
|
+
|
|
49
|
+
def sqrt(self, x):
|
|
50
|
+
return torch.sqrt(x)
|
|
51
|
+
|
|
52
|
+
def clip(self, x, a, b):
|
|
53
|
+
return torch.clamp(x, a, b)
|
|
54
|
+
|
|
55
|
+
def inverse(self, x):
|
|
56
|
+
return torch.linalg.inv(x)
|
|
57
|
+
|
|
58
|
+
def abs(self, x):
|
|
59
|
+
return torch.abs(x)
|
|
60
|
+
|
|
61
|
+
def real(self, x):
|
|
62
|
+
return torch.real(x)
|
|
63
|
+
|
|
64
|
+
def conj(self, x):
|
|
65
|
+
return torch.conj(x)
|
|
66
|
+
|
|
67
|
+
def einsum(self, s, *ops):
|
|
68
|
+
return torch.einsum(s, *ops)
|
|
69
|
+
|
|
70
|
+
def matmul(self, a, b):
|
|
71
|
+
return torch.matmul(a, b)
|
|
72
|
+
|
|
73
|
+
def linspace(self, a, b, n):
|
|
74
|
+
return torch.linspace(a, b, steps=n, device=self.device)
|
|
75
|
+
|
|
76
|
+
def concat(self, xs, axis=0):
|
|
77
|
+
return torch.cat(xs, dim=axis)
|
|
78
|
+
|
|
79
|
+
def sum(self, x, axis=None):
|
|
80
|
+
return torch.sum(x, dim=axis)
|
|
81
|
+
|
|
82
|
+
def max(self, x):
|
|
83
|
+
return torch.max(x)
|
|
84
|
+
|
|
85
|
+
def scatter_sum(self, values, indices, size):
|
|
86
|
+
index = self.asarray(indices, dtype=torch.int64)
|
|
87
|
+
result = torch.zeros(size, device=self.device, dtype=values.dtype)
|
|
88
|
+
return result.scatter_add(0, index, values)
|
|
89
|
+
|
|
90
|
+
def degrees(self, x):
|
|
91
|
+
return torch.rad2deg(x)
|
|
92
|
+
|
|
93
|
+
def q_from_two_theta(self, two_theta, wavelength):
|
|
94
|
+
return 4.0 * torch.pi * torch.sin(two_theta / 2.0) / wavelength
|
|
95
|
+
|
|
96
|
+
def two_theta_from_q(self, q, wavelength):
|
|
97
|
+
theta = torch.arcsin(torch.clamp(q * wavelength / (4.0 * torch.pi), -1.0, 1.0))
|
|
98
|
+
return 2.0 * theta
|
braggcalculator/core.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Public Bragg diffraction calculator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Iterable, Literal, Mapping
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .backends.numpy_backend import NumpyBackend
|
|
11
|
+
from .factors import resolve_wavelength
|
|
12
|
+
from .hkl import HKLEnumerator
|
|
13
|
+
from .io import to_pmg_structure
|
|
14
|
+
from .profiles import GaussianProfile, GaussianProfileQ
|
|
15
|
+
from .results import ReflectionTable
|
|
16
|
+
from .renderer import apply_lp_and_multiplicity, render_profile, render_profile_q
|
|
17
|
+
from .structure_factor import compute_F2, reflection_geometry
|
|
18
|
+
from .symmetry import SymmetryEngine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ParameterDict = Mapping[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BraggCalculator:
|
|
26
|
+
"""Calculate ideal kinematic X-ray or neutron powder diffraction.
|
|
27
|
+
|
|
28
|
+
Symmetry detection and HKL enumeration happen in :meth:`load` and form a
|
|
29
|
+
fixed, discrete topology. With a Torch backend, tensors supplied through
|
|
30
|
+
``parameters=`` remain differentiable inside that topology.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
mode: Literal["xray", "neutron"] = "xray"
|
|
34
|
+
wavelength: float | str = 1.5406
|
|
35
|
+
two_theta_range: tuple[float, float] = (10.0, 80.0)
|
|
36
|
+
two_theta_step: float = 0.01
|
|
37
|
+
q_range: tuple[float, float] = (0.0, 10.0)
|
|
38
|
+
q_step: float = 0.005
|
|
39
|
+
qmax: float | None = None
|
|
40
|
+
profile: Any = field(default_factory=GaussianProfile)
|
|
41
|
+
profile_q: Any = field(default_factory=GaussianProfileQ)
|
|
42
|
+
backend: Any = field(default_factory=NumpyBackend)
|
|
43
|
+
symprec: float = 1e-3
|
|
44
|
+
angle_tolerance: float = 5.0
|
|
45
|
+
primitive: bool = True
|
|
46
|
+
debye_waller_factors: Mapping[str, float] = field(default_factory=dict)
|
|
47
|
+
neutron_scattering_lengths: Mapping[str | int, float | str] = field(default_factory=dict)
|
|
48
|
+
intensity_tolerance: float = 1e-5
|
|
49
|
+
phase_chunk_entries: int = 4_194_304
|
|
50
|
+
|
|
51
|
+
_pmg_structure: Any = field(default=None, init=False, repr=False)
|
|
52
|
+
_symm: dict[str, Any] = field(default_factory=dict, init=False, repr=False)
|
|
53
|
+
_hkl: dict[str, Any] = field(default_factory=dict, init=False, repr=False)
|
|
54
|
+
|
|
55
|
+
def __post_init__(self):
|
|
56
|
+
if self.mode not in {"xray", "neutron"}:
|
|
57
|
+
raise ValueError("mode must be 'xray' or 'neutron'")
|
|
58
|
+
self.wavelength = resolve_wavelength(self.wavelength)
|
|
59
|
+
self._validate_range("two_theta_range", self.two_theta_range, lower=0.0, upper=180.0)
|
|
60
|
+
self._validate_range("q_range", self.q_range, lower=0.0)
|
|
61
|
+
for name, value in (
|
|
62
|
+
("two_theta_step", self.two_theta_step),
|
|
63
|
+
("q_step", self.q_step),
|
|
64
|
+
("phase_chunk_entries", self.phase_chunk_entries),
|
|
65
|
+
):
|
|
66
|
+
if not np.isfinite(value) or value <= 0:
|
|
67
|
+
raise ValueError(f"{name} must be positive and finite")
|
|
68
|
+
if self.intensity_tolerance < 0 or not np.isfinite(self.intensity_tolerance):
|
|
69
|
+
raise ValueError("intensity_tolerance must be non-negative and finite")
|
|
70
|
+
if not np.isfinite(self.symprec) or self.symprec <= 0:
|
|
71
|
+
raise ValueError("symprec must be positive and finite")
|
|
72
|
+
if not np.isfinite(self.angle_tolerance) or self.angle_tolerance <= 0:
|
|
73
|
+
raise ValueError("angle_tolerance must be positive and finite")
|
|
74
|
+
if int(self.phase_chunk_entries) != self.phase_chunk_entries:
|
|
75
|
+
raise ValueError("phase_chunk_entries must be an integer")
|
|
76
|
+
self.phase_chunk_entries = int(self.phase_chunk_entries)
|
|
77
|
+
|
|
78
|
+
physical_qmax = np.nextafter(4.0 * np.pi / self.wavelength, 0.0)
|
|
79
|
+
angle_qmax = (
|
|
80
|
+
4.0 * np.pi * np.sin(np.radians(self.two_theta_range[1]) / 2.0) / self.wavelength
|
|
81
|
+
)
|
|
82
|
+
required_qmax = min(
|
|
83
|
+
max(angle_qmax, float(self.q_range[1])),
|
|
84
|
+
physical_qmax,
|
|
85
|
+
)
|
|
86
|
+
if self.qmax is None:
|
|
87
|
+
self.qmax = required_qmax
|
|
88
|
+
elif not np.isfinite(self.qmax) or self.qmax <= 0:
|
|
89
|
+
raise ValueError("qmax must be positive and finite")
|
|
90
|
+
elif self.qmax < required_qmax - 64 * np.finfo(float).eps:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"qmax={self.qmax} does not cover the configured output ranges; "
|
|
93
|
+
f"use at least {required_qmax:.8g} inverse angstroms"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _validate_range(name, values, *, lower=None, upper=None):
|
|
98
|
+
if len(values) != 2:
|
|
99
|
+
raise ValueError(f"{name} must contain two values")
|
|
100
|
+
start, stop = map(float, values)
|
|
101
|
+
if not np.isfinite(start) or not np.isfinite(stop) or start >= stop:
|
|
102
|
+
raise ValueError(f"{name} must be a finite increasing pair")
|
|
103
|
+
if lower is not None and start < lower:
|
|
104
|
+
raise ValueError(f"{name} cannot start below {lower}")
|
|
105
|
+
if upper is not None and stop > upper:
|
|
106
|
+
raise ValueError(f"{name} cannot end above {upper}")
|
|
107
|
+
|
|
108
|
+
def load(self, structure_like: Any) -> "BraggCalculator":
|
|
109
|
+
"""Load a CIF path, pymatgen Structure, or optional ASE Atoms object."""
|
|
110
|
+
self._pmg_structure = to_pmg_structure(structure_like)
|
|
111
|
+
engine = SymmetryEngine(
|
|
112
|
+
symprec=self.symprec,
|
|
113
|
+
angle_tolerance=self.angle_tolerance,
|
|
114
|
+
primitive=self.primitive,
|
|
115
|
+
)
|
|
116
|
+
self._symm = engine.reduce(self._pmg_structure)
|
|
117
|
+
|
|
118
|
+
if self.debye_waller_factors:
|
|
119
|
+
b_values = self._symm["B"].copy()
|
|
120
|
+
for index, symbol in enumerate(self._symm["symbols"]):
|
|
121
|
+
if symbol in self.debye_waller_factors:
|
|
122
|
+
value = float(self.debye_waller_factors[symbol])
|
|
123
|
+
if value < 0 or not np.isfinite(value):
|
|
124
|
+
raise ValueError(f"invalid Debye-Waller B value for {symbol}: {value}")
|
|
125
|
+
b_values[index] = value
|
|
126
|
+
self._symm["B"] = b_values
|
|
127
|
+
|
|
128
|
+
self._hkl = HKLEnumerator(self.wavelength, self.qmax).enumerate(
|
|
129
|
+
self._symm["lattice"], self._symm["pointgroup_symbol"]
|
|
130
|
+
)
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def _ensure_loaded(self):
|
|
134
|
+
if not self._hkl:
|
|
135
|
+
raise RuntimeError("load a structure before calculating diffraction")
|
|
136
|
+
|
|
137
|
+
def tensor_parameters(
|
|
138
|
+
self,
|
|
139
|
+
requires_grad: bool | Iterable[str] = False,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Return backend arrays suitable for the ``parameters=`` argument.
|
|
142
|
+
|
|
143
|
+
Valid differentiable names are ``lattice``, ``frac_coords``,
|
|
144
|
+
``occupancies`` and ``b_iso``. Species and the HKL list are discrete.
|
|
145
|
+
"""
|
|
146
|
+
self._ensure_loaded()
|
|
147
|
+
names = {"lattice", "frac_coords", "occupancies", "b_iso"}
|
|
148
|
+
if isinstance(requires_grad, bool):
|
|
149
|
+
grad_names = names if requires_grad else set()
|
|
150
|
+
else:
|
|
151
|
+
grad_names = set(requires_grad)
|
|
152
|
+
unknown = grad_names - names
|
|
153
|
+
if unknown:
|
|
154
|
+
raise ValueError(f"unknown differentiable parameters: {sorted(unknown)}")
|
|
155
|
+
|
|
156
|
+
values = {
|
|
157
|
+
"lattice": self._symm["lattice"],
|
|
158
|
+
"frac_coords": self._symm["frac_coords"],
|
|
159
|
+
"occupancies": self._symm["occ"],
|
|
160
|
+
"b_iso": self._symm["B"],
|
|
161
|
+
}
|
|
162
|
+
result = {}
|
|
163
|
+
for name, value in values.items():
|
|
164
|
+
array = self.backend.asarray(value, dtype=self.backend.dtype)
|
|
165
|
+
if getattr(self.backend, "is_torch", False):
|
|
166
|
+
array = array.clone().detach().requires_grad_(name in grad_names)
|
|
167
|
+
result[name] = array
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
def _parameter_values(self, parameters: ParameterDict | None):
|
|
171
|
+
parameters = {} if parameters is None else parameters
|
|
172
|
+
allowed = {"lattice", "frac_coords", "occupancies", "b_iso"}
|
|
173
|
+
unknown = set(parameters) - allowed
|
|
174
|
+
if unknown:
|
|
175
|
+
raise ValueError(f"unknown parameters: {sorted(unknown)}")
|
|
176
|
+
values = (
|
|
177
|
+
parameters.get("lattice", self._symm["lattice"]),
|
|
178
|
+
parameters.get("frac_coords", self._symm["frac_coords"]),
|
|
179
|
+
parameters.get("occupancies", self._symm["occ"]),
|
|
180
|
+
parameters.get("b_iso", self._symm["B"]),
|
|
181
|
+
)
|
|
182
|
+
lattice, frac, occ, b_iso = values
|
|
183
|
+
atom_count = len(self._symm["Z"])
|
|
184
|
+
expected = ((3, 3), (atom_count, 3), (atom_count,), (atom_count,))
|
|
185
|
+
names = ("lattice", "frac_coords", "occupancies", "b_iso")
|
|
186
|
+
for name, value, shape in zip(names, values, expected):
|
|
187
|
+
if tuple(value.shape) != shape:
|
|
188
|
+
raise ValueError(f"{name} must have shape {shape}, got {tuple(value.shape)}")
|
|
189
|
+
return lattice, frac, occ, b_iso
|
|
190
|
+
|
|
191
|
+
def _domain_indices(self, domain: Literal["two_theta", "q"]):
|
|
192
|
+
if domain == "two_theta":
|
|
193
|
+
nominal = np.degrees(self._hkl["two_theta"])
|
|
194
|
+
lower, upper = self.two_theta_range
|
|
195
|
+
elif domain == "q":
|
|
196
|
+
nominal = self._hkl["q"]
|
|
197
|
+
lower, upper = self.q_range
|
|
198
|
+
else:
|
|
199
|
+
raise ValueError("domain must be 'two_theta' or 'q'")
|
|
200
|
+
return np.flatnonzero((nominal >= lower) & (nominal <= upper))
|
|
201
|
+
|
|
202
|
+
def _geometry(self, lattice, indices=None):
|
|
203
|
+
hkl = self._hkl["hkl"] if indices is None else self._hkl["hkl"][indices]
|
|
204
|
+
return reflection_geometry(self.backend, hkl, lattice, self.wavelength)
|
|
205
|
+
|
|
206
|
+
def fq(self, parameters: ParameterDict | None = None, *, indices=None):
|
|
207
|
+
"""Return per-reciprocal-point ``|F|^2`` without powder corrections."""
|
|
208
|
+
self._ensure_loaded()
|
|
209
|
+
lattice, frac, occ, b_iso = self._parameter_values(parameters)
|
|
210
|
+
hkl = self._hkl["hkl"] if indices is None else self._hkl["hkl"][indices]
|
|
211
|
+
_, two_theta = self._geometry(lattice, indices)
|
|
212
|
+
return self._compute_f2(hkl, two_theta, frac, occ, b_iso)
|
|
213
|
+
|
|
214
|
+
def _compute_f2(self, hkl, two_theta, frac, occ, b_iso):
|
|
215
|
+
return compute_F2(
|
|
216
|
+
mode=self.mode,
|
|
217
|
+
backend=self.backend,
|
|
218
|
+
hkl=hkl,
|
|
219
|
+
two_theta=two_theta,
|
|
220
|
+
wavelength=self.wavelength,
|
|
221
|
+
Z=self._symm["Z"],
|
|
222
|
+
frac=frac,
|
|
223
|
+
occ=occ,
|
|
224
|
+
B=b_iso,
|
|
225
|
+
neutron_scattering_lengths=self.neutron_scattering_lengths,
|
|
226
|
+
phase_chunk_entries=self.phase_chunk_entries,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def iq(
|
|
230
|
+
self,
|
|
231
|
+
domain: Literal["two_theta", "q"] = "two_theta",
|
|
232
|
+
parameters: ParameterDict | None = None,
|
|
233
|
+
):
|
|
234
|
+
"""Return individual reciprocal-point positions and corrected intensities."""
|
|
235
|
+
indices, g, two_theta, _, intensity = self._individual_data(domain, parameters)
|
|
236
|
+
del indices
|
|
237
|
+
if domain == "two_theta":
|
|
238
|
+
positions = self.backend.degrees(two_theta)
|
|
239
|
+
else:
|
|
240
|
+
positions = 2.0 * self.backend.pi() * g
|
|
241
|
+
return positions, intensity
|
|
242
|
+
|
|
243
|
+
def _individual_data(self, domain, parameters):
|
|
244
|
+
self._ensure_loaded()
|
|
245
|
+
indices = self._domain_indices(domain)
|
|
246
|
+
lattice, frac, occ, b_iso = self._parameter_values(parameters)
|
|
247
|
+
g, two_theta = self._geometry(lattice, indices)
|
|
248
|
+
f2 = self._compute_f2(self._hkl["hkl"][indices], two_theta, frac, occ, b_iso)
|
|
249
|
+
intensity = apply_lp_and_multiplicity(
|
|
250
|
+
self.mode, self.backend, f2, two_theta, multiplicity=None
|
|
251
|
+
)
|
|
252
|
+
return indices, g, two_theta, f2, intensity
|
|
253
|
+
|
|
254
|
+
def reflection_table(
|
|
255
|
+
self,
|
|
256
|
+
domain: Literal["two_theta", "q"] = "two_theta",
|
|
257
|
+
parameters: ParameterDict | None = None,
|
|
258
|
+
) -> ReflectionTable:
|
|
259
|
+
"""Return indexed per-reflection geometry and intensities."""
|
|
260
|
+
indices, g, two_theta, f2, intensity = self._individual_data(domain, parameters)
|
|
261
|
+
return ReflectionTable(
|
|
262
|
+
hkl=self._hkl["hkl"][indices].copy(),
|
|
263
|
+
d_spacing=1.0 / g,
|
|
264
|
+
q=2.0 * self.backend.pi() * g,
|
|
265
|
+
two_theta=self.backend.degrees(two_theta),
|
|
266
|
+
f_squared=f2,
|
|
267
|
+
intensity=intensity,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def line_pattern(
|
|
271
|
+
self,
|
|
272
|
+
domain: Literal["two_theta", "q"] = "two_theta",
|
|
273
|
+
parameters: ParameterDict | None = None,
|
|
274
|
+
*,
|
|
275
|
+
scaled: bool = False,
|
|
276
|
+
):
|
|
277
|
+
"""Return coincident reciprocal points merged into powder lines.
|
|
278
|
+
|
|
279
|
+
Lattice parameters must preserve the prepared metric degeneracies when
|
|
280
|
+
using this reporting method. :meth:`pattern` keeps reciprocal points
|
|
281
|
+
separate and is the appropriate differentiable path for symmetry-
|
|
282
|
+
breaking lattice changes.
|
|
283
|
+
"""
|
|
284
|
+
positions, intensities = self.iq(domain=domain, parameters=parameters)
|
|
285
|
+
indices = self._domain_indices(domain)
|
|
286
|
+
nominal = (
|
|
287
|
+
np.degrees(self._hkl["two_theta"][indices])
|
|
288
|
+
if domain == "two_theta"
|
|
289
|
+
else self._hkl["q"][indices]
|
|
290
|
+
)
|
|
291
|
+
tolerance = 1e-5 if domain == "two_theta" else 1e-7
|
|
292
|
+
if len(nominal) == 0:
|
|
293
|
+
return positions, intensities
|
|
294
|
+
starts = np.r_[0, np.flatnonzero(np.diff(nominal) > tolerance) + 1]
|
|
295
|
+
group_ids = np.cumsum(np.r_[0, (np.diff(nominal) > tolerance).astype(np.int64)])
|
|
296
|
+
backend_starts = self.backend.asarray(starts, dtype=self.backend.int64)
|
|
297
|
+
merged_positions = positions[backend_starts]
|
|
298
|
+
merged_intensities = self.backend.scatter_sum(intensities, group_ids, len(starts))
|
|
299
|
+
|
|
300
|
+
# Match the conventional powder-line reporting threshold. The mask is
|
|
301
|
+
# based on nominal output and is intentionally not part of the smooth
|
|
302
|
+
# profile-rendering path.
|
|
303
|
+
if len(starts):
|
|
304
|
+
if getattr(self.backend, "is_torch", False):
|
|
305
|
+
mask = (
|
|
306
|
+
merged_intensities.detach().cpu().numpy()
|
|
307
|
+
> float(merged_intensities.detach().max().cpu()) * self.intensity_tolerance
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
mask = merged_intensities > np.max(merged_intensities) * self.intensity_tolerance
|
|
311
|
+
backend_mask = self.backend.asarray(mask, dtype=self.backend.bool)
|
|
312
|
+
merged_positions = merged_positions[backend_mask]
|
|
313
|
+
merged_intensities = merged_intensities[backend_mask]
|
|
314
|
+
|
|
315
|
+
if scaled and int(merged_intensities.shape[0]):
|
|
316
|
+
merged_intensities = 100.0 * merged_intensities / self.backend.max(merged_intensities)
|
|
317
|
+
return merged_positions, merged_intensities
|
|
318
|
+
|
|
319
|
+
def pattern(
|
|
320
|
+
self,
|
|
321
|
+
domain: Literal["two_theta", "q"] = "two_theta",
|
|
322
|
+
parameters: ParameterDict | None = None,
|
|
323
|
+
):
|
|
324
|
+
"""Return an area-normalized, gridded powder profile."""
|
|
325
|
+
positions, intensities = self.iq(domain=domain, parameters=parameters)
|
|
326
|
+
if domain == "two_theta":
|
|
327
|
+
lower, upper = self.two_theta_range
|
|
328
|
+
grid = self._regular_grid(lower, upper, self.two_theta_step)
|
|
329
|
+
values = render_profile(self.profile, self.backend, grid, positions, intensities)
|
|
330
|
+
elif domain == "q":
|
|
331
|
+
lower, upper = self.q_range
|
|
332
|
+
grid = self._regular_grid(lower, upper, self.q_step)
|
|
333
|
+
values = render_profile_q(self.profile_q, self.backend, grid, positions, intensities)
|
|
334
|
+
else:
|
|
335
|
+
raise ValueError("domain must be 'two_theta' or 'q'")
|
|
336
|
+
return grid, values
|
|
337
|
+
|
|
338
|
+
def _regular_grid(self, lower: float, upper: float, step: float):
|
|
339
|
+
intervals = int(np.floor((upper - lower) / step + 8 * np.finfo(float).eps))
|
|
340
|
+
last = lower + intervals * step
|
|
341
|
+
return self.backend.linspace(lower, last, intervals + 1)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tabulated X-ray and neutron scattering amplitudes.
|
|
2
|
+
|
|
3
|
+
The numerical tables are maintained by pymatgen, a required dependency. The
|
|
4
|
+
X-ray expression is the Doyle-Turner/International Tables parameterization used by
|
|
5
|
+
``pymatgen.analysis.diffraction.xrd``; neutron values are coherent bound
|
|
6
|
+
scattering lengths in femtometres from the same package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from pymatgen.analysis.diffraction.neutron import ATOMIC_SCATTERING_LEN
|
|
16
|
+
from pymatgen.analysis.diffraction.xrd import ATOMIC_SCATTERING_PARAMS, WAVELENGTHS
|
|
17
|
+
from pymatgen.core import Element
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_XRAY_FORM_FACTOR_SCALE = 41.78214
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_wavelength(value: float | str) -> float:
|
|
24
|
+
"""Return a wavelength in angstroms from a number or radiation name."""
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
try:
|
|
27
|
+
return float(WAVELENGTHS[value])
|
|
28
|
+
except KeyError as exc:
|
|
29
|
+
choices = ", ".join(sorted(WAVELENGTHS))
|
|
30
|
+
raise ValueError(f"Unknown radiation {value!r}; choose one of {choices}") from exc
|
|
31
|
+
wavelength = float(value)
|
|
32
|
+
if not np.isfinite(wavelength) or wavelength <= 0:
|
|
33
|
+
raise ValueError("wavelength must be a positive finite value in angstroms")
|
|
34
|
+
return wavelength
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _numpy_atomic_numbers(values) -> np.ndarray:
|
|
38
|
+
if hasattr(values, "detach"):
|
|
39
|
+
values = values.detach().cpu().numpy()
|
|
40
|
+
return np.asarray(values, dtype=np.int64)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@lru_cache(maxsize=None)
|
|
44
|
+
def _xray_coefficients(numbers: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray]:
|
|
45
|
+
coeffs = []
|
|
46
|
+
for z in numbers:
|
|
47
|
+
symbol = Element.from_Z(z).symbol
|
|
48
|
+
try:
|
|
49
|
+
coeffs.append(ATOMIC_SCATTERING_PARAMS[symbol])
|
|
50
|
+
except KeyError as exc:
|
|
51
|
+
raise ValueError(f"No X-ray scattering coefficients for {symbol}") from exc
|
|
52
|
+
return np.asarray(numbers, dtype=float), np.asarray(coeffs, dtype=float)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def xray_form_factors(Z, s, backend) -> Any:
|
|
56
|
+
"""Evaluate neutral-atom X-ray form factors.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
Z: Atomic numbers, shape ``(N,)``.
|
|
60
|
+
s: ``sin(theta) / wavelength`` in inverse angstroms, shape ``(H,)``.
|
|
61
|
+
backend: NumPy or Torch backend.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Form factors with shape ``(H, N)``.
|
|
65
|
+
"""
|
|
66
|
+
numbers_array = _numpy_atomic_numbers(Z)
|
|
67
|
+
unique_numbers, inverse = np.unique(numbers_array, return_inverse=True)
|
|
68
|
+
numbers = tuple(int(z) for z in unique_numbers.tolist())
|
|
69
|
+
zs_np, coeff_np = _xray_coefficients(numbers)
|
|
70
|
+
dtype = getattr(s, "dtype", backend.dtype)
|
|
71
|
+
zs = backend.asarray(zs_np, dtype=dtype)
|
|
72
|
+
coeff = backend.asarray(coeff_np, dtype=dtype)
|
|
73
|
+
s2 = s * s
|
|
74
|
+
a = coeff[:, :, 0]
|
|
75
|
+
b = coeff[:, :, 1]
|
|
76
|
+
terms = a[None, :, :] * backend.exp(-s2[:, None, None] * b[None, :, :])
|
|
77
|
+
unique_factors = zs[None, :] - _XRAY_FORM_FACTOR_SCALE * s2[:, None] * backend.sum(
|
|
78
|
+
terms, axis=2
|
|
79
|
+
)
|
|
80
|
+
backend_inverse = backend.asarray(inverse, dtype=backend.int64)
|
|
81
|
+
return unique_factors[:, backend_inverse]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@lru_cache(maxsize=None)
|
|
85
|
+
def _neutron_lengths(numbers: tuple[int, ...]) -> np.ndarray:
|
|
86
|
+
values = []
|
|
87
|
+
for z in numbers:
|
|
88
|
+
symbol = Element.from_Z(z).symbol
|
|
89
|
+
try:
|
|
90
|
+
values.append(ATOMIC_SCATTERING_LEN[symbol])
|
|
91
|
+
except KeyError as exc:
|
|
92
|
+
raise ValueError(f"No coherent neutron scattering length for {symbol}") from exc
|
|
93
|
+
return np.asarray(values, dtype=float)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def neutron_b_coherent(
|
|
97
|
+
Z,
|
|
98
|
+
backend,
|
|
99
|
+
overrides: Mapping[str | int, float | str] | None = None,
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""Return coherent neutron scattering lengths in femtometres.
|
|
102
|
+
|
|
103
|
+
``overrides`` supports element symbols or atomic numbers. Values can be an
|
|
104
|
+
exact length or a tabulated isotope key such as ``"2H"``. This is the
|
|
105
|
+
explicit route for isotope-specific samples, which pymatgen ``Structure``
|
|
106
|
+
objects do not encode unambiguously.
|
|
107
|
+
"""
|
|
108
|
+
numbers = tuple(int(z) for z in _numpy_atomic_numbers(Z).tolist())
|
|
109
|
+
values = _neutron_lengths(numbers).copy()
|
|
110
|
+
if overrides:
|
|
111
|
+
for idx, z in enumerate(numbers):
|
|
112
|
+
symbol = Element.from_Z(z).symbol
|
|
113
|
+
override = overrides.get(symbol, overrides.get(z))
|
|
114
|
+
if override is not None:
|
|
115
|
+
if isinstance(override, str):
|
|
116
|
+
try:
|
|
117
|
+
values[idx] = ATOMIC_SCATTERING_LEN[override]
|
|
118
|
+
except KeyError as exc:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"No coherent neutron scattering length for isotope {override!r}"
|
|
121
|
+
) from exc
|
|
122
|
+
else:
|
|
123
|
+
values[idx] = float(override)
|
|
124
|
+
return backend.asarray(values, dtype=backend.dtype)
|