de-ebsd 0.2.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.
- de_ebsd/__init__.py +76 -0
- de_ebsd/core/__init__.py +23 -0
- de_ebsd/core/constants.py +81 -0
- de_ebsd/core/device.py +12 -0
- de_ebsd/core/geometry.py +67 -0
- de_ebsd/core/lambert.py +148 -0
- de_ebsd/core/rotation.py +139 -0
- de_ebsd/crystal/__init__.py +22 -0
- de_ebsd/crystal/data/__init__.py +0 -0
- de_ebsd/crystal/data/element_properties.py +29 -0
- de_ebsd/crystal/data/scattering_tables.py +414 -0
- de_ebsd/crystal/lattice.py +517 -0
- de_ebsd/crystal/scattering.py +339 -0
- de_ebsd/crystal/scattering_torch.py +233 -0
- de_ebsd/crystal/structure_factors.py +291 -0
- de_ebsd/diffraction/__init__.py +45 -0
- de_ebsd/diffraction/backends.py +318 -0
- de_ebsd/diffraction/bethe.py +28 -0
- de_ebsd/diffraction/kinematic.py +389 -0
- de_ebsd/diffraction/kvectors.py +120 -0
- de_ebsd/diffraction/prism.py +204 -0
- de_ebsd/diffraction/reflections.py +234 -0
- de_ebsd/index/__init__.py +61 -0
- de_ebsd/index/_kernels.py +222 -0
- de_ebsd/index/backproject.py +171 -0
- de_ebsd/index/crosscorr.py +654 -0
- de_ebsd/index/dictionary.py +362 -0
- de_ebsd/index/io.py +324 -0
- de_ebsd/index/live/__init__.py +5 -0
- de_ebsd/index/live/background.py +103 -0
- de_ebsd/index/live/calibration.py +70 -0
- de_ebsd/index/live/common.py +44 -0
- de_ebsd/index/live/deformation.py +289 -0
- de_ebsd/index/live/indexer.py +386 -0
- de_ebsd/index/preprocess.py +195 -0
- de_ebsd/index/refine/__init__.py +7 -0
- de_ebsd/index/refine/highres.py +479 -0
- de_ebsd/index/refine/levenberg.py +33 -0
- de_ebsd/index/refine/orientation.py +367 -0
- de_ebsd/index/refine/pattern_center.py +356 -0
- de_ebsd/index/spherical/__init__.py +41 -0
- de_ebsd/index/spherical/correlation.py +106 -0
- de_ebsd/index/spherical/harmonics.py +311 -0
- de_ebsd/index/spherical/indexer.py +345 -0
- de_ebsd/index/spherical/wigner.py +190 -0
- de_ebsd/sim/__init__.py +20 -0
- de_ebsd/sim/detector.py +145 -0
- de_ebsd/sim/masterpattern.py +231 -0
- de_ebsd/sim/microscope.py +93 -0
- de_ebsd/sim/montecarlo/__init__.py +19 -0
- de_ebsd/sim/montecarlo/depth.py +167 -0
- de_ebsd/sim/montecarlo/transport.py +532 -0
- de_ebsd/sim/simulator.py +291 -0
- de_ebsd/viz/__init__.py +53 -0
- de_ebsd/viz/camera.py +56 -0
- de_ebsd/viz/depth_widget.js +76 -0
- de_ebsd/viz/explorer.py +647 -0
- de_ebsd/viz/figures.py +67 -0
- de_ebsd/viz/maps.py +277 -0
- de_ebsd/viz/patterns.py +164 -0
- de_ebsd/viz/setup_widget.js +171 -0
- de_ebsd/viz/views.py +125 -0
- de_ebsd/viz/widgets.py +191 -0
- de_ebsd-0.2.0.dist-info/METADATA +251 -0
- de_ebsd-0.2.0.dist-info/RECORD +69 -0
- de_ebsd-0.2.0.dist-info/WHEEL +5 -0
- de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
- de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
- de_ebsd-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Structure factors U_g for every reflection within ``dmin``.
|
|
2
|
+
|
|
3
|
+
Sums Weickenmeier-Kohl form factors over the symmetry-expanded basis. Provides
|
|
4
|
+
the numpy path (:func:`compute_structure_factors`) used by the Bloch-wave
|
|
5
|
+
backend and a differentiable lookup (:func:`differentiable_ucg_lut`). Follows
|
|
6
|
+
EMsoft ``CalcUcg`` (Weickenmeier-Kohl branch).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import torch
|
|
16
|
+
|
|
17
|
+
from de_ebsd.core.constants import C_PI, DWWK, PREF_COEF, PREG, SWK, electron_wavelength_nm
|
|
18
|
+
from de_ebsd.core.device import resolve_device
|
|
19
|
+
from de_ebsd.crystal.lattice import Crystal
|
|
20
|
+
from de_ebsd.crystal.scattering import atomic_number, wk_scattering_factor
|
|
21
|
+
from de_ebsd.crystal.scattering_torch import wk_scattering_factor_torch
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from anyplotlib import Figure
|
|
25
|
+
|
|
26
|
+
__all__ = ["StructureFactors", "compute_structure_factors", "differentiable_ucg_lut"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class StructureFactors:
|
|
31
|
+
"""Structure factors of a crystal at one beam energy (inspectable stage).
|
|
32
|
+
|
|
33
|
+
Attributes
|
|
34
|
+
----------
|
|
35
|
+
crystal, energy_kev, dmin
|
|
36
|
+
The inputs that produced this table.
|
|
37
|
+
hmax
|
|
38
|
+
Largest Miller index with d >= dmin along any axis; the lookup table
|
|
39
|
+
spans ``-2 hmax .. 2 hmax`` per axis so any difference of two
|
|
40
|
+
reflections in the sphere is available.
|
|
41
|
+
ucg_lut
|
|
42
|
+
Complex U_g in nm^-2, shape (4 hmax + 1,) * 3, indexed
|
|
43
|
+
``[h + 2 hmax, k + 2 hmax, l + 2 hmax]``.
|
|
44
|
+
v0
|
|
45
|
+
Mean inner potential in volts.
|
|
46
|
+
upz0
|
|
47
|
+
Normal absorption coefficient U'_0 in nm^-2.
|
|
48
|
+
wavelength
|
|
49
|
+
Relativistic electron wavelength in nm, refraction corrected with v0.
|
|
50
|
+
normal_absorption_length
|
|
51
|
+
Normal absorption length ``1 / (U'_0 lambda)`` in nm (EMsoft ``xgp``
|
|
52
|
+
for g = 0), used to correct the Monte Carlo depth profile.
|
|
53
|
+
hkl, g, d, ucg
|
|
54
|
+
Flat listing of the reflections with 0 < g and d >= dmin that are
|
|
55
|
+
allowed by the lattice centering, sorted by g: Miller indices (N, 3),
|
|
56
|
+
lengths (N,) in nm^-1, spacings (N,) in nm and complex U_g (N,).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
crystal: Crystal
|
|
60
|
+
energy_kev: float
|
|
61
|
+
dmin: float
|
|
62
|
+
hmax: int
|
|
63
|
+
ucg_lut: np.ndarray
|
|
64
|
+
v0: float
|
|
65
|
+
upz0: float
|
|
66
|
+
wavelength: float
|
|
67
|
+
hkl: np.ndarray = field(repr=False)
|
|
68
|
+
g: np.ndarray = field(repr=False)
|
|
69
|
+
d: np.ndarray = field(repr=False)
|
|
70
|
+
ucg: np.ndarray = field(repr=False)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def normal_absorption_length(self) -> float:
|
|
74
|
+
"""Normal absorption length 1 / (U'_0 lambda) in nm."""
|
|
75
|
+
if self.upz0 <= 0:
|
|
76
|
+
return 1.0e8
|
|
77
|
+
return 1.0 / (self.upz0 * self.wavelength)
|
|
78
|
+
|
|
79
|
+
def lookup(self, hkl: np.ndarray) -> np.ndarray:
|
|
80
|
+
"""U_g for Miller indices (..., 3); indices must lie within the table."""
|
|
81
|
+
hkl = np.asarray(hkl, dtype=np.int64)
|
|
82
|
+
if np.any(np.abs(hkl) > 2 * self.hmax):
|
|
83
|
+
raise IndexError(f"|hkl| exceeds the lookup range 2*hmax = {2 * self.hmax}")
|
|
84
|
+
idx = hkl + 2 * self.hmax
|
|
85
|
+
return self.ucg_lut[idx[..., 0], idx[..., 1], idx[..., 2]]
|
|
86
|
+
|
|
87
|
+
def families(self, n: int | None = None) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
88
|
+
"""Group the reflection list by ``|g|``; representative hkl, g, and U_g.
|
|
89
|
+
|
|
90
|
+
Returns the first ``n`` (or all) families sorted by g length.
|
|
91
|
+
"""
|
|
92
|
+
keys = np.round(self.g, 6)
|
|
93
|
+
_, first = np.unique(keys, return_index=True)
|
|
94
|
+
order = np.sort(first)
|
|
95
|
+
if n is not None:
|
|
96
|
+
order = order[:n]
|
|
97
|
+
return self.hkl[order], self.g[order], self.ucg[order]
|
|
98
|
+
|
|
99
|
+
def show(self, n: int = 25) -> Figure:
|
|
100
|
+
"""Bar chart of ``|U_g|`` for the first ``n`` reflection families.
|
|
101
|
+
|
|
102
|
+
Systematic absences appear as (near-)zero bars.
|
|
103
|
+
"""
|
|
104
|
+
from de_ebsd.viz.figures import bar_figure
|
|
105
|
+
|
|
106
|
+
hkl, g, ucg = self.families(n)
|
|
107
|
+
labels = [f"{h} {k} {l_}" for h, k, l_ in hkl]
|
|
108
|
+
return bar_figure(
|
|
109
|
+
np.abs(ucg),
|
|
110
|
+
labels,
|
|
111
|
+
title=f"|U_g| [nm^-2] at {self.energy_kev:g} keV (V0 = {self.v0:.2f} V)",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def __repr__(self) -> str:
|
|
115
|
+
return (
|
|
116
|
+
f"StructureFactors({self.crystal!r}, energy={self.energy_kev:g} keV, "
|
|
117
|
+
f"dmin={self.dmin:g} nm, {len(self.hkl)} reflections, "
|
|
118
|
+
f"V0={self.v0:.2f} V, lambda={self.wavelength * 1e3:.4f} pm)"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _hmax_for_dmin(crystal: Crystal, dmin: float) -> int:
|
|
123
|
+
hmax = 0
|
|
124
|
+
for axis in range(3):
|
|
125
|
+
h = 1
|
|
126
|
+
while True:
|
|
127
|
+
hkl = np.zeros(3)
|
|
128
|
+
hkl[axis] = h
|
|
129
|
+
if crystal.d_spacing(hkl) < dmin:
|
|
130
|
+
break
|
|
131
|
+
h += 1
|
|
132
|
+
if h > 200:
|
|
133
|
+
raise ValueError("dmin is unreasonably small for this lattice")
|
|
134
|
+
hmax = max(hmax, h)
|
|
135
|
+
return hmax
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def compute_structure_factors(
|
|
139
|
+
crystal: Crystal,
|
|
140
|
+
energy_kev: float,
|
|
141
|
+
dmin: float,
|
|
142
|
+
absorption: bool = True,
|
|
143
|
+
) -> StructureFactors:
|
|
144
|
+
"""Compute U_g over the full lookup box for one beam energy.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
crystal
|
|
149
|
+
The crystal.
|
|
150
|
+
energy_kev
|
|
151
|
+
Beam energy in keV (numerically equal to the accelerating voltage
|
|
152
|
+
in kV).
|
|
153
|
+
dmin
|
|
154
|
+
Smallest interplanar spacing to include, in nm.
|
|
155
|
+
absorption
|
|
156
|
+
Include the WK absorptive parts (imaginary U''_g).
|
|
157
|
+
"""
|
|
158
|
+
hmax = _hmax_for_dmin(crystal, dmin)
|
|
159
|
+
m = 2 * hmax
|
|
160
|
+
rng = np.arange(-m, m + 1)
|
|
161
|
+
h, k, l = np.meshgrid(rng, rng, rng, indexing="ij") # noqa: E741
|
|
162
|
+
hkl_box = np.stack([h, k, l], axis=-1).reshape(-1, 3)
|
|
163
|
+
|
|
164
|
+
g_len = crystal.g_length(hkl_box) # nm^-1
|
|
165
|
+
g_wk = g_len * SWK # WK units
|
|
166
|
+
|
|
167
|
+
# Scattering factors depend on the element and |g| only: evaluate on the
|
|
168
|
+
# unique lengths and scatter back.
|
|
169
|
+
g_unique, inverse = np.unique(np.round(g_wk, 9), return_inverse=True)
|
|
170
|
+
|
|
171
|
+
volume = crystal.volume
|
|
172
|
+
pref = PREF_COEF / volume / (4.0 * C_PI)
|
|
173
|
+
pre = pref * PREG
|
|
174
|
+
|
|
175
|
+
ff = np.zeros(len(hkl_box), dtype=np.complex128)
|
|
176
|
+
gg = np.zeros(len(hkl_box), dtype=np.complex128)
|
|
177
|
+
for site, positions in zip(crystal.sites, crystal.positions, strict=True):
|
|
178
|
+
z = atomic_number(site.element)
|
|
179
|
+
ul = float(np.sqrt(site.debye_waller * DWWK))
|
|
180
|
+
structure_factors = wk_scattering_factor(z, g_unique, ul, energy_kev, absorption=absorption)
|
|
181
|
+
structure_factors = structure_factors[inverse] * site.occupancy
|
|
182
|
+
phase = np.exp(-2j * np.pi * (hkl_box @ positions.T)).sum(axis=1)
|
|
183
|
+
ff += phase * structure_factors.real
|
|
184
|
+
gg += phase * structure_factors.imag
|
|
185
|
+
|
|
186
|
+
if absorption:
|
|
187
|
+
ucg = pre * (ff + 1j * gg)
|
|
188
|
+
else:
|
|
189
|
+
ucg = pre * ff
|
|
190
|
+
|
|
191
|
+
center = len(hkl_box) // 2 # index of (0, 0, 0)
|
|
192
|
+
v0 = float(pref * np.abs(ff[center]))
|
|
193
|
+
upz0 = float(PREG * pref * np.abs(gg[center])) if absorption else 0.0
|
|
194
|
+
wavelength = electron_wavelength_nm(energy_kev, v0mod=v0)
|
|
195
|
+
|
|
196
|
+
# Flat reflection listing for inspection: allowed by centering, inside the
|
|
197
|
+
# dmin sphere, g > 0, sorted by g.
|
|
198
|
+
keep = (g_len > 0) & (1.0 / np.maximum(g_len, 1e-12) >= dmin)
|
|
199
|
+
keep &= crystal.allowed_by_centering(hkl_box)
|
|
200
|
+
keep &= np.all(np.abs(hkl_box) <= hmax, axis=1)
|
|
201
|
+
order = np.argsort(g_len[keep], kind="stable")
|
|
202
|
+
hkl_list = hkl_box[keep][order]
|
|
203
|
+
g_list = g_len[keep][order]
|
|
204
|
+
|
|
205
|
+
n = 4 * hmax + 1
|
|
206
|
+
return StructureFactors(
|
|
207
|
+
crystal=crystal,
|
|
208
|
+
energy_kev=float(energy_kev),
|
|
209
|
+
dmin=float(dmin),
|
|
210
|
+
hmax=hmax,
|
|
211
|
+
ucg_lut=ucg.reshape(n, n, n),
|
|
212
|
+
v0=v0,
|
|
213
|
+
upz0=upz0,
|
|
214
|
+
wavelength=float(wavelength),
|
|
215
|
+
hkl=hkl_list,
|
|
216
|
+
g=g_list,
|
|
217
|
+
d=1.0 / g_list,
|
|
218
|
+
ucg=ucg[keep][order],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def differentiable_ucg_lut(
|
|
223
|
+
crystal: Crystal,
|
|
224
|
+
energy_kev: float,
|
|
225
|
+
dmin: float,
|
|
226
|
+
*,
|
|
227
|
+
positions: list[torch.Tensor] | None = None,
|
|
228
|
+
occupancies: list[torch.Tensor] | None = None,
|
|
229
|
+
debye_waller: list[torch.Tensor] | None = None,
|
|
230
|
+
absorption: bool = True,
|
|
231
|
+
device: torch.device | str | None = None,
|
|
232
|
+
dtype: torch.dtype = torch.complex128,
|
|
233
|
+
) -> torch.Tensor:
|
|
234
|
+
"""Assemble the ``U_g`` lookup table in torch, differentiable in the crystal.
|
|
235
|
+
|
|
236
|
+
``U_g = pre * sum_site occ_site * f_site(g) * sum_r exp(-2 pi i g.r)``. All
|
|
237
|
+
three parameter groups are differentiable when passed as ``requires_grad``
|
|
238
|
+
tensors:
|
|
239
|
+
|
|
240
|
+
- **positions** -- the structure-factor phase ``sum_r exp(-2 pi i g.r)``;
|
|
241
|
+
- **occupancies** -- the linear per-site weight;
|
|
242
|
+
- **debye_waller** (``B`` in nm^2, one per site) -- feeds the torch WK
|
|
243
|
+
amplitude (:func:`de_ebsd.crystal.scattering_torch.wk_scattering_factor_torch`),
|
|
244
|
+
so ``U_g`` differentiates w.r.t. the thermal factor.
|
|
245
|
+
|
|
246
|
+
When ``debye_waller`` is None the amplitudes are evaluated in numpy and held
|
|
247
|
+
constant (positions/occupancy gradients only). Feed the result to a torch
|
|
248
|
+
backend (``bloch_prism``) as its ``u_lut`` for end-to-end
|
|
249
|
+
``d(intensity)/d(crystal parameter)``. Lattice gradients (through ``g``)
|
|
250
|
+
remain a further step.
|
|
251
|
+
"""
|
|
252
|
+
dev = resolve_device(device)
|
|
253
|
+
hmax = _hmax_for_dmin(crystal, dmin)
|
|
254
|
+
m = 2 * hmax
|
|
255
|
+
rng = np.arange(-m, m + 1)
|
|
256
|
+
h, k, ll = np.meshgrid(rng, rng, rng, indexing="ij")
|
|
257
|
+
hkl_box = np.stack([h, k, ll], axis=-1).reshape(-1, 3)
|
|
258
|
+
g_wk = crystal.g_length(hkl_box) * SWK
|
|
259
|
+
g_unique, inverse = np.unique(np.round(g_wk, 9), return_inverse=True)
|
|
260
|
+
pre = PREF_COEF / crystal.volume / (4.0 * C_PI) * PREG
|
|
261
|
+
|
|
262
|
+
hkl_t = torch.as_tensor(hkl_box, device=dev, dtype=torch.float64)
|
|
263
|
+
inverse_t = torch.as_tensor(inverse, device=dev)
|
|
264
|
+
g_unique_t = torch.as_tensor(g_unique, device=dev, dtype=torch.float64)
|
|
265
|
+
if positions is None:
|
|
266
|
+
positions = [torch.as_tensor(p, device=dev, dtype=torch.float64) for p in crystal.positions]
|
|
267
|
+
if occupancies is None:
|
|
268
|
+
occupancies = [
|
|
269
|
+
torch.as_tensor(s.occupancy, device=dev, dtype=torch.float64) for s in crystal.sites
|
|
270
|
+
]
|
|
271
|
+
dw = debye_waller if debye_waller is not None else [None] * len(crystal.sites)
|
|
272
|
+
|
|
273
|
+
ucg = torch.zeros(len(hkl_box), device=dev, dtype=dtype)
|
|
274
|
+
for site, pos, occ, b in zip(crystal.sites, positions, occupancies, dw, strict=True):
|
|
275
|
+
z = atomic_number(site.element)
|
|
276
|
+
if b is None: # detached WK amplitude (numpy)
|
|
277
|
+
ul = float(np.sqrt(site.debye_waller * DWWK))
|
|
278
|
+
f = wk_scattering_factor(z, g_unique, ul, energy_kev, absorption=absorption)
|
|
279
|
+
f_t = torch.as_tensor(f, device=dev, dtype=dtype)[inverse_t]
|
|
280
|
+
else: # torch WK amplitude, differentiable in the Debye-Waller factor
|
|
281
|
+
ul = torch.sqrt(b.to(dev, torch.float64) * DWWK)
|
|
282
|
+
f_unique = wk_scattering_factor_torch(
|
|
283
|
+
z, g_unique_t, ul, energy_kev, absorption=absorption
|
|
284
|
+
)
|
|
285
|
+
f_t = f_unique.to(dtype)[inverse_t]
|
|
286
|
+
# structure-factor phase sum over the site's atoms (differentiable in pos)
|
|
287
|
+
phase = torch.exp(-2j * np.pi * (hkl_t @ pos.to(dev, torch.float64).T)).sum(dim=1)
|
|
288
|
+
ucg = ucg + pre * occ.to(dev, dtype) * f_t * phase
|
|
289
|
+
|
|
290
|
+
n = 4 * hmax + 1
|
|
291
|
+
return ucg.reshape(n, n, n)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Dynamical electron diffraction: reflection lists, Bethe potentials, Bloch waves."""
|
|
2
|
+
|
|
3
|
+
from de_ebsd.diffraction.backends import (
|
|
4
|
+
BlochWaveBackend,
|
|
5
|
+
DynamicalMatrix,
|
|
6
|
+
MasterPatternBackend,
|
|
7
|
+
SimulationInputs,
|
|
8
|
+
build_dynamical_matrix,
|
|
9
|
+
classify_reflections,
|
|
10
|
+
resolve_backend,
|
|
11
|
+
)
|
|
12
|
+
from de_ebsd.diffraction.bethe import BetheParameters
|
|
13
|
+
from de_ebsd.diffraction.kvectors import (
|
|
14
|
+
KVectorGrid,
|
|
15
|
+
reduce_to_fundamental_sector,
|
|
16
|
+
sample_incident_directions,
|
|
17
|
+
)
|
|
18
|
+
from de_ebsd.diffraction.prism import BlochPrismBackend
|
|
19
|
+
from de_ebsd.diffraction.reflections import (
|
|
20
|
+
ReflectionList,
|
|
21
|
+
candidate_reflections,
|
|
22
|
+
excitation_errors,
|
|
23
|
+
master_reflection_list,
|
|
24
|
+
partition_strong_weak,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BetheParameters",
|
|
29
|
+
"BlochPrismBackend",
|
|
30
|
+
"BlochWaveBackend",
|
|
31
|
+
"DynamicalMatrix",
|
|
32
|
+
"KVectorGrid",
|
|
33
|
+
"MasterPatternBackend",
|
|
34
|
+
"ReflectionList",
|
|
35
|
+
"SimulationInputs",
|
|
36
|
+
"build_dynamical_matrix",
|
|
37
|
+
"candidate_reflections",
|
|
38
|
+
"classify_reflections",
|
|
39
|
+
"excitation_errors",
|
|
40
|
+
"master_reflection_list",
|
|
41
|
+
"partition_strong_weak",
|
|
42
|
+
"reduce_to_fundamental_sector",
|
|
43
|
+
"resolve_backend",
|
|
44
|
+
"sample_incident_directions",
|
|
45
|
+
]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Master-pattern backends: how a set of incident directions becomes intensity.
|
|
2
|
+
|
|
3
|
+
:class:`BlochWaveBackend` is the dynamical solver (Bethe-perturbed dynamical
|
|
4
|
+
matrix, eigen-decomposition, depth integration) in float64, one direction at a
|
|
5
|
+
time; :class:`~de_ebsd.diffraction.prism.BlochPrismBackend` is its batched GPU
|
|
6
|
+
equivalent. The dynamical matrix, depth integration and ``S_gh`` weighting port
|
|
7
|
+
EMsoft's ``GetDynMat``, ``CalcLgh`` and ``preCalcSgh``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import torch
|
|
18
|
+
|
|
19
|
+
from de_ebsd.crystal.scattering import atomic_number
|
|
20
|
+
from de_ebsd.crystal.structure_factors import StructureFactors
|
|
21
|
+
from de_ebsd.diffraction.bethe import BetheParameters
|
|
22
|
+
from de_ebsd.diffraction.reflections import (
|
|
23
|
+
STRONG,
|
|
24
|
+
WEAK,
|
|
25
|
+
ReflectionList,
|
|
26
|
+
candidate_reflections,
|
|
27
|
+
excitation_errors,
|
|
28
|
+
master_reflection_list,
|
|
29
|
+
partition_strong_weak,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from anyplotlib import Figure
|
|
34
|
+
|
|
35
|
+
from de_ebsd.sim.montecarlo.depth import DepthDistribution
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DynamicalMatrix",
|
|
39
|
+
"MasterPatternBackend",
|
|
40
|
+
"BlochWaveBackend",
|
|
41
|
+
"build_dynamical_matrix",
|
|
42
|
+
"classify_reflections",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def classify_reflections(
|
|
47
|
+
structure_factors: StructureFactors,
|
|
48
|
+
k_hat: np.ndarray,
|
|
49
|
+
bethe: BetheParameters,
|
|
50
|
+
) -> ReflectionList:
|
|
51
|
+
"""Candidate + strong/weak classification for one beam direction.
|
|
52
|
+
|
|
53
|
+
``k_hat`` is a unit vector in the Cartesian crystal frame.
|
|
54
|
+
"""
|
|
55
|
+
refl = master_reflection_list(structure_factors)
|
|
56
|
+
k = torch.as_tensor(k_hat, dtype=torch.float64)
|
|
57
|
+
g_cart = torch.as_tensor(structure_factors.crystal.g_cartesian(refl.hkl), dtype=torch.float64)
|
|
58
|
+
sg = excitation_errors(g_cart, k, structure_factors.wavelength).numpy()
|
|
59
|
+
|
|
60
|
+
keep = candidate_reflections(refl, sg, structure_factors.wavelength, bethe)
|
|
61
|
+
# prepend the incident beam g = 0
|
|
62
|
+
hkl = np.concatenate([np.zeros((1, 3), dtype=np.int64), refl.hkl[keep]])
|
|
63
|
+
sg_c = np.concatenate([[0.0], sg[keep]])
|
|
64
|
+
u_mod = np.concatenate(
|
|
65
|
+
[[np.abs(structure_factors.lookup(np.zeros(3, dtype=np.int64)))], refl.u_mod[keep]]
|
|
66
|
+
)
|
|
67
|
+
forbidden = np.concatenate([[False], refl.forbidden[keep]])
|
|
68
|
+
|
|
69
|
+
status = partition_strong_weak(hkl, sg_c, forbidden, structure_factors, bethe)
|
|
70
|
+
g_len = structure_factors.crystal.g_length(hkl)
|
|
71
|
+
return ReflectionList(
|
|
72
|
+
hkl=hkl,
|
|
73
|
+
g=g_len,
|
|
74
|
+
d=1.0 / np.maximum(g_len, 1e-12),
|
|
75
|
+
u_mod=u_mod,
|
|
76
|
+
forbidden=forbidden,
|
|
77
|
+
k=np.asarray(k_hat, dtype=np.float64),
|
|
78
|
+
sg=sg_c,
|
|
79
|
+
status=status,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_dynamical_matrix(structure_factors: StructureFactors, refl: ReflectionList) -> np.ndarray:
|
|
84
|
+
"""The Bloch dynamical matrix A (complex, nm^-2) for one classified list.
|
|
85
|
+
|
|
86
|
+
Port of EMsoft ``GetDynMat`` (Bloch branch, with Bethe corrections).
|
|
87
|
+
"""
|
|
88
|
+
if refl.status is None or refl.sg is None:
|
|
89
|
+
raise ValueError("reflection list must be classified for one beam direction")
|
|
90
|
+
strong = refl.status == STRONG
|
|
91
|
+
weak = refl.status == WEAK
|
|
92
|
+
hkl_s = refl.hkl[strong]
|
|
93
|
+
hkl_w = refl.hkl[weak]
|
|
94
|
+
sg_s = refl.sg[strong]
|
|
95
|
+
sg_w = refl.sg[weak]
|
|
96
|
+
lam = structure_factors.wavelength
|
|
97
|
+
|
|
98
|
+
diff = hkl_s[:, None, :] - hkl_s[None, :, :]
|
|
99
|
+
a = structure_factors.lookup(diff).astype(np.complex128) # off-diagonal U_{g-h}
|
|
100
|
+
|
|
101
|
+
if len(hkl_w) > 0:
|
|
102
|
+
u_gw = structure_factors.lookup(hkl_s[:, None, :] - hkl_w[None, :, :]) # (Ns, Nw)
|
|
103
|
+
u_wh = structure_factors.lookup(hkl_w[:, None, :] - hkl_s[None, :, :]) # (Nw, Ns)
|
|
104
|
+
a -= 0.5 * lam * (u_gw / sg_w[None, :]) @ u_wh
|
|
105
|
+
weak_diag = 0.5 * lam * (np.abs(u_gw) ** 2 / sg_w[None, :]).sum(axis=1)
|
|
106
|
+
else:
|
|
107
|
+
weak_diag = np.zeros(len(hkl_s))
|
|
108
|
+
|
|
109
|
+
np.fill_diagonal(a, 2.0 * sg_s / lam - weak_diag + 1j * structure_factors.upz0)
|
|
110
|
+
return a
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class DynamicalMatrix:
|
|
115
|
+
"""The dynamical matrix for one beam direction (inspectable stage)."""
|
|
116
|
+
|
|
117
|
+
matrix: np.ndarray
|
|
118
|
+
hkl: np.ndarray
|
|
119
|
+
k: np.ndarray
|
|
120
|
+
reflections: ReflectionList
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def n_beams(self) -> int:
|
|
124
|
+
return self.matrix.shape[0]
|
|
125
|
+
|
|
126
|
+
def show(self) -> Figure:
|
|
127
|
+
"""Heat map of ``log10 |A_gh|``."""
|
|
128
|
+
from de_ebsd.viz.figures import image_figure
|
|
129
|
+
|
|
130
|
+
with np.errstate(divide="ignore"):
|
|
131
|
+
image = np.log10(np.maximum(np.abs(self.matrix), 1e-12))
|
|
132
|
+
return image_figure(
|
|
133
|
+
image,
|
|
134
|
+
title=f"log10 |A| for k = {np.round(self.k, 4)} ({self.n_beams} strong beams)",
|
|
135
|
+
cmap="viridis",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str:
|
|
139
|
+
return (
|
|
140
|
+
f"DynamicalMatrix({self.n_beams} strong beams, "
|
|
141
|
+
f"{self.reflections.n_weak} weak folded in, k={np.round(self.k, 4)})"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class SimulationInputs:
|
|
147
|
+
"""Everything a backend needs, prepared once by the simulator."""
|
|
148
|
+
|
|
149
|
+
structure_factors: StructureFactors
|
|
150
|
+
bethe: BetheParameters
|
|
151
|
+
monte_carlo: DepthDistribution | None
|
|
152
|
+
device: torch.device
|
|
153
|
+
dtype: torch.dtype
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class MasterPatternBackend(ABC):
|
|
157
|
+
"""Interface every master-pattern intensity backend implements.
|
|
158
|
+
|
|
159
|
+
A backend receives a batch of unit incident-beam directions (Cartesian
|
|
160
|
+
crystal frame) and returns one backscatter intensity per direction. The
|
|
161
|
+
simulator handles gridding, chunking, and assembly into the Lambert maps.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
name: str = "abstract"
|
|
165
|
+
#: whether this backend consumes a Monte Carlo exit-depth profile; the
|
|
166
|
+
#: simulator builds a native one (EMsoft-free) when the user supplies none.
|
|
167
|
+
uses_depth_profile: bool = False
|
|
168
|
+
|
|
169
|
+
@abstractmethod
|
|
170
|
+
def prepare(self, inputs: SimulationInputs) -> None:
|
|
171
|
+
"""Build and cache per-simulation tensors on the target device."""
|
|
172
|
+
|
|
173
|
+
@abstractmethod
|
|
174
|
+
def intensities(self, directions: torch.Tensor) -> torch.Tensor:
|
|
175
|
+
"""Intensities (K,) for unit directions (K, 3). Called after prepare."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def calc_lgh(
|
|
179
|
+
a: np.ndarray,
|
|
180
|
+
kn: float,
|
|
181
|
+
thick: float,
|
|
182
|
+
depthstep: float,
|
|
183
|
+
lambda_e: np.ndarray,
|
|
184
|
+
gzero: int,
|
|
185
|
+
) -> np.ndarray:
|
|
186
|
+
"""The depth-integrated intensity matrix L_gh (EMsoft ``CalcLgh``).
|
|
187
|
+
|
|
188
|
+
Eigendecompose the dynamical matrix ``a`` (complex, nm^-2), scale
|
|
189
|
+
eigenvalues by ``1/(2 kn)``, and integrate the Bloch-wave interference
|
|
190
|
+
against the Monte Carlo exit-depth profile ``lambda_e`` (already corrected
|
|
191
|
+
for normal absorption), returning ``L_gh = conj(C) I C^T``.
|
|
192
|
+
"""
|
|
193
|
+
w, c = np.linalg.eig(a) # right eigenvectors are the columns of c (LAPACK zgeev)
|
|
194
|
+
c_inv = np.linalg.inv(c)
|
|
195
|
+
w = w / (2.0 * kn)
|
|
196
|
+
alpha = c_inv[:, gzero] # incident-beam excitation amplitudes CGinv(:, gzero)
|
|
197
|
+
|
|
198
|
+
tpi = 2.0 * np.pi * depthstep
|
|
199
|
+
re, im = w.real, w.imag
|
|
200
|
+
q_re = tpi * (im[:, None] + im[None, :]) # real part of q_jk
|
|
201
|
+
q_im = tpi * (re[:, None] - re[None, :]) # imaginary part
|
|
202
|
+
qold = q_re + 1j * q_im
|
|
203
|
+
qold = np.where(q_re < 0.0, -qold, qold) # fold the real part positive
|
|
204
|
+
|
|
205
|
+
# sum_z lambda_E[z] exp(-qold z) is a Horner polynomial in r = exp(-qold):
|
|
206
|
+
# sum_{z=1..Z} lambda_z r^z, evaluated with Z complex multiply-adds instead
|
|
207
|
+
# of materializing and exponentiating the full [S, S, Z] tensor -- identical
|
|
208
|
+
# to machine precision, ~20x fewer exponentials (the dominant cost here).
|
|
209
|
+
r = np.exp(-qold)
|
|
210
|
+
q = np.zeros_like(r)
|
|
211
|
+
for z in range(len(lambda_e), 0, -1):
|
|
212
|
+
q = q * r + lambda_e[z - 1]
|
|
213
|
+
q = q * r
|
|
214
|
+
ijk = np.conj(alpha)[:, None] * q * alpha[None, :] * (depthstep / thick)
|
|
215
|
+
return np.conj(c) @ ijk @ c.T
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class BlochWaveBackend(MasterPatternBackend):
|
|
219
|
+
"""The physical Bloch-wave backend (EMsoft ``CalcLgh`` + ``Sgh``).
|
|
220
|
+
|
|
221
|
+
Per beam direction: classify reflections (Bethe), build the dynamical
|
|
222
|
+
matrix, eigendecompose and depth-integrate (:func:`calc_lgh`), weight with
|
|
223
|
+
the structure-factor-like ``Sgh`` matrix, and reduce to one backscatter
|
|
224
|
+
intensity ``I(k) = Re sum_gh L_gh S_gh / n_atoms``.
|
|
225
|
+
|
|
226
|
+
This is the float64 reference path (one direction at a time), the M2
|
|
227
|
+
correctness milestone; the batched GPU path (bucket by strong-beam count +
|
|
228
|
+
batched eig) is the follow-up optimization behind the same interface.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
name = "bloch"
|
|
232
|
+
uses_depth_profile = True
|
|
233
|
+
|
|
234
|
+
def prepare(self, inputs: SimulationInputs) -> None:
|
|
235
|
+
from de_ebsd.sim.montecarlo.depth import DepthDistribution
|
|
236
|
+
|
|
237
|
+
self.structure_factors = inputs.structure_factors
|
|
238
|
+
self.bethe = inputs.bethe
|
|
239
|
+
self.device = inputs.device
|
|
240
|
+
self.out_dtype = inputs.dtype
|
|
241
|
+
crystal = inputs.structure_factors.crystal
|
|
242
|
+
|
|
243
|
+
# depth integration (EMsoft lambdaE / thick / izzmax, one energy bin)
|
|
244
|
+
energy = inputs.structure_factors.energy_kev
|
|
245
|
+
mc = inputs.monte_carlo or DepthDistribution.single_energy(energy)
|
|
246
|
+
izzmax = max(int(mc.integration_depths()[mc.energy_index(energy)]), 1)
|
|
247
|
+
self.depthstep = float(mc.depth_step)
|
|
248
|
+
self.thick = izzmax * self.depthstep
|
|
249
|
+
weights = mc.depth_weights(energy, inputs.structure_factors.normal_absorption_length)
|
|
250
|
+
self.lambda_e = np.ascontiguousarray(weights[:izzmax], dtype=np.float64)
|
|
251
|
+
self.kn = 1.0 / inputs.structure_factors.wavelength
|
|
252
|
+
self.gzero = 0
|
|
253
|
+
self.n_atoms = int(sum(p.shape[0] for p in crystal.positions))
|
|
254
|
+
|
|
255
|
+
self._build_sgh_lut(inputs.structure_factors, crystal)
|
|
256
|
+
|
|
257
|
+
def _build_sgh_lut(self, structure_factors: StructureFactors, crystal) -> None:
|
|
258
|
+
self.hmax = structure_factors.hmax
|
|
259
|
+
m = 2 * self.hmax
|
|
260
|
+
rng = np.arange(-m, m + 1)
|
|
261
|
+
hh, kk, ll = np.meshgrid(rng, rng, rng, indexing="ij")
|
|
262
|
+
box = np.stack([hh, kk, ll], axis=-1).reshape(-1, 3)
|
|
263
|
+
g2 = crystal.g_length(box) ** 2 # |h-g|^2 in the reciprocal frame
|
|
264
|
+
|
|
265
|
+
pos, z2occ, bfac = [], [], []
|
|
266
|
+
for site, positions in zip(crystal.sites, crystal.positions, strict=True):
|
|
267
|
+
z = atomic_number(site.element)
|
|
268
|
+
pos.append(positions)
|
|
269
|
+
z2occ.append(np.full(positions.shape[0], z**2 * site.occupancy))
|
|
270
|
+
bfac.append(np.full(positions.shape[0], site.debye_waller))
|
|
271
|
+
pos = np.concatenate(pos)
|
|
272
|
+
z2occ = np.concatenate(z2occ)
|
|
273
|
+
bfac = np.concatenate(bfac)
|
|
274
|
+
|
|
275
|
+
phase = np.exp(2j * np.pi * (box @ pos.T))
|
|
276
|
+
dwf = np.exp(-0.25 * g2[:, None] * bfac[None, :])
|
|
277
|
+
sgh = (phase * dwf * z2occ[None, :]).sum(axis=1)
|
|
278
|
+
n = 4 * self.hmax + 1
|
|
279
|
+
self.sgh_lut = sgh.reshape(n, n, n).astype(np.complex128)
|
|
280
|
+
|
|
281
|
+
def _sgh_matrix(self, hkl_strong: np.ndarray) -> np.ndarray:
|
|
282
|
+
diff = hkl_strong[None, :, :] - hkl_strong[:, None, :] # [g, h] = h - g
|
|
283
|
+
idx = diff + 2 * self.hmax
|
|
284
|
+
return self.sgh_lut[idx[..., 0], idx[..., 1], idx[..., 2]]
|
|
285
|
+
|
|
286
|
+
def _intensity(self, k_hat: np.ndarray) -> float:
|
|
287
|
+
refl = classify_reflections(self.structure_factors, k_hat, self.bethe)
|
|
288
|
+
hkl_s = refl.hkl[refl.status == STRONG]
|
|
289
|
+
a = build_dynamical_matrix(self.structure_factors, refl)
|
|
290
|
+
lgh = calc_lgh(a, self.kn, self.thick, self.depthstep, self.lambda_e, self.gzero)
|
|
291
|
+
sgh = self._sgh_matrix(hkl_s)
|
|
292
|
+
return float(np.real(np.sum(lgh * sgh)) / self.n_atoms)
|
|
293
|
+
|
|
294
|
+
def intensities(self, directions: torch.Tensor) -> torch.Tensor:
|
|
295
|
+
dirs = directions.detach().cpu().numpy().astype(np.float64)
|
|
296
|
+
out = np.empty(dirs.shape[0], dtype=np.float64)
|
|
297
|
+
for i in range(dirs.shape[0]):
|
|
298
|
+
out[i] = self._intensity(dirs[i])
|
|
299
|
+
return torch.as_tensor(out, device=self.device, dtype=self.out_dtype)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def resolve_backend(backend: str | MasterPatternBackend) -> MasterPatternBackend:
|
|
303
|
+
"""Map a backend name to an instance; pass instances through."""
|
|
304
|
+
if isinstance(backend, MasterPatternBackend):
|
|
305
|
+
return backend
|
|
306
|
+
from de_ebsd.diffraction.prism import BlochPrismBackend # deferred: prism imports this module
|
|
307
|
+
|
|
308
|
+
named = {
|
|
309
|
+
"bloch": BlochWaveBackend,
|
|
310
|
+
"bloch_prism": BlochPrismBackend,
|
|
311
|
+
}
|
|
312
|
+
try:
|
|
313
|
+
return named[backend]()
|
|
314
|
+
except KeyError:
|
|
315
|
+
raise ValueError(
|
|
316
|
+
f"unknown backend {backend!r}; expected one of {sorted(named)} "
|
|
317
|
+
"or a MasterPatternBackend instance"
|
|
318
|
+
) from None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Bethe potential cutoffs that split reflections into strong/weak/ignored.
|
|
2
|
+
|
|
3
|
+
The scheme is EMsoft's ``Apply_BethePotentials``; the defaults here differ from
|
|
4
|
+
EMsoft's own (4 / 8 / 50) and must be matched to compare against it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
__all__ = ["BetheParameters"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class BetheParameters:
|
|
16
|
+
"""Strong/weak/ignore thresholds for the Bethe-potential truncation."""
|
|
17
|
+
|
|
18
|
+
strong: float = 8.0
|
|
19
|
+
weak: float = 50.0
|
|
20
|
+
cutoff: float = 100.0
|
|
21
|
+
sg_double: float = 1.0
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if not 0 < self.strong < self.weak <= self.cutoff:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"require 0 < strong < weak <= cutoff, got "
|
|
27
|
+
f"strong={self.strong}, weak={self.weak}, cutoff={self.cutoff}"
|
|
28
|
+
)
|