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,389 @@
|
|
|
1
|
+
"""Lightweight kinematic STEM orientation patterns.
|
|
2
|
+
|
|
3
|
+
This module contains the geometry used by the orientation explorer. It is
|
|
4
|
+
deliberately independent of the master-pattern and Bloch-wave code: a
|
|
5
|
+
reflection is represented by a reciprocal-lattice vector, a Kikuchi line is a
|
|
6
|
+
gnomonic trace of its plane, and a diffraction spot is an Ewald-sphere
|
|
7
|
+
intersection. Diffsims is used only for the elastic structure-factor
|
|
8
|
+
intensity calculation through :class:`_DiffsimsElasticScatteringAdapter`.
|
|
9
|
+
|
|
10
|
+
All public geometry in this module uses nm and nm :sup:`-1`. The adapter is
|
|
11
|
+
the only place where conversion to diffsims' Angstrom convention occurs.
|
|
12
|
+
Quaternions are scalar-first ``(w, x, y, z)`` and rotate sample/beam-frame
|
|
13
|
+
vectors into the crystal frame, matching :meth:`MasterPattern.project`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from math import gcd
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from de_ebsd.core.constants import electron_wavelength_nm
|
|
25
|
+
from de_ebsd.core.rotation import matrix_to_quaternion
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"KinematicPattern",
|
|
29
|
+
"KinematicPatternModel",
|
|
30
|
+
"orientation_from_zone_axis",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_ABSENCE_TOL = 1.0e-8
|
|
34
|
+
_GEOMETRY_TOL = 1.0e-10
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class KinematicPattern:
|
|
39
|
+
"""One projected kinematic pattern.
|
|
40
|
+
|
|
41
|
+
``lines`` contains clipped line segments in gnomonic slope coordinates,
|
|
42
|
+
with shape ``(L, 2, 2)`` and the corresponding Miller indices in
|
|
43
|
+
``line_hkl``. ``spots`` and ``spot_hkl`` contain only reflections that
|
|
44
|
+
intersect the requested Ewald-sphere excitation window and the displayed
|
|
45
|
+
square. ``intensities`` are relative values normalized to the strongest
|
|
46
|
+
*non-direct* spot. The direct beam is represented separately at
|
|
47
|
+
``direct_beam`` so it cannot accidentally affect that normalization.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
lines: np.ndarray
|
|
51
|
+
line_hkl: np.ndarray
|
|
52
|
+
spots: np.ndarray
|
|
53
|
+
spot_hkl: np.ndarray
|
|
54
|
+
intensities: np.ndarray
|
|
55
|
+
direct_beam: np.ndarray = field(default_factory=lambda: np.zeros(2, dtype=np.float64))
|
|
56
|
+
excitation_errors: np.ndarray = field(
|
|
57
|
+
default_factory=lambda: np.empty(0, dtype=np.float64), repr=False
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _DiffsimsElasticScatteringAdapter:
|
|
62
|
+
"""Use diffsims' Lobato factors with per-site occupancies and thermal motion.
|
|
63
|
+
|
|
64
|
+
Geometry stays in the native crystal frame. Only reciprocal lengths and
|
|
65
|
+
isotropic B factors cross the Angstrom unit boundary. Fractional atom
|
|
66
|
+
positions and Miller indices are basis duals, independent of Cartesian
|
|
67
|
+
conventions. The full symmetry-expanded cell supplies basis extinctions.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, crystal: Any) -> None:
|
|
71
|
+
from diffsims.utils.sim_utils import (
|
|
72
|
+
get_atomic_scattering_factors,
|
|
73
|
+
get_scattering_params_dict,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._scattering = get_atomic_scattering_factors
|
|
77
|
+
parameters = get_scattering_params_dict("lobato")
|
|
78
|
+
atoms = [
|
|
79
|
+
(site, position)
|
|
80
|
+
for site, positions in zip(crystal.sites, crystal.positions, strict=True)
|
|
81
|
+
for position in positions
|
|
82
|
+
]
|
|
83
|
+
if not atoms:
|
|
84
|
+
raise ValueError("kinematic patterns require at least one atom")
|
|
85
|
+
missing = sorted({site.element for site, _ in atoms} - parameters.keys())
|
|
86
|
+
if missing:
|
|
87
|
+
raise ValueError(f"Lobato scattering factors unavailable for {', '.join(missing)}")
|
|
88
|
+
self._positions = np.asarray([position for _, position in atoms], dtype=float)
|
|
89
|
+
self._occupancies = np.asarray([site.occupancy for site, _ in atoms], dtype=float)
|
|
90
|
+
self._b_angstrom2 = np.asarray([site.debye_waller * 100 for site, _ in atoms])
|
|
91
|
+
self._coeffs = np.asarray([parameters[site.element] for site, _ in atoms])
|
|
92
|
+
if (
|
|
93
|
+
not np.isfinite(self._positions).all()
|
|
94
|
+
or not np.isfinite(self._occupancies).all()
|
|
95
|
+
or not np.isfinite(self._b_angstrom2).all()
|
|
96
|
+
or np.any((self._occupancies < 0) | (self._occupancies > 1))
|
|
97
|
+
or np.any(self._b_angstrom2 < 0)
|
|
98
|
+
):
|
|
99
|
+
raise ValueError("atoms require finite positions, occupancy in [0, 1], and B >= 0")
|
|
100
|
+
|
|
101
|
+
def elastic_intensities(self, hkl: np.ndarray, g_nm: np.ndarray) -> np.ndarray:
|
|
102
|
+
"""Return coherent elastic |F_g| squared, in bounded reflection batches."""
|
|
103
|
+
hkl = np.asarray(hkl, dtype=np.int64)
|
|
104
|
+
g_angstrom = np.asarray(g_nm, dtype=float) * 0.1
|
|
105
|
+
result = np.empty(len(hkl), dtype=float)
|
|
106
|
+
for start in range(0, len(hkl), 256):
|
|
107
|
+
take = slice(start, start + 256)
|
|
108
|
+
g2 = g_angstrom[take] ** 2
|
|
109
|
+
atomic = self._scattering(g2, self._coeffs, "lobato")
|
|
110
|
+
phase = np.exp(2j * np.pi * (hkl[take] @ self._positions.T))
|
|
111
|
+
dw = np.exp(-0.25 * g2[:, None] * self._b_angstrom2)
|
|
112
|
+
amplitudes = np.sum(atomic * self._occupancies * phase * dw, axis=1)
|
|
113
|
+
result[take] = np.abs(amplitudes) ** 2
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validate_energy(value: float) -> float:
|
|
118
|
+
value = float(value)
|
|
119
|
+
if not np.isfinite(value) or value <= 0.0:
|
|
120
|
+
raise ValueError(f"energy_kev must be finite and > 0, got {value!r}")
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_dmin(value: float) -> float:
|
|
125
|
+
value = float(value)
|
|
126
|
+
if not np.isfinite(value) or value <= 0.0:
|
|
127
|
+
raise ValueError(f"dmin must be finite and > 0 nm, got {value!r}")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _as_rotation_matrix(orientation: Any) -> np.ndarray:
|
|
132
|
+
"""Convert one scalar-first quaternion (or proper 3x3 matrix) to ``R``."""
|
|
133
|
+
|
|
134
|
+
data = getattr(orientation, "data", orientation)
|
|
135
|
+
q_or_r = np.asarray(data, dtype=np.float64)
|
|
136
|
+
if q_or_r.shape == (3, 3):
|
|
137
|
+
if not np.all(np.isfinite(q_or_r)):
|
|
138
|
+
raise ValueError("orientation matrix must contain finite values")
|
|
139
|
+
if not np.allclose(q_or_r.T @ q_or_r, np.eye(3), atol=1e-7, rtol=1e-7):
|
|
140
|
+
raise ValueError("orientation matrix must be orthonormal")
|
|
141
|
+
if not np.isclose(np.linalg.det(q_or_r), 1.0, atol=1e-7, rtol=1e-7):
|
|
142
|
+
raise ValueError("orientation matrix must have determinant +1")
|
|
143
|
+
return q_or_r
|
|
144
|
+
if q_or_r.shape == (1, 4):
|
|
145
|
+
q_or_r = q_or_r[0]
|
|
146
|
+
if q_or_r.shape != (4,):
|
|
147
|
+
raise ValueError(
|
|
148
|
+
"orientation must be one scalar-first quaternion with shape (4,) "
|
|
149
|
+
"or a proper rotation matrix with shape (3, 3)"
|
|
150
|
+
)
|
|
151
|
+
if not np.all(np.isfinite(q_or_r)):
|
|
152
|
+
raise ValueError("orientation quaternion must contain finite values")
|
|
153
|
+
norm = float(np.linalg.norm(q_or_r))
|
|
154
|
+
if norm <= _GEOMETRY_TOL:
|
|
155
|
+
raise ValueError("orientation quaternion must have nonzero norm")
|
|
156
|
+
w, x, y, z = q_or_r / norm
|
|
157
|
+
return np.array(
|
|
158
|
+
[
|
|
159
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
|
160
|
+
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
|
161
|
+
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
|
162
|
+
],
|
|
163
|
+
dtype=np.float64,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def orientation_from_zone_axis(crystal: Any, uvw: Any, roll_deg: float = 0.0) -> np.ndarray:
|
|
168
|
+
"""Return the orientation that places direct-lattice direction ``uvw`` on +z.
|
|
169
|
+
|
|
170
|
+
``uvw`` is interpreted in the direct lattice basis, then converted with
|
|
171
|
+
``Crystal.direct_structure_matrix``. This distinction is essential for
|
|
172
|
+
non-cubic cells, where treating ``uvw`` as Cartesian gives the wrong zone.
|
|
173
|
+
``roll_deg`` rotates the screen x/y axes around the resulting beam axis.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
uvw = np.asarray(uvw, dtype=np.float64)
|
|
177
|
+
if uvw.shape != (3,) or not np.all(np.isfinite(uvw)):
|
|
178
|
+
raise ValueError("uvw must be a finite three-component direct-lattice direction")
|
|
179
|
+
direct = uvw @ np.asarray(crystal.direct_structure_matrix, dtype=np.float64).T
|
|
180
|
+
norm = float(np.linalg.norm(direct))
|
|
181
|
+
if norm <= _GEOMETRY_TOL:
|
|
182
|
+
raise ValueError("uvw must not be the zero direction")
|
|
183
|
+
beam = direct / norm
|
|
184
|
+
roll = float(roll_deg)
|
|
185
|
+
if not np.isfinite(roll):
|
|
186
|
+
raise ValueError("roll_deg must be finite")
|
|
187
|
+
|
|
188
|
+
# Choose a deterministic in-plane basis. The e_y reference makes [001]
|
|
189
|
+
# return the identity orientation, which is convenient for the explorer.
|
|
190
|
+
ref = np.array([0.0, 1.0, 0.0])
|
|
191
|
+
if abs(float(np.dot(ref, beam))) > 0.95:
|
|
192
|
+
ref = np.array([1.0, 0.0, 0.0])
|
|
193
|
+
x_axis = np.cross(ref, beam)
|
|
194
|
+
x_axis /= np.linalg.norm(x_axis)
|
|
195
|
+
y_axis = np.cross(beam, x_axis)
|
|
196
|
+
base = np.column_stack((x_axis, y_axis, beam))
|
|
197
|
+
theta = np.deg2rad(roll)
|
|
198
|
+
around_beam = np.array(
|
|
199
|
+
[
|
|
200
|
+
[np.cos(theta), -np.sin(theta), 0.0],
|
|
201
|
+
[np.sin(theta), np.cos(theta), 0.0],
|
|
202
|
+
[0.0, 0.0, 1.0],
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
return matrix_to_quaternion(base @ around_beam)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _primitive_hkl(hkl: np.ndarray) -> tuple[int, int, int]:
|
|
209
|
+
"""Canonical sign and harmonic representative for a plane family."""
|
|
210
|
+
|
|
211
|
+
values = [int(v) for v in hkl]
|
|
212
|
+
divisor = gcd(gcd(abs(values[0]), abs(values[1])), abs(values[2]))
|
|
213
|
+
values = [v // divisor for v in values]
|
|
214
|
+
for v in values:
|
|
215
|
+
if v:
|
|
216
|
+
if v < 0:
|
|
217
|
+
values = [-x for x in values]
|
|
218
|
+
break
|
|
219
|
+
return tuple(values)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _clip_line(normal: np.ndarray, bound: float) -> np.ndarray | None:
|
|
223
|
+
"""Clip ``normal[:2] @ (x,y) + normal[2] == 0`` to a square."""
|
|
224
|
+
|
|
225
|
+
nx, ny, nz = (float(v) for v in normal)
|
|
226
|
+
if abs(nx) <= _GEOMETRY_TOL and abs(ny) <= _GEOMETRY_TOL:
|
|
227
|
+
return None
|
|
228
|
+
points: list[tuple[float, float]] = []
|
|
229
|
+
if abs(ny) > _GEOMETRY_TOL:
|
|
230
|
+
for x in (-bound, bound):
|
|
231
|
+
y = -(nx * x + nz) / ny
|
|
232
|
+
if -bound - _GEOMETRY_TOL <= y <= bound + _GEOMETRY_TOL:
|
|
233
|
+
points.append((x, float(np.clip(y, -bound, bound))))
|
|
234
|
+
if abs(nx) > _GEOMETRY_TOL:
|
|
235
|
+
for y in (-bound, bound):
|
|
236
|
+
x = -(ny * y + nz) / nx
|
|
237
|
+
if -bound - _GEOMETRY_TOL <= x <= bound + _GEOMETRY_TOL:
|
|
238
|
+
points.append((float(np.clip(x, -bound, bound)), y))
|
|
239
|
+
unique: list[tuple[float, float]] = []
|
|
240
|
+
for point in points:
|
|
241
|
+
if not any(np.linalg.norm(np.subtract(point, old)) < 1.0e-9 for old in unique):
|
|
242
|
+
unique.append(point)
|
|
243
|
+
if len(unique) < 2:
|
|
244
|
+
return None
|
|
245
|
+
# Deterministic endpoint ordering helps artist updates and tests.
|
|
246
|
+
unique.sort(key=lambda p: (p[0], p[1]))
|
|
247
|
+
return np.asarray(unique[:2], dtype=np.float64)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class KinematicPatternModel:
|
|
251
|
+
"""Cached reflection geometry and lightweight kinematic STEM projection."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, crystal: Any, energy_kev: float = 200, dmin: float = 0.1) -> None:
|
|
254
|
+
if not hasattr(crystal, "g_cartesian") or not hasattr(crystal, "direct_structure_matrix"):
|
|
255
|
+
raise TypeError("crystal must be a de_ebsd Crystal")
|
|
256
|
+
self.crystal = crystal
|
|
257
|
+
self.dmin = _validate_dmin(dmin)
|
|
258
|
+
self._energy_kev = _validate_energy(energy_kev)
|
|
259
|
+
# Enumerate the reciprocal sphere once. Intensities and absences are
|
|
260
|
+
# also orientation independent, so they are cached on the model.
|
|
261
|
+
self._adapter = _DiffsimsElasticScatteringAdapter(crystal)
|
|
262
|
+
# The reciprocal-space sphere can project farther along a Miller
|
|
263
|
+
# coordinate than its axial reflection in a skew cell (metric terms
|
|
264
|
+
# can cancel). The direct-metric ellipsoid bound
|
|
265
|
+
# ``|h_i| <= |a_i| / dmin`` is safe for every cell.
|
|
266
|
+
lengths = np.asarray([crystal.a, crystal.b, crystal.c], dtype=float)
|
|
267
|
+
if not np.isfinite(lengths).all() or np.any(lengths <= 0):
|
|
268
|
+
raise ValueError("lattice lengths must be finite and positive")
|
|
269
|
+
limits = np.ceil(lengths / self.dmin)
|
|
270
|
+
if np.prod(2 * limits + 1) > 2_000_000:
|
|
271
|
+
raise ValueError("reflection search too large for the explorer; increase dmin")
|
|
272
|
+
axes = [np.arange(-int(limit), int(limit) + 1) for limit in limits]
|
|
273
|
+
hh, kk, ll = np.meshgrid(*axes, indexing="ij")
|
|
274
|
+
all_hkl = np.stack((hh, kk, ll), axis=-1).reshape(-1, 3)
|
|
275
|
+
all_g = np.asarray(crystal.g_length(all_hkl), dtype=np.float64)
|
|
276
|
+
keep = all_g > 0.0
|
|
277
|
+
keep &= np.divide(1.0, all_g, out=np.zeros_like(all_g), where=all_g > 0.0) >= self.dmin
|
|
278
|
+
keep &= crystal.allowed_by_centering(all_hkl)
|
|
279
|
+
order = np.argsort(all_g[keep], kind="stable")
|
|
280
|
+
self._all_hkl = all_hkl[keep][order]
|
|
281
|
+
self._all_g = all_g[keep][order]
|
|
282
|
+
self._all_g_cart = np.asarray(crystal.g_cartesian(self._all_hkl), dtype=np.float64)
|
|
283
|
+
all_elastic = self._adapter.elastic_intensities(self._all_hkl, self._all_g)
|
|
284
|
+
allowed = all_elastic > _ABSENCE_TOL
|
|
285
|
+
self._elastic = all_elastic[allowed].copy()
|
|
286
|
+
self.forbidden_hkl = self._all_hkl[~allowed].copy()
|
|
287
|
+
self._hkl = self._all_hkl[allowed].copy()
|
|
288
|
+
self._g = self._all_g[allowed].copy()
|
|
289
|
+
self._g_cart = self._all_g_cart[allowed].copy()
|
|
290
|
+
|
|
291
|
+
# Unique primitive planes, retaining the shortest allowed harmonic.
|
|
292
|
+
selected: dict[tuple[int, int, int], int] = {}
|
|
293
|
+
for i, hkl in enumerate(self._hkl):
|
|
294
|
+
selected.setdefault(_primitive_hkl(hkl), i)
|
|
295
|
+
plane_indices = np.asarray(list(selected.values()), dtype=np.int64)
|
|
296
|
+
self._band_hkl = self._hkl[plane_indices].copy()
|
|
297
|
+
band_g = self._g_cart[plane_indices]
|
|
298
|
+
self._band_normals = band_g / np.linalg.norm(band_g, axis=1, keepdims=True)
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def energy_kev(self) -> float:
|
|
302
|
+
return self._energy_kev
|
|
303
|
+
|
|
304
|
+
@energy_kev.setter
|
|
305
|
+
def energy_kev(self, value: float) -> None:
|
|
306
|
+
self._energy_kev = _validate_energy(value)
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def band_normals(self) -> np.ndarray:
|
|
310
|
+
return self._band_normals.copy()
|
|
311
|
+
|
|
312
|
+
def project(
|
|
313
|
+
self,
|
|
314
|
+
orientation: Any,
|
|
315
|
+
semiangle_deg: float = 5,
|
|
316
|
+
excitation_width: float = 0.1,
|
|
317
|
+
) -> KinematicPattern:
|
|
318
|
+
"""Project centerlines and Ewald-aware spots into a square view."""
|
|
319
|
+
|
|
320
|
+
semiangle = float(semiangle_deg)
|
|
321
|
+
width = float(excitation_width)
|
|
322
|
+
if not np.isfinite(semiangle) or not 0.0 < semiangle < 89.9:
|
|
323
|
+
raise ValueError("semiangle_deg must be finite and in (0, 89.9)")
|
|
324
|
+
if not np.isfinite(width) or width <= 0.0:
|
|
325
|
+
raise ValueError("excitation_width must be finite and > 0 nm^-1")
|
|
326
|
+
rotation = _as_rotation_matrix(orientation)
|
|
327
|
+
bound = float(np.tan(np.deg2rad(semiangle)))
|
|
328
|
+
|
|
329
|
+
# Crystal -> beam/sample frame is R.T; with row vectors this is g @ R.
|
|
330
|
+
normals_beam = self._band_normals @ rotation
|
|
331
|
+
line_segments: list[np.ndarray] = []
|
|
332
|
+
line_indices: list[np.ndarray] = []
|
|
333
|
+
for normal, hkl in zip(normals_beam, self._band_hkl, strict=True):
|
|
334
|
+
segment = _clip_line(normal, bound)
|
|
335
|
+
if segment is not None:
|
|
336
|
+
line_segments.append(segment)
|
|
337
|
+
line_indices.append(hkl)
|
|
338
|
+
|
|
339
|
+
wavelength = float(electron_wavelength_nm(self._energy_kev))
|
|
340
|
+
g_beam = self._g_cart @ rotation
|
|
341
|
+
k_radius = 1.0 / wavelength
|
|
342
|
+
transverse_sq = np.sum(g_beam[:, :2] ** 2, axis=1)
|
|
343
|
+
real_ewald = transverse_sq <= k_radius * k_radius
|
|
344
|
+
expected_z = np.full(len(g_beam), np.nan, dtype=np.float64)
|
|
345
|
+
# With the documented incident beam +z and scattering vector
|
|
346
|
+
# ``g = k_out - k_in``, the forward Ewald intersection has a small
|
|
347
|
+
# negative z component: g_z = sqrt(k²-|g_perp|²) - k.
|
|
348
|
+
# Rationalized form avoids subtracting nearly equal large values.
|
|
349
|
+
expected_z[real_ewald] = -transverse_sq[real_ewald] / (
|
|
350
|
+
k_radius + np.sqrt(k_radius * k_radius - transverse_sq[real_ewald])
|
|
351
|
+
)
|
|
352
|
+
excitation = g_beam[:, 2] - expected_z
|
|
353
|
+
selected = real_ewald & (np.abs(excitation) <= width)
|
|
354
|
+
selected_idx = np.flatnonzero(selected)
|
|
355
|
+
if len(selected_idx):
|
|
356
|
+
transverse = wavelength * g_beam[selected_idx, :2]
|
|
357
|
+
transverse_norm = np.linalg.norm(transverse, axis=1)
|
|
358
|
+
front = transverse_norm < 1.0 - _GEOMETRY_TOL
|
|
359
|
+
selected_idx = selected_idx[front]
|
|
360
|
+
transverse = transverse[front]
|
|
361
|
+
transverse_norm = transverse_norm[front]
|
|
362
|
+
# The spot position is set by the transverse Ewald intersection,
|
|
363
|
+
# not by the off-sphere g_z of a finite-width rel-rod.
|
|
364
|
+
slopes = (
|
|
365
|
+
transverse / np.sqrt(np.maximum(1.0 - transverse_norm**2, _GEOMETRY_TOL))[:, None]
|
|
366
|
+
)
|
|
367
|
+
in_view = np.all(np.abs(slopes) <= bound + _GEOMETRY_TOL, axis=1)
|
|
368
|
+
selected_idx = selected_idx[in_view]
|
|
369
|
+
slopes = slopes[in_view]
|
|
370
|
+
else:
|
|
371
|
+
slopes = np.empty((0, 2), dtype=np.float64)
|
|
372
|
+
|
|
373
|
+
spot_hkl = self._hkl[selected_idx].copy()
|
|
374
|
+
spot_excitation = excitation[selected_idx]
|
|
375
|
+
shape = 1.0 / (1.0 + (spot_excitation / width) ** 2)
|
|
376
|
+
intensities = self._elastic[selected_idx] * shape
|
|
377
|
+
if len(intensities):
|
|
378
|
+
maximum = float(np.max(intensities))
|
|
379
|
+
if maximum > 0.0:
|
|
380
|
+
intensities = intensities / maximum
|
|
381
|
+
|
|
382
|
+
return KinematicPattern(
|
|
383
|
+
lines=np.asarray(line_segments, dtype=np.float64).reshape(-1, 2, 2),
|
|
384
|
+
line_hkl=np.asarray(line_indices, dtype=np.int64).reshape(-1, 3),
|
|
385
|
+
spots=slopes,
|
|
386
|
+
spot_hkl=spot_hkl,
|
|
387
|
+
intensities=np.asarray(intensities, dtype=np.float64),
|
|
388
|
+
excitation_errors=np.asarray(spot_excitation, dtype=np.float64),
|
|
389
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Incident beam directions on the master pattern grid.
|
|
2
|
+
|
|
3
|
+
For the master pattern every pixel of the square Lambert grid is one incident
|
|
4
|
+
beam direction (EMsoft ``Calckvectors`` with the Rosca-Lambert map mode). Both
|
|
5
|
+
hemispheres are sampled; :func:`reduce_to_fundamental_sector` then folds the
|
|
6
|
+
directions onto the crystal point group's fundamental sector so a backend
|
|
7
|
+
computes each symmetry-distinct direction only once.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import torch
|
|
16
|
+
|
|
17
|
+
from de_ebsd.core.lambert import master_pattern_grid
|
|
18
|
+
|
|
19
|
+
__all__ = ["KVectorGrid", "sample_incident_directions", "reduce_to_fundamental_sector"]
|
|
20
|
+
|
|
21
|
+
# (cache_key, space_group) -> (reps_cpu, inverse_cpu). The fold is deterministic
|
|
22
|
+
# per grid + point group, so it is computed once and reused across energy bins,
|
|
23
|
+
# refine() calls, and reruns.
|
|
24
|
+
_FZ_CACHE: dict[tuple, tuple[np.ndarray, np.ndarray]] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dedup_on_device(reduced: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
28
|
+
q = torch.round(reduced.double() * 1e5).to(torch.int64) + 100_001 # >0, < 200_003
|
|
29
|
+
base = 200_003
|
|
30
|
+
packed = (q[:, 0] * base + q[:, 1]) * base + q[:, 2]
|
|
31
|
+
_, inverse = torch.unique(packed, return_inverse=True)
|
|
32
|
+
n = int(inverse.max()) + 1 if inverse.numel() else 0
|
|
33
|
+
order = torch.arange(reduced.shape[0], device=reduced.device)
|
|
34
|
+
first = torch.full((n,), reduced.shape[0], dtype=torch.long, device=reduced.device)
|
|
35
|
+
first = first.scatter_reduce(0, inverse, order, reduce="amin", include_self=True)
|
|
36
|
+
return reduced[first], inverse
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def reduce_to_fundamental_sector(
|
|
40
|
+
directions: torch.Tensor, space_group: int, cache_key=None
|
|
41
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
42
|
+
"""Fold directions onto the point-group fundamental sector.
|
|
43
|
+
|
|
44
|
+
The master pattern is invariant under the crystal point group -- ``I(k) =
|
|
45
|
+
I(g k)`` -- so directions related by symmetry share one intensity. Reduces
|
|
46
|
+
every direction to its canonical fundamental-sector representative (orix
|
|
47
|
+
``in_fundamental_sector``) and deduplicates on-device: symmetry-equivalent
|
|
48
|
+
grid points map to a bit-identical representative, so the fold is exact (no
|
|
49
|
+
interpolation). Cubic grids collapse ~15x; low-symmetry groups less.
|
|
50
|
+
|
|
51
|
+
``cache_key`` (e.g. the grid ``npx``) memoizes the result -- the fold depends
|
|
52
|
+
only on the grid and the point group, so passing it makes repeated calls
|
|
53
|
+
(energy bins, ``refine``) free.
|
|
54
|
+
|
|
55
|
+
Returns ``(reps, inverse)`` -- the unique representative directions to
|
|
56
|
+
evaluate, and an index mapping each input direction to its representative,
|
|
57
|
+
so ``full_values = backend(reps)[inverse]``.
|
|
58
|
+
"""
|
|
59
|
+
dev, dt = directions.device, directions.dtype
|
|
60
|
+
ck = (cache_key, int(space_group)) if cache_key is not None else None
|
|
61
|
+
if ck is not None and ck in _FZ_CACHE:
|
|
62
|
+
reps_cpu, inverse_cpu = _FZ_CACHE[ck]
|
|
63
|
+
return (
|
|
64
|
+
torch.as_tensor(reps_cpu, device=dev, dtype=dt),
|
|
65
|
+
torch.as_tensor(inverse_cpu, device=dev, dtype=torch.long),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
from orix.quaternion.symmetry import get_point_group
|
|
69
|
+
from orix.vector import Vector3d
|
|
70
|
+
|
|
71
|
+
pg = get_point_group(int(space_group))
|
|
72
|
+
reduced = Vector3d(directions.detach().cpu().numpy()).in_fundamental_sector(pg).data
|
|
73
|
+
reps, inverse = _dedup_on_device(torch.as_tensor(reduced, device=dev, dtype=dt))
|
|
74
|
+
if ck is not None:
|
|
75
|
+
_FZ_CACHE[ck] = (reps.detach().cpu().numpy(), inverse.detach().cpu().numpy())
|
|
76
|
+
return reps, inverse
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class KVectorGrid:
|
|
81
|
+
"""Incident-beam directions covering the sphere.
|
|
82
|
+
|
|
83
|
+
Attributes
|
|
84
|
+
----------
|
|
85
|
+
npx
|
|
86
|
+
Half-width of the square Lambert grid; each hemisphere map has shape
|
|
87
|
+
``(2 npx + 1, 2 npx + 1)``.
|
|
88
|
+
directions
|
|
89
|
+
Unit vectors, shape ``(2, 2 npx + 1, 2 npx + 1, 3)``; index 0 is the
|
|
90
|
+
northern hemisphere (z >= 0), index 1 the southern.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
npx: int
|
|
94
|
+
directions: torch.Tensor
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def n_directions(self) -> int:
|
|
98
|
+
return 2 * (2 * self.npx + 1) ** 2
|
|
99
|
+
|
|
100
|
+
def flat(self) -> torch.Tensor:
|
|
101
|
+
"""All directions as a flat (K, 3) tensor (north first, row-major)."""
|
|
102
|
+
return self.directions.reshape(-1, 3)
|
|
103
|
+
|
|
104
|
+
def show(self, title: str = "Incident-direction sampling"):
|
|
105
|
+
"""Display the sampled incident directions on an interactive sphere."""
|
|
106
|
+
from de_ebsd.viz.patterns import sampling_sphere_figure
|
|
107
|
+
|
|
108
|
+
return sampling_sphere_figure(self.flat().detach().cpu().numpy(), title=title)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def sample_incident_directions(
|
|
112
|
+
npx: int,
|
|
113
|
+
device: torch.device | str | None = None,
|
|
114
|
+
dtype: torch.dtype = torch.float32,
|
|
115
|
+
) -> KVectorGrid:
|
|
116
|
+
"""Both hemispheres of the square Lambert grid as unit direction vectors."""
|
|
117
|
+
north = master_pattern_grid(npx, device=device, dtype=dtype)
|
|
118
|
+
south = north.clone()
|
|
119
|
+
south[..., 2] = -south[..., 2]
|
|
120
|
+
return KVectorGrid(npx=npx, directions=torch.stack([north, south], dim=0))
|