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,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))