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.
@@ -0,0 +1,5 @@
1
+ from .core import BraggCalculator
2
+ from .results import ReflectionTable
3
+ from ._version import __version__
4
+
5
+ __all__ = ["BraggCalculator", "ReflectionTable", "__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ from .numpy_backend import NumpyBackend
2
+
3
+ try:
4
+ from .torch_backend import TorchBackend
5
+ except ImportError: # pragma: no cover
6
+ TorchBackend = None
7
+
8
+ __all__ = ["NumpyBackend", "TorchBackend"]
@@ -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
@@ -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)