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.
Files changed (69) hide show
  1. de_ebsd/__init__.py +76 -0
  2. de_ebsd/core/__init__.py +23 -0
  3. de_ebsd/core/constants.py +81 -0
  4. de_ebsd/core/device.py +12 -0
  5. de_ebsd/core/geometry.py +67 -0
  6. de_ebsd/core/lambert.py +148 -0
  7. de_ebsd/core/rotation.py +139 -0
  8. de_ebsd/crystal/__init__.py +22 -0
  9. de_ebsd/crystal/data/__init__.py +0 -0
  10. de_ebsd/crystal/data/element_properties.py +29 -0
  11. de_ebsd/crystal/data/scattering_tables.py +414 -0
  12. de_ebsd/crystal/lattice.py +517 -0
  13. de_ebsd/crystal/scattering.py +339 -0
  14. de_ebsd/crystal/scattering_torch.py +233 -0
  15. de_ebsd/crystal/structure_factors.py +291 -0
  16. de_ebsd/diffraction/__init__.py +45 -0
  17. de_ebsd/diffraction/backends.py +318 -0
  18. de_ebsd/diffraction/bethe.py +28 -0
  19. de_ebsd/diffraction/kinematic.py +389 -0
  20. de_ebsd/diffraction/kvectors.py +120 -0
  21. de_ebsd/diffraction/prism.py +204 -0
  22. de_ebsd/diffraction/reflections.py +234 -0
  23. de_ebsd/index/__init__.py +61 -0
  24. de_ebsd/index/_kernels.py +222 -0
  25. de_ebsd/index/backproject.py +171 -0
  26. de_ebsd/index/crosscorr.py +654 -0
  27. de_ebsd/index/dictionary.py +362 -0
  28. de_ebsd/index/io.py +324 -0
  29. de_ebsd/index/live/__init__.py +5 -0
  30. de_ebsd/index/live/background.py +103 -0
  31. de_ebsd/index/live/calibration.py +70 -0
  32. de_ebsd/index/live/common.py +44 -0
  33. de_ebsd/index/live/deformation.py +289 -0
  34. de_ebsd/index/live/indexer.py +386 -0
  35. de_ebsd/index/preprocess.py +195 -0
  36. de_ebsd/index/refine/__init__.py +7 -0
  37. de_ebsd/index/refine/highres.py +479 -0
  38. de_ebsd/index/refine/levenberg.py +33 -0
  39. de_ebsd/index/refine/orientation.py +367 -0
  40. de_ebsd/index/refine/pattern_center.py +356 -0
  41. de_ebsd/index/spherical/__init__.py +41 -0
  42. de_ebsd/index/spherical/correlation.py +106 -0
  43. de_ebsd/index/spherical/harmonics.py +311 -0
  44. de_ebsd/index/spherical/indexer.py +345 -0
  45. de_ebsd/index/spherical/wigner.py +190 -0
  46. de_ebsd/sim/__init__.py +20 -0
  47. de_ebsd/sim/detector.py +145 -0
  48. de_ebsd/sim/masterpattern.py +231 -0
  49. de_ebsd/sim/microscope.py +93 -0
  50. de_ebsd/sim/montecarlo/__init__.py +19 -0
  51. de_ebsd/sim/montecarlo/depth.py +167 -0
  52. de_ebsd/sim/montecarlo/transport.py +532 -0
  53. de_ebsd/sim/simulator.py +291 -0
  54. de_ebsd/viz/__init__.py +53 -0
  55. de_ebsd/viz/camera.py +56 -0
  56. de_ebsd/viz/depth_widget.js +76 -0
  57. de_ebsd/viz/explorer.py +647 -0
  58. de_ebsd/viz/figures.py +67 -0
  59. de_ebsd/viz/maps.py +277 -0
  60. de_ebsd/viz/patterns.py +164 -0
  61. de_ebsd/viz/setup_widget.js +171 -0
  62. de_ebsd/viz/views.py +125 -0
  63. de_ebsd/viz/widgets.py +191 -0
  64. de_ebsd-0.2.0.dist-info/METADATA +251 -0
  65. de_ebsd-0.2.0.dist-info/RECORD +69 -0
  66. de_ebsd-0.2.0.dist-info/WHEEL +5 -0
  67. de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
  68. de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
  69. 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
+ )