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,190 @@
1
+ """Wigner d/D matrices and the SOFT grid on SO(3).
2
+
3
+ Real Wigner-d at beta = pi/2 by stable recursion, the (alpha, beta, gamma) SOFT
4
+ sampling grid, and the bridge from ZYZ Euler angles to orix ``Rotation``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import torch
12
+
13
+ __all__ = [
14
+ "euler_zyz_to_orix",
15
+ "grid_to_rotation",
16
+ "n_orientations",
17
+ "so3_grid",
18
+ "wigner_D",
19
+ "wigner_d",
20
+ "wigner_small_d",
21
+ ]
22
+
23
+
24
+ def _small_d_generator(
25
+ ell: int, device: torch.device | str | None, dtype: torch.dtype
26
+ ) -> torch.Tensor:
27
+ n = 2 * ell + 1
28
+ k = torch.zeros((n, n), device=device, dtype=dtype)
29
+ if ell > 0:
30
+ i = torch.arange(2 * ell, device=device, dtype=dtype)
31
+ c = 0.5 * torch.sqrt((2 * ell - i) * (i + 1)) # coupling on the i-th sub-diagonal
32
+ idx = torch.arange(2 * ell, device=device)
33
+ k[idx + 1, idx] = -c # (m+1, m)
34
+ k[idx, idx + 1] = c # (m, m+1) = -(m+1, m) -> antisymmetric
35
+ return k
36
+
37
+
38
+ def wigner_small_d(
39
+ ell: int,
40
+ beta: torch.Tensor,
41
+ ) -> torch.Tensor:
42
+ """Real small Wigner-d matrix ``d^l_{m'm}(beta)`` for a single degree ``ell``.
43
+
44
+ ``beta`` is a real tensor of any shape. Returns ``(*beta.shape, 2l+1, 2l+1)``
45
+ real, with row index ``m'`` and column index ``m`` (both ``-l .. l``), computed
46
+ as ``matrix_exp(beta * K_l)`` (the ``exp(-i beta J_y)`` convention).
47
+ """
48
+ k = _small_d_generator(ell, beta.device, beta.dtype)
49
+ mats = beta[..., None, None] * k # (*beta.shape, n, n)
50
+ return torch.matrix_exp(mats)
51
+
52
+
53
+ def wigner_d(
54
+ bandwidth: int,
55
+ beta: torch.Tensor,
56
+ ) -> torch.Tensor:
57
+ """Small Wigner-d matrices ``d^l_{m'm}(beta)`` for all ``l <= bandwidth``.
58
+
59
+ Returns a padded real tensor of shape ``(*beta.shape, L+1, 2L+1, 2L+1)``: axis
60
+ ``-3`` is the degree ``l = 0 .. L``, axes ``-2`` / ``-1`` are the orders
61
+ ``m' = idx - L`` (row) and ``m = idx - L`` (column). Entries with ``|m'| > l`` or
62
+ ``|m| > l`` are zero, so degree ``l`` occupies the centred ``(2l+1)``-block.
63
+ """
64
+ L = int(bandwidth)
65
+ out = beta.new_zeros((*beta.shape, L + 1, 2 * L + 1, 2 * L + 1))
66
+ for ell in range(L + 1):
67
+ d_l = wigner_small_d(ell, beta) # (*beta.shape, 2l+1, 2l+1)
68
+ lo, hi = L - ell, L + ell + 1
69
+ out[..., ell, lo:hi, lo:hi] = d_l
70
+ return out
71
+
72
+
73
+ def wigner_D(
74
+ bandwidth: int,
75
+ alpha: torch.Tensor,
76
+ beta: torch.Tensor,
77
+ gamma: torch.Tensor,
78
+ ) -> torch.Tensor:
79
+ """Full complex Wigner-D matrices ``D^l_{m'm}(alpha, beta, gamma)`` for ``l <= L``.
80
+
81
+ ``alpha``, ``beta``, ``gamma`` are real tensors, broadcast to a common shape
82
+ ``S``. Returns a padded complex tensor ``(*S, L+1, 2L+1, 2L+1)`` with
83
+
84
+ D^l_{m'm} = exp(-i m' alpha) d^l_{m'm}(beta) exp(-i m gamma),
85
+
86
+ row index ``m'`` (axis ``-2``), column index ``m`` (axis ``-1``), both
87
+ ``m = idx - L``. Entries outside ``|m'|, |m| <= l`` are zero.
88
+ """
89
+ L = int(bandwidth)
90
+ alpha, beta, gamma = torch.broadcast_tensors(alpha, beta, gamma)
91
+ d = wigner_d(L, beta) # (*S, L+1, 2L+1, 2L+1), real
92
+ cdtype = torch.complex128 if d.dtype == torch.float64 else torch.complex64
93
+ orders = torch.arange(-L, L + 1, device=d.device, dtype=d.dtype) # (2L+1,)
94
+ # row phase exp(-i m' alpha) and column phase exp(-i m gamma)
95
+ row_phase = torch.exp(-1j * orders * alpha[..., None].to(d.dtype)) # (*S, 2L+1)
96
+ col_phase = torch.exp(-1j * orders * gamma[..., None].to(d.dtype)) # (*S, 2L+1)
97
+ D = d.to(cdtype)
98
+ D = D * row_phase[..., None, :, None] # broadcast over degree and column
99
+ D = D * col_phase[..., None, None, :] # broadcast over degree and row
100
+ return D
101
+
102
+
103
+ def so3_grid(
104
+ bandwidth: int,
105
+ device: torch.device | str | None = None,
106
+ dtype: torch.dtype = torch.float64,
107
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
108
+ """The equiangular SOFT SO(3) sampling grid for bandwidth ``L``.
109
+
110
+ Returns the three 1-D angle vectors ``(alpha, beta, gamma)`` of the separable
111
+ grid, each of length ``N = 2L + 2`` (SOFT bandwidth ``B = L + 1``):
112
+
113
+ - ``alpha[j] = 2 pi j / N`` and ``gamma[p] = 2 pi p / N`` -- equiangular in
114
+ ``[0, 2 pi)``, the Fourier grids conjugate to the orders ``m', m`` (so the
115
+ correlation is an inverse FFT over ``alpha`` and ``gamma``);
116
+ - ``beta[k] = pi (2k + 1) / (2 N)`` -- the ``2N``-point equiangular colatitude
117
+ grid in ``(0, pi)`` (no samples at the poles).
118
+
119
+ The cross-correlation returned by :func:`.so3_cross_correlation` is indexed
120
+ ``C[..., j, k, p]`` on this ``(alpha, beta, gamma)`` grid; :func:`grid_to_rotation`
121
+ converts an ``(j, k, p)`` index to an orix Rotation.
122
+ """
123
+ L = int(bandwidth)
124
+ n = 2 * L + 2
125
+ j = torch.arange(n, device=device, dtype=dtype)
126
+ alpha = 2.0 * math.pi * j / n
127
+ gamma = 2.0 * math.pi * j / n
128
+ beta = math.pi * (2.0 * j + 1.0) / (2.0 * n)
129
+ return alpha, beta, gamma
130
+
131
+
132
+ def n_orientations(bandwidth: int) -> int:
133
+ """Number of SOFT grid points ``(2L + 2) ** 3`` for bandwidth ``L``."""
134
+ return (2 * bandwidth + 2) ** 3
135
+
136
+
137
+ def euler_zyz_to_orix(
138
+ alpha,
139
+ beta,
140
+ gamma,
141
+ ):
142
+ """Convert active ``ZYZ`` Euler angles to an :class:`orix.quaternion.Rotation`.
143
+
144
+ ``alpha``, ``beta``, ``gamma`` are array-likes (numpy arrays, torch tensors, or
145
+ scalars, in radians) broadcast to a common shape. The returned Rotation has that
146
+ shape and satisfies ``R.to_matrix() == (R_z(alpha) R_y(beta) R_z(gamma))^T``:
147
+ the SOFT triple is passed to orix's Bunge (``ZXZ``) ``from_euler`` as
148
+ ``(alpha + pi/2, beta, gamma - pi/2)``.
149
+ """
150
+ import numpy as np
151
+ from orix.quaternion import Rotation
152
+
153
+ def _to_np(x):
154
+ if isinstance(x, torch.Tensor):
155
+ return x.detach().cpu().numpy()
156
+ return np.asarray(x, dtype=float)
157
+
158
+ a = _to_np(alpha)
159
+ b = _to_np(beta)
160
+ g = _to_np(gamma)
161
+ a, b, g = np.broadcast_arrays(a, b, g)
162
+ phi1 = a + np.pi / 2.0
163
+ Phi = b
164
+ phi2 = g - np.pi / 2.0
165
+ euler = np.stack([phi1, Phi, phi2], axis=-1)
166
+ return Rotation.from_euler(euler)
167
+
168
+
169
+ def grid_to_rotation(
170
+ bandwidth: int,
171
+ alpha_index=None,
172
+ beta_index=None,
173
+ gamma_index=None,
174
+ ):
175
+ """Map SOFT grid indices to orix Rotations.
176
+
177
+ With no indices, returns the full grid as a Rotation of shape
178
+ ``(N, N, N)`` (``N = 2L + 2``) matching the ``C[..., j, k, p]`` correlation
179
+ layout -- index it directly with the peak location, e.g.
180
+ ``grid_to_rotation(L)[j, k, p]``. Passing ``alpha_index`` / ``beta_index`` /
181
+ ``gamma_index`` (ints or index arrays, broadcast together) returns the Rotation
182
+ at just those grid points. The conversion is :func:`euler_zyz_to_orix`.
183
+ """
184
+ alpha, beta, gamma = so3_grid(bandwidth)
185
+ if alpha_index is None and beta_index is None and gamma_index is None:
186
+ a, b, g = torch.meshgrid(alpha, beta, gamma, indexing="ij")
187
+ return euler_zyz_to_orix(a, b, g)
188
+ if alpha_index is None or beta_index is None or gamma_index is None:
189
+ raise ValueError("pass all three of alpha_index, beta_index, gamma_index, or none")
190
+ return euler_zyz_to_orix(alpha[alpha_index], beta[beta_index], gamma[gamma_index])
@@ -0,0 +1,20 @@
1
+ """Master-pattern generation: the simulator, its output, and the detector geometry.
2
+
3
+ The crystallography lives in :mod:`de_ebsd.crystal`, the diffraction physics in
4
+ :mod:`de_ebsd.diffraction`, and the electron transport in
5
+ :mod:`de_ebsd.sim.montecarlo`.
6
+ """
7
+
8
+ from de_ebsd.sim.detector import EBSDDetector
9
+ from de_ebsd.sim.masterpattern import MasterPattern
10
+ from de_ebsd.sim.microscope import Microscope
11
+ from de_ebsd.sim.montecarlo import DepthDistribution
12
+ from de_ebsd.sim.simulator import MasterPatternSimulator
13
+
14
+ __all__ = [
15
+ "DepthDistribution",
16
+ "EBSDDetector",
17
+ "MasterPattern",
18
+ "MasterPatternSimulator",
19
+ "Microscope",
20
+ ]
@@ -0,0 +1,145 @@
1
+ """EBSD detector geometry and per-pixel direction cosines.
2
+
3
+ Torch port of kikuchipy's ``EBSDDetector`` (Bruker pattern-center convention),
4
+ so patterns projected here match kikuchipy pixel for pixel.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+ from de_ebsd.core.geometry import sample_to_detector_matrix
15
+
16
+ __all__ = ["EBSDDetector"]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class EBSDDetector:
21
+ """An EBSD detector with a fixed pattern center.
22
+
23
+ Parameters
24
+ ----------
25
+ shape
26
+ (n_rows, n_cols) in pixels.
27
+ pattern_center
28
+ Pattern center (PCx, PCy, PCz) in the Bruker convention.
29
+ sample_tilt
30
+ Sample tilt in degrees (usually set from ``Microscope.sample_tilt``).
31
+ tilt
32
+ Detector tilt in degrees.
33
+ azimuthal
34
+ Detector azimuthal angle in degrees.
35
+ twist
36
+ Detector twist about its normal, in degrees.
37
+ px_size
38
+ Effective screen pixel pitch in micrometers, including binning (not
39
+ camera sensor pitch). Used for physical dimensions in the inspector;
40
+ pattern projection itself only depends on the normalized PC.
41
+ """
42
+
43
+ shape: tuple[int, int] = (480, 480)
44
+ pattern_center: tuple[float, float, float] = (0.5, 0.5, 0.5)
45
+ sample_tilt: float = 70.0
46
+ tilt: float = 0.0
47
+ azimuthal: float = 0.0
48
+ twist: float = 0.0
49
+ px_size: float = 1.0
50
+
51
+ @property
52
+ def nrows(self) -> int:
53
+ return int(self.shape[0])
54
+
55
+ @property
56
+ def ncols(self) -> int:
57
+ return int(self.shape[1])
58
+
59
+ @property
60
+ def size(self) -> int:
61
+ return self.nrows * self.ncols
62
+
63
+ @property
64
+ def aspect_ratio(self) -> float:
65
+ return self.ncols / self.nrows
66
+
67
+ @property
68
+ def gnomonic_bounds(self) -> np.ndarray:
69
+ """Detector bounds [x_min, x_max, y_min, y_max] in gnomonic coordinates."""
70
+ pcx, pcy, pcz = self.pattern_center
71
+ x_min = -self.aspect_ratio * pcx / pcz
72
+ x_max = self.aspect_ratio * (1.0 - pcx) / pcz
73
+ y_min = -(1.0 - pcy) / pcz
74
+ y_max = pcy / pcz
75
+ return np.array([x_min, x_max, y_min, y_max])
76
+
77
+ @property
78
+ def corners_sample_mm(self) -> np.ndarray:
79
+ """Top-left clockwise corners relative to the beam impact, in mm.
80
+
81
+ Uses the same Bruker PC and frame transform as direction_cosines.
82
+ Physical distances assume px_size is the effective screen pixel pitch
83
+ (including binning), not the camera sensor's pixel pitch.
84
+ """
85
+ height = self.nrows * self.px_size / 1000.0
86
+ width = self.ncols * self.px_size / 1000.0
87
+ x, y, z = self.pattern_center
88
+ corners = np.array(
89
+ [
90
+ [-x * width, y * height, z * height],
91
+ [(1 - x) * width, y * height, z * height],
92
+ [(1 - x) * width, -(1 - y) * height, z * height],
93
+ [-x * width, -(1 - y) * height, z * height],
94
+ ]
95
+ )
96
+ return corners @ sample_to_detector_matrix(
97
+ self.sample_tilt, self.tilt, self.azimuthal, self.twist
98
+ )
99
+
100
+ @property
101
+ def pattern_center_sample_mm(self) -> np.ndarray:
102
+ """Perpendicular foot from impact to screen, in sample coordinates."""
103
+ distance = self.pattern_center[2] * self.nrows * self.px_size / 1000.0
104
+ return (
105
+ distance
106
+ * sample_to_detector_matrix(self.sample_tilt, self.tilt, self.azimuthal, self.twist)[2]
107
+ )
108
+
109
+ def direction_cosines(
110
+ self,
111
+ device: torch.device | str | None = None,
112
+ dtype: torch.dtype = torch.float32,
113
+ ) -> torch.Tensor:
114
+ """Unit direction vectors (n_rows * n_cols, 3) in the sample frame.
115
+
116
+ Row-major over the detector (row 0 = top). Port of kikuchipy
117
+ ``_get_direction_cosines_for_fixed_pc``.
118
+ """
119
+ bounds = self.gnomonic_bounds
120
+ pcz = self.pattern_center[2]
121
+ x_scale = (bounds[1] - bounds[0]) / self.ncols
122
+ y_scale = (bounds[3] - bounds[2]) / self.nrows
123
+
124
+ gn_x = bounds[0] + x_scale * np.arange(self.ncols) + 0.5 * x_scale
125
+ gn_y = bounds[3] - y_scale * np.arange(self.nrows) - 0.5 * y_scale
126
+
127
+ det = np.empty((self.nrows, self.ncols, 3))
128
+ det[..., 0] = gn_x[None, :] * pcz
129
+ det[..., 1] = gn_y[:, None] * pcz
130
+ det[..., 2] = pcz
131
+
132
+ om = sample_to_detector_matrix(self.sample_tilt, self.tilt, self.azimuthal, self.twist)
133
+ dc = det.reshape(-1, 3) @ om # (det_to_sample = om.T; row vectors: v @ om)
134
+ dc /= np.linalg.norm(dc, axis=-1, keepdims=True)
135
+ return torch.as_tensor(dc, device=device, dtype=dtype)
136
+
137
+ def show(self, microscope=None, crystal=None):
138
+ """Interactive screen placement and PC editor, also exportable to HTML.
139
+
140
+ If a microscope is supplied, its sample tilt and azimuth must match.
141
+ Edits are a separate snapshot; read them with widget.to_detector().
142
+ """
143
+ from de_ebsd.viz.widgets import MicroscopeWidget
144
+
145
+ return MicroscopeWidget.from_setup(microscope=microscope, detector=self, crystal=crystal)
@@ -0,0 +1,231 @@
1
+ """The simulated master pattern and projection onto a detector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING
8
+
9
+ import h5py
10
+ import numpy as np
11
+ import torch
12
+
13
+ from de_ebsd.core.constants import DIVISION_FLOOR
14
+ from de_ebsd.core.lambert import sample_texture
15
+ from de_ebsd.core.rotation import quaternion_to_matrix
16
+ from de_ebsd.sim.detector import EBSDDetector
17
+
18
+ if TYPE_CHECKING:
19
+ from anyplotlib import Figure
20
+
21
+ __all__ = ["MasterPattern"]
22
+
23
+
24
+ @dataclass
25
+ class MasterPattern:
26
+ """Backscatter intensity on the sphere in square-Lambert storage.
27
+
28
+ Attributes
29
+ ----------
30
+ data
31
+ Intensities, shape ``(n_energies, 2, n, n)``; hemisphere 0 is north.
32
+ energies_kev
33
+ Energy bin centers in keV, shape ``(n_energies,)``.
34
+ metadata
35
+ Free-form provenance (crystal, dmin, backend name, ...).
36
+ """
37
+
38
+ data: torch.Tensor
39
+ energies_kev: np.ndarray
40
+ metadata: dict = field(default_factory=dict)
41
+
42
+ def __post_init__(self) -> None:
43
+ shape_ok = (
44
+ self.data.ndim == 4
45
+ and self.data.shape[1] == 2
46
+ and self.data.shape[2] == self.data.shape[3]
47
+ )
48
+ if not shape_ok:
49
+ raise ValueError("data must have shape (n_energies, 2, n, n)")
50
+ self.energies_kev = np.atleast_1d(np.asarray(self.energies_kev, dtype=np.float64))
51
+ if len(self.energies_kev) != self.data.shape[0]:
52
+ raise ValueError("energies_kev length must match data.shape[0]")
53
+
54
+ @property
55
+ def npx(self) -> int:
56
+ """Half-width of the Lambert grid (n = 2 npx + 1)."""
57
+ return (self.data.shape[-1] - 1) // 2
58
+
59
+ @property
60
+ def device(self) -> torch.device:
61
+ return self.data.device
62
+
63
+ def to(self, device: torch.device | str) -> MasterPattern:
64
+ """Copy to another device."""
65
+ return MasterPattern(self.data.to(device), self.energies_kev, dict(self.metadata))
66
+
67
+ def energy_index(self, energy_kev: float | None) -> int:
68
+ if energy_kev is None:
69
+ return len(self.energies_kev) - 1 # highest energy bin
70
+ return int(np.argmin(np.abs(self.energies_kev - energy_kev)))
71
+
72
+ def sample(self, directions: torch.Tensor, energy_kev: float | None = None) -> torch.Tensor:
73
+ """Interpolate the master pattern at unit vectors (..., 3).
74
+
75
+ Bilinear interpolation on the square-Lambert grid via
76
+ ``grid_sample(align_corners=True)``, identical to kikuchipy's
77
+ interpolation. The hemisphere is selected by ``sign(z)``.
78
+ """
79
+ directions = directions.to(self.data.device, self.data.dtype)
80
+ return sample_texture(self.data[self.energy_index(energy_kev)][None], directions)
81
+
82
+ def project(
83
+ self,
84
+ detector: EBSDDetector,
85
+ orientation,
86
+ energy_kev: float | None = None,
87
+ rescale: bool = True,
88
+ ) -> torch.Tensor:
89
+ """Project onto a detector for one or more crystal orientations.
90
+
91
+ Parameters
92
+ ----------
93
+ detector
94
+ The detector geometry (fixed pattern center).
95
+ orientation
96
+ Crystal orientation(s): an ``orix.quaternion.Rotation`` or a
97
+ tensor/array of unit quaternions (N, 4) in (a, b, c, d) order.
98
+ energy_kev
99
+ Energy bin to project (default: highest).
100
+ rescale
101
+ Linearly rescale each pattern to [-1, 1].
102
+
103
+ Returns
104
+ -------
105
+ torch.Tensor
106
+ Patterns of shape (N, n_rows, n_cols).
107
+ """
108
+ q = _as_quaternions(orientation).to(self.data.device, self.data.dtype)
109
+ dc = detector.direction_cosines(device=self.data.device, dtype=self.data.dtype)
110
+
111
+ rot = quaternion_to_matrix(q) # (N, 3, 3)
112
+ v = torch.einsum("nij,pj->npi", rot, dc) # sample frame -> crystal frame
113
+ pattern = self.sample(v, energy_kev=energy_kev) # (N, P)
114
+
115
+ if rescale:
116
+ pmin = pattern.amin(dim=1, keepdim=True)
117
+ pmax = pattern.amax(dim=1, keepdim=True)
118
+ pattern = 2.0 * (pattern - pmin) / (pmax - pmin).clamp_min(DIVISION_FLOOR) - 1.0
119
+ return pattern.reshape(-1, detector.nrows, detector.ncols)
120
+
121
+ def to_stereographic(
122
+ self, n: int = 501, hemisphere: str = "north", energy_kev: float | None = None
123
+ ) -> torch.Tensor:
124
+ """Resample one hemisphere into a stereographic (equal-angle) image."""
125
+ sign = 1.0 if hemisphere == "north" else -1.0
126
+ coords = torch.linspace(-1.0, 1.0, n, device=self.device)
127
+ ys, xs = torch.meshgrid(coords, coords, indexing="ij")
128
+ r2 = xs * xs + ys * ys
129
+ # inverse stereographic projection of the unit disk
130
+ vz = sign * (1.0 - r2) / (1.0 + r2)
131
+ vx = 2.0 * xs / (1.0 + r2)
132
+ vy = 2.0 * ys / (1.0 + r2)
133
+ v = torch.stack([vx, vy, vz], dim=-1)
134
+ image = self.sample(v, energy_kev=energy_kev)
135
+ return torch.where(r2 <= 1.0, image, torch.zeros_like(image))
136
+
137
+ def to_equirectangular(
138
+ self, height: int = 256, energy_kev: float | None = None
139
+ ) -> torch.Tensor:
140
+ """Resample the full sphere into an equirectangular (lat-long) image.
141
+
142
+ Row 0 is the north pole; used as the texture for the sphere view.
143
+ """
144
+ width = 2 * height
145
+ theta = torch.linspace(0.0, math.pi, height, device=self.device) # polar
146
+ phi = torch.linspace(-math.pi, math.pi, width, device=self.device)
147
+ t, p = torch.meshgrid(theta, phi, indexing="ij")
148
+ v = torch.stack(
149
+ [torch.sin(t) * torch.cos(p), torch.sin(t) * torch.sin(p), torch.cos(t)], dim=-1
150
+ )
151
+ return self.sample(v, energy_kev=energy_kev)
152
+
153
+ def show(
154
+ self,
155
+ projection: str = "lambert",
156
+ energy_kev: float | None = None,
157
+ texture_height: int = 256,
158
+ **kwargs,
159
+ ) -> Figure:
160
+ """Display the master pattern.
161
+
162
+ Parameters
163
+ ----------
164
+ projection
165
+ ``"lambert"`` (both hemispheres side by side), ``"stereographic"``
166
+ (both hemispheres, equal-angle), or ``"sphere"`` (interactive 3D
167
+ sphere with the pattern as texture).
168
+ energy_kev
169
+ Energy bin to display (default: highest).
170
+ texture_height
171
+ Height of the equirectangular texture used by the interactive
172
+ sphere. Lower values reduce notebook/SpyDE payload size while
173
+ leaving the master-pattern data unchanged.
174
+ """
175
+ from de_ebsd.viz.patterns import hemispheres_figure, sphere_figure
176
+
177
+ i_e = self.energy_index(energy_kev)
178
+ label = f"{self.energies_kev[i_e]:g} keV"
179
+ if projection == "lambert":
180
+ north = self.data[i_e, 0].detach().cpu().numpy()
181
+ south = self.data[i_e, 1].detach().cpu().numpy()
182
+ return hemispheres_figure(north, south, title=f"Master pattern ({label})")
183
+ if projection == "stereographic":
184
+ north = self.to_stereographic(hemisphere="north", energy_kev=energy_kev)
185
+ south = self.to_stereographic(hemisphere="south", energy_kev=energy_kev)
186
+ return hemispheres_figure(
187
+ north.detach().cpu().numpy(),
188
+ south.detach().cpu().numpy(),
189
+ title=f"Master pattern, stereographic ({label})",
190
+ )
191
+ if projection == "sphere":
192
+ if texture_height < 8:
193
+ raise ValueError("texture_height must be >= 8")
194
+ texture = self.to_equirectangular(height=int(texture_height), energy_kev=energy_kev)
195
+ return sphere_figure(
196
+ texture.detach().cpu().numpy(), title=f"Master pattern ({label})", **kwargs
197
+ )
198
+ raise ValueError("projection must be 'lambert', 'stereographic', or 'sphere'")
199
+
200
+ def save(self, path: str) -> None:
201
+ """Save to HDF5 (data, energies, and string metadata)."""
202
+ with h5py.File(path, "w") as f:
203
+ f.create_dataset("master_pattern", data=self.data.detach().cpu().numpy())
204
+ f.create_dataset("energies_kev", data=self.energies_kev)
205
+ meta = f.create_group("metadata")
206
+ for key, value in self.metadata.items():
207
+ meta.attrs[key] = str(value)
208
+
209
+ @classmethod
210
+ def load(cls, path: str, device: torch.device | str | None = None) -> MasterPattern:
211
+ """Load a master pattern saved with :meth:`save`."""
212
+ with h5py.File(path, "r") as f:
213
+ data = torch.as_tensor(np.asarray(f["master_pattern"]), device=device)
214
+ energies = np.asarray(f["energies_kev"])
215
+ metadata = dict(f["metadata"].attrs) if "metadata" in f else {}
216
+ return cls(data=data, energies_kev=energies, metadata=metadata)
217
+
218
+ def __repr__(self) -> str:
219
+ e = self.energies_kev
220
+ return (
221
+ f"MasterPattern(npx={self.npx}, {len(e)} energy bin(s) "
222
+ f"[{e.min():g}..{e.max():g} keV], device={self.data.device.type}, "
223
+ f"backend={self.metadata.get('backend', '?')})"
224
+ )
225
+
226
+
227
+ def _as_quaternions(orientation) -> torch.Tensor:
228
+ data = getattr(orientation, "data", orientation) # orix Rotation -> .data
229
+ q = torch.as_tensor(np.asarray(data) if not isinstance(data, torch.Tensor) else data)
230
+ q = q.reshape(-1, 4)
231
+ return q / q.norm(dim=-1, keepdim=True).clamp_min(DIVISION_FLOOR)
@@ -0,0 +1,93 @@
1
+ """Microscope: beam energy and sample geometry.
2
+
3
+ The :class:`Microscope` collects the experimental parameters that belong to
4
+ the instrument rather than to the crystal or the detector: accelerating
5
+ voltage and the sample tilt. Detector geometry lives on
6
+ :class:`de_ebsd.sim.detector.EBSDDetector`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import dataclass
13
+
14
+ from de_ebsd.core.constants import (
15
+ interaction_constant,
16
+ relativistic_correction,
17
+ )
18
+
19
+ __all__ = ["Microscope"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Microscope:
24
+ """An SEM configured for EBSD.
25
+
26
+ Parameters
27
+ ----------
28
+ energy
29
+ Beam energy in eV (e.g. ``20e3``).
30
+ sample_tilt
31
+ Sample tilt from the horizontal in degrees (70 is standard for EBSD).
32
+ azimuthal
33
+ Detector azimuthal angle with respect to the tilt axis, in degrees.
34
+ probe_diameter_nm
35
+ Nominal circular beam diameter perpendicular to the beam, in nm.
36
+ Optional geometry metadata, not a finite-probe scattering model.
37
+ """
38
+
39
+ energy: float = 20e3
40
+ sample_tilt: float = 70.0
41
+ azimuthal: float = 0.0
42
+ probe_diameter_nm: float | None = None
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.energy <= 0:
46
+ raise ValueError("energy must be positive (in eV)")
47
+ if self.probe_diameter_nm is not None and (
48
+ not math.isfinite(self.probe_diameter_nm) or self.probe_diameter_nm <= 0
49
+ ):
50
+ raise ValueError("probe_diameter_nm must be finite and positive")
51
+
52
+ @property
53
+ def probe_footprint_nm(self) -> tuple[float, float] | None:
54
+ """Major/minor diameters on a flat surface; None at grazing incidence.
55
+
56
+ Geometric projection of a circular parallel probe, not an interaction
57
+ volume, beam broadening prediction, or a measured spatial resolution.
58
+ """
59
+ cosine = math.cos(math.radians(self.sample_tilt))
60
+ if self.probe_diameter_nm is None or cosine <= 1e-12:
61
+ return None
62
+ return self.probe_diameter_nm / cosine, self.probe_diameter_nm
63
+
64
+ @property
65
+ def energy_kev(self) -> float:
66
+ """Beam energy in keV."""
67
+ return self.energy / 1e3
68
+
69
+ @property
70
+ def gamma(self) -> float:
71
+ """Relativistic correction factor 1 + e V / (m0 c^2)."""
72
+ return relativistic_correction(self.energy_kev)
73
+
74
+ @property
75
+ def sigma(self) -> float:
76
+ """Interaction constant in (V nm)^-1."""
77
+ return interaction_constant(self.energy_kev, v0mod=0.0)
78
+
79
+ def show(self, detector=None, crystal=None):
80
+ """Editable beam, footprint and optional detector geometry inspector.
81
+
82
+ When supplied, detector sample tilt/azimuth must agree with this object.
83
+ Edits are a separate snapshot; read them with widget.to_microscope().
84
+ """
85
+ from de_ebsd.viz.widgets import MicroscopeWidget
86
+
87
+ return MicroscopeWidget.from_setup(microscope=self, detector=detector, crystal=crystal)
88
+
89
+ def __repr__(self) -> str:
90
+ return (
91
+ f"Microscope(energy={self.energy:g} eV, sample_tilt={self.sample_tilt:g} deg, "
92
+ f"azimuthal={self.azimuthal:g} deg)"
93
+ )