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
de_ebsd/viz/explorer.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""Interactive, geometric STEM orientation explorer.
|
|
2
|
+
|
|
3
|
+
The explorer deliberately keeps the display layer small. The kinematic model
|
|
4
|
+
owns reflection generation and this module only projects its result into a
|
|
5
|
+
gnomonic canvas, draws a reciprocal-normal sphere, and wires anyplotlib events
|
|
6
|
+
to one scalar-first sample-to-crystal quaternion.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# The anyplotlib calls below are intentionally kept visually grouped; several
|
|
10
|
+
# pass descriptive labels alongside their geometry.
|
|
11
|
+
# ruff: noqa: E501,E701
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import torch
|
|
20
|
+
|
|
21
|
+
from de_ebsd.core.rotation import axis_angle_matrix, matrix_to_quaternion, quaternion_to_matrix
|
|
22
|
+
from de_ebsd.diffraction.kinematic import orientation_from_zone_axis
|
|
23
|
+
from de_ebsd.viz.camera import band_path, camera_angles, camera_direction, camera_frame
|
|
24
|
+
from de_ebsd.viz.figures import apl
|
|
25
|
+
|
|
26
|
+
__all__ = ["CrystalExplorer"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _quaternion_to_matrix(q: np.ndarray) -> np.ndarray:
|
|
30
|
+
return quaternion_to_matrix(torch.as_tensor(q, dtype=torch.float64)).numpy()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_IDENTITY = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_quaternion(value: Any) -> np.ndarray:
|
|
37
|
+
"""Return one normalized scalar-first quaternion, rejecting bad rotations."""
|
|
38
|
+
data = getattr(value, "data", value)
|
|
39
|
+
q = np.asarray(data, dtype=np.float64)
|
|
40
|
+
if q.size != 4:
|
|
41
|
+
raise ValueError("orientation must contain exactly one quaternion of shape (4,)")
|
|
42
|
+
q = q.reshape(4)
|
|
43
|
+
if not np.isfinite(q).all():
|
|
44
|
+
raise ValueError("orientation must contain finite values")
|
|
45
|
+
norm = float(np.linalg.norm(q))
|
|
46
|
+
if norm < 1e-12:
|
|
47
|
+
raise ValueError("orientation quaternion must have non-zero norm")
|
|
48
|
+
q = q / norm
|
|
49
|
+
# A single sign makes programmatic comparisons and widget state stable.
|
|
50
|
+
return q if q[0] >= 0 else -q
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CrystalExplorer:
|
|
54
|
+
"""Linked STEM orientation explorer with geometric bands and kinematic spots."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
crystal,
|
|
59
|
+
energy_kev: float = 200.0,
|
|
60
|
+
dmin: float = 0.1,
|
|
61
|
+
*,
|
|
62
|
+
semiangle_deg: float = 5.0,
|
|
63
|
+
excitation_width: float = 0.1,
|
|
64
|
+
orientation=None,
|
|
65
|
+
model=None,
|
|
66
|
+
show_lines: bool = True,
|
|
67
|
+
show_spots: bool = True,
|
|
68
|
+
show_labels: bool = True,
|
|
69
|
+
name: str | None = None,
|
|
70
|
+
):
|
|
71
|
+
self.crystal = crystal
|
|
72
|
+
self.name = name or "STEM crystal explorer"
|
|
73
|
+
self._energy_kev = self._positive(energy_kev, "energy_kev")
|
|
74
|
+
self._dmin = self._positive(dmin, "dmin")
|
|
75
|
+
self._semiangle_deg = self._fov(semiangle_deg)
|
|
76
|
+
self._excitation_width = self._positive(excitation_width, "excitation_width")
|
|
77
|
+
self.model = model
|
|
78
|
+
if self.model is None:
|
|
79
|
+
from de_ebsd.diffraction.kinematic import KinematicPatternModel
|
|
80
|
+
|
|
81
|
+
self.model = KinematicPatternModel(
|
|
82
|
+
crystal, energy_kev=self._energy_kev, dmin=self._dmin
|
|
83
|
+
)
|
|
84
|
+
self._model_dmin = self._dmin
|
|
85
|
+
self._orientation = _as_quaternion(_IDENTITY if orientation is None else orientation)
|
|
86
|
+
self._base_orientation = self._orientation.copy()
|
|
87
|
+
self._zone_axis: np.ndarray | None = None
|
|
88
|
+
self._roll_deg = 0.0
|
|
89
|
+
self._roll_deg_before = 0.0
|
|
90
|
+
self._tilt_x_deg = 0.0
|
|
91
|
+
self._tilt_y_deg = 0.0
|
|
92
|
+
self._show_lines = bool(show_lines)
|
|
93
|
+
self._show_spots = bool(show_spots)
|
|
94
|
+
self._show_labels = bool(show_labels)
|
|
95
|
+
self._figure = None
|
|
96
|
+
self._projection_ax = self._sphere_ax = self._crystal_ax = None
|
|
97
|
+
self._line_artist = self._spot_artist = self._beam_artist = self._label_artist = None
|
|
98
|
+
self._sphere_artist = self._crystal_artist = None
|
|
99
|
+
self._last_result = None
|
|
100
|
+
self._updating = False
|
|
101
|
+
self._camera = (0.0, 90.0)
|
|
102
|
+
self._initial_energy_kev = self._energy_kev
|
|
103
|
+
self._initial_dmin = self._dmin
|
|
104
|
+
self._initial_semiangle_deg = self._semiangle_deg
|
|
105
|
+
self._initial_show_lines = self._show_lines
|
|
106
|
+
self._initial_show_spots = self._show_spots
|
|
107
|
+
self._initial_show_labels = self._show_labels
|
|
108
|
+
self._initial_orientation = self._orientation.copy()
|
|
109
|
+
self._initial_roll_deg = self._roll_deg
|
|
110
|
+
self._initial_tilt = (self._tilt_x_deg, self._tilt_y_deg)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _positive(value, name):
|
|
114
|
+
value = float(value)
|
|
115
|
+
if not np.isfinite(value) or value <= 0:
|
|
116
|
+
raise ValueError(f"{name} must be finite and > 0")
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _fov(value):
|
|
121
|
+
value = float(value)
|
|
122
|
+
if not np.isfinite(value) or value <= 0 or value >= 89:
|
|
123
|
+
raise ValueError("semiangle_deg must be finite and in (0, 89)")
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def orientation(self) -> np.ndarray:
|
|
128
|
+
return self._orientation.copy()
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def energy_kev(self) -> float:
|
|
132
|
+
return self._energy_kev
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def dmin(self) -> float:
|
|
136
|
+
return self._dmin
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def semiangle_deg(self) -> float:
|
|
140
|
+
return self._semiangle_deg
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def excitation_width(self) -> float:
|
|
144
|
+
return self._excitation_width
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def roll_deg(self) -> float:
|
|
148
|
+
return self._roll_deg
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def fine_tilt(self) -> tuple[float, float]:
|
|
152
|
+
return self._tilt_x_deg, self._tilt_y_deg
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def zone_axis(self):
|
|
156
|
+
return None if self._zone_axis is None else self._zone_axis.copy()
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def show_lines(self) -> bool:
|
|
160
|
+
return self._show_lines
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def show_spots(self) -> bool:
|
|
164
|
+
return self._show_spots
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def show_labels(self) -> bool:
|
|
168
|
+
return self._show_labels
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def figure(self):
|
|
172
|
+
if self._figure is None:
|
|
173
|
+
self._make_figure()
|
|
174
|
+
return self._figure
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def figures(self) -> dict[str, object]:
|
|
178
|
+
return {"explorer": self.figure}
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def result(self):
|
|
182
|
+
"""Most recent model projection result, useful to host integrations."""
|
|
183
|
+
_ = self.figure
|
|
184
|
+
return self._last_result
|
|
185
|
+
|
|
186
|
+
def _make_figure(self):
|
|
187
|
+
fig, axes = apl().subplots(1, 3, figsize=(900, 410), width_ratios=[1.35, 1.0, 1.0])
|
|
188
|
+
self._figure = fig
|
|
189
|
+
self._projection_ax, self._sphere_ax, self._crystal_ax = np.ravel(axes)[:3]
|
|
190
|
+
bound = math.tan(math.radians(self._semiangle_deg))
|
|
191
|
+
self._projection_ax = self._projection_ax.axes2d(
|
|
192
|
+
xlim=(-bound, bound),
|
|
193
|
+
ylim=(-bound, bound),
|
|
194
|
+
aspect="equal",
|
|
195
|
+
units="gnomonic slope",
|
|
196
|
+
y_units="gnomonic slope",
|
|
197
|
+
)
|
|
198
|
+
self._projection_ax.set_xlabel("x/z (right)")
|
|
199
|
+
self._projection_ax.set_ylabel("y/z (up)")
|
|
200
|
+
self._projection_ax.set_title("Kikuchi lines + kinematic spots")
|
|
201
|
+
|
|
202
|
+
normals = np.asarray(
|
|
203
|
+
getattr(self.model, "band_normals", np.empty((0, 3))), dtype=float
|
|
204
|
+
).reshape(-1, 3)
|
|
205
|
+
band_path_points = band_path(normals)
|
|
206
|
+
self._sphere_artist = self._sphere_ax.scatter3d(
|
|
207
|
+
band_path_points[:, 0],
|
|
208
|
+
band_path_points[:, 1],
|
|
209
|
+
band_path_points[:, 2],
|
|
210
|
+
color="#48d1cc",
|
|
211
|
+
point_size=1.4,
|
|
212
|
+
bounds=((-1, 1),) * 3,
|
|
213
|
+
x_label="crystal x",
|
|
214
|
+
y_label="crystal y",
|
|
215
|
+
z_label="crystal z",
|
|
216
|
+
azimuth=0,
|
|
217
|
+
elevation=90,
|
|
218
|
+
gpu="auto",
|
|
219
|
+
)
|
|
220
|
+
self._sphere_artist.set_sphere(radius=1, color="#9aa4b2", alpha=0.12, wireframe=True)
|
|
221
|
+
self._sphere_artist.set_title("Band-line sphere (drag to tilt)")
|
|
222
|
+
# CallbackRegistry.connect accepts bound methods directly. The
|
|
223
|
+
# decorator-style anyplotlib API annotates callbacks and therefore
|
|
224
|
+
# cannot annotate a bound method on Python 3.12.
|
|
225
|
+
self._sphere_artist.callbacks.connect("pointer_move", self._on_sphere_event)
|
|
226
|
+
self._sphere_artist.callbacks.connect("pointer_up", self._on_sphere_event)
|
|
227
|
+
# Draw each reciprocal-plane great circle as a separate native 3-D
|
|
228
|
+
# line. Keeping these static means camera drags never regenerate the
|
|
229
|
+
# reflection set and the sphere remains useful when lines are hidden
|
|
230
|
+
# in the central projection.
|
|
231
|
+
|
|
232
|
+
cart = self._crystal_cartesian()
|
|
233
|
+
self._crystal_artist = self._crystal_ax.scatter3d(
|
|
234
|
+
cart[:, 0] if len(cart) else [],
|
|
235
|
+
cart[:, 1] if len(cart) else [],
|
|
236
|
+
cart[:, 2] if len(cart) else [],
|
|
237
|
+
color="#56b4e9",
|
|
238
|
+
point_size=6,
|
|
239
|
+
bounds=self._crystal_bounds(cart),
|
|
240
|
+
x_label="crystal x (nm)",
|
|
241
|
+
y_label="crystal y (nm)",
|
|
242
|
+
z_label="crystal z (nm)",
|
|
243
|
+
azimuth=-55,
|
|
244
|
+
elevation=24,
|
|
245
|
+
gpu="auto",
|
|
246
|
+
)
|
|
247
|
+
self._crystal_artist.set_title("Crystal (linked orientation)")
|
|
248
|
+
self._recompute(push=False)
|
|
249
|
+
|
|
250
|
+
def _crystal_bounds(self, cart):
|
|
251
|
+
if len(cart) == 0:
|
|
252
|
+
return ((-1, 1),) * 3
|
|
253
|
+
radius = max(float(np.max(np.linalg.norm(cart, axis=1))) * 1.12, 1e-6)
|
|
254
|
+
return ((-radius, radius),) * 3
|
|
255
|
+
|
|
256
|
+
def _crystal_cartesian(self):
|
|
257
|
+
parts = [np.asarray(p, dtype=float) for p in getattr(self.crystal, "positions", ())]
|
|
258
|
+
if not parts:
|
|
259
|
+
return np.empty((0, 3))
|
|
260
|
+
frac = np.concatenate(parts, axis=0)
|
|
261
|
+
return frac @ np.asarray(self.crystal.direct_structure_matrix, dtype=float).T
|
|
262
|
+
|
|
263
|
+
def _apply_tilts(self):
|
|
264
|
+
rx = axis_angle_matrix(np.array([1.0, 0.0, 0.0]), self._tilt_x_deg)
|
|
265
|
+
ry = axis_angle_matrix(np.array([0.0, 1.0, 0.0]), self._tilt_y_deg)
|
|
266
|
+
return self._base_orientation_matrix @ rx @ ry
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def _base_orientation_matrix(self):
|
|
270
|
+
return _quaternion_to_matrix(self._base_orientation)
|
|
271
|
+
|
|
272
|
+
def _recompute(self, *, push=True):
|
|
273
|
+
if self._updating:
|
|
274
|
+
return
|
|
275
|
+
self._updating = True
|
|
276
|
+
try:
|
|
277
|
+
self._orientation = matrix_to_quaternion(self._apply_tilts())
|
|
278
|
+
self._last_result = self.model.project(
|
|
279
|
+
self._orientation,
|
|
280
|
+
semiangle_deg=self._semiangle_deg,
|
|
281
|
+
excitation_width=self._excitation_width,
|
|
282
|
+
)
|
|
283
|
+
if self._projection_ax is not None:
|
|
284
|
+
lines = np.asarray(
|
|
285
|
+
getattr(self._last_result, "lines", np.empty((0, 2, 2))), dtype=float
|
|
286
|
+
).reshape(-1, 2, 2)
|
|
287
|
+
spots = np.asarray(
|
|
288
|
+
getattr(self._last_result, "spots", np.empty((0, 2))), dtype=float
|
|
289
|
+
).reshape(-1, 2)
|
|
290
|
+
intensities = np.asarray(
|
|
291
|
+
getattr(self._last_result, "intensities", np.ones(len(spots))), dtype=float
|
|
292
|
+
).reshape(-1)
|
|
293
|
+
if self._line_artist is None:
|
|
294
|
+
self._line_artist = self._projection_ax.add_lines(
|
|
295
|
+
lines if self._show_lines else np.empty((0, 2, 2)),
|
|
296
|
+
name="kikuchi_lines",
|
|
297
|
+
edgecolors="#48d1cc",
|
|
298
|
+
linewidths=1.2,
|
|
299
|
+
label="geometric Kikuchi lines",
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
self._line_artist.set(
|
|
303
|
+
segments=lines if self._show_lines else np.empty((0, 2, 2))
|
|
304
|
+
)
|
|
305
|
+
sizes = 2.5 + 4.0 * np.sqrt(
|
|
306
|
+
np.clip(intensities, 0, None)
|
|
307
|
+
/ max(float(np.max(intensities)) if len(intensities) else 1, 1e-12)
|
|
308
|
+
)
|
|
309
|
+
if self._spot_artist is None:
|
|
310
|
+
self._spot_artist = self._projection_ax.add_circles(
|
|
311
|
+
spots if self._show_spots else np.empty((0, 2)),
|
|
312
|
+
name="kinematic_spots",
|
|
313
|
+
radius=sizes if self._show_spots else np.empty((0,)),
|
|
314
|
+
facecolors="#f48fb1",
|
|
315
|
+
edgecolors="#f48fb1",
|
|
316
|
+
alpha=0.85,
|
|
317
|
+
labels=[str(x) for x in getattr(self._last_result, "spot_hkl", [])]
|
|
318
|
+
if self._show_labels
|
|
319
|
+
else [],
|
|
320
|
+
label="kinematic spots",
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
self._spot_artist.set(
|
|
324
|
+
offsets=spots if self._show_spots else np.empty((0, 2)),
|
|
325
|
+
sizes=sizes if self._show_spots else np.empty((0,)),
|
|
326
|
+
labels=[str(x) for x in getattr(self._last_result, "spot_hkl", [])]
|
|
327
|
+
if self._show_labels
|
|
328
|
+
else [],
|
|
329
|
+
)
|
|
330
|
+
if self._beam_artist is None:
|
|
331
|
+
self._beam_artist = self._projection_ax.add_circles(
|
|
332
|
+
[[0.0, 0.0]] if self._show_spots else np.empty((0, 2)),
|
|
333
|
+
name="direct_beam",
|
|
334
|
+
radius=3,
|
|
335
|
+
edgecolors="#ffffff",
|
|
336
|
+
linewidths=1.0,
|
|
337
|
+
label="direct beam",
|
|
338
|
+
)
|
|
339
|
+
self._beam_artist.set(
|
|
340
|
+
offsets=[[0.0, 0.0]] if self._show_spots else np.empty((0, 2)), sizes=3
|
|
341
|
+
)
|
|
342
|
+
spot_hkl = getattr(self._last_result, "spot_hkl", [])
|
|
343
|
+
label_offsets = (
|
|
344
|
+
spots if self._show_spots and self._show_labels else np.empty((0, 2))
|
|
345
|
+
)
|
|
346
|
+
label_texts = (
|
|
347
|
+
["(" + ",".join(str(int(v)) for v in row) + ")" for row in spot_hkl]
|
|
348
|
+
if self._show_spots and self._show_labels
|
|
349
|
+
else []
|
|
350
|
+
)
|
|
351
|
+
if self._label_artist is None:
|
|
352
|
+
self._label_artist = self._projection_ax.add_texts(
|
|
353
|
+
label_offsets, label_texts, name="spot_labels", color="#f48fb1", fontsize=10
|
|
354
|
+
)
|
|
355
|
+
else:
|
|
356
|
+
self._label_artist.set(offsets=label_offsets, texts=label_texts)
|
|
357
|
+
self._update_linked_views()
|
|
358
|
+
finally:
|
|
359
|
+
self._updating = False
|
|
360
|
+
|
|
361
|
+
def _update_linked_views(self):
|
|
362
|
+
if self._crystal_artist is not None:
|
|
363
|
+
cart = self._crystal_cartesian() @ self._quaternion_matrix_inverse().T
|
|
364
|
+
self._crystal_artist.set_data(cart[:, 0], cart[:, 1], cart[:, 2])
|
|
365
|
+
if self._sphere_artist is not None:
|
|
366
|
+
beam = _quaternion_to_matrix(self._orientation)[:, 2]
|
|
367
|
+
self._sphere_artist.set_highlight(
|
|
368
|
+
float(beam[0]), float(beam[1]), float(beam[2]), color="#ffffff", size=8
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def _quaternion_matrix_inverse(self):
|
|
372
|
+
return _quaternion_to_matrix(self._orientation).T
|
|
373
|
+
|
|
374
|
+
def set_orientation(self, orientation):
|
|
375
|
+
self._orientation = _as_quaternion(orientation)
|
|
376
|
+
self._base_orientation = self._orientation.copy()
|
|
377
|
+
self._zone_axis = None
|
|
378
|
+
self._tilt_x_deg = self._tilt_y_deg = 0.0
|
|
379
|
+
self._roll_deg = 0.0
|
|
380
|
+
self._roll_deg_before = 0.0
|
|
381
|
+
self._set_camera_for_beam(_quaternion_to_matrix(self._orientation)[:, 2])
|
|
382
|
+
self._recompute()
|
|
383
|
+
return self
|
|
384
|
+
|
|
385
|
+
def set_zone_axis(self, uvw, roll_deg: float | None = None):
|
|
386
|
+
uvw = np.asarray(uvw, dtype=float).reshape(-1)
|
|
387
|
+
if uvw.size != 3:
|
|
388
|
+
raise ValueError("zone axis must have shape (3,)")
|
|
389
|
+
self._zone_axis = uvw.copy()
|
|
390
|
+
if roll_deg is not None:
|
|
391
|
+
roll_deg = float(roll_deg)
|
|
392
|
+
if not np.isfinite(roll_deg):
|
|
393
|
+
raise ValueError("roll_deg must be finite")
|
|
394
|
+
self._roll_deg = roll_deg
|
|
395
|
+
self._roll_deg_before = self._roll_deg
|
|
396
|
+
self._base_orientation = orientation_from_zone_axis(self.crystal, uvw, self._roll_deg)
|
|
397
|
+
self._tilt_x_deg = self._tilt_y_deg = 0.0
|
|
398
|
+
self._set_camera_for_beam(_quaternion_to_matrix(self._base_orientation)[:, 2])
|
|
399
|
+
self._recompute()
|
|
400
|
+
return self
|
|
401
|
+
|
|
402
|
+
def set_roll(self, roll_deg: float):
|
|
403
|
+
roll_deg = float(roll_deg)
|
|
404
|
+
if not np.isfinite(roll_deg):
|
|
405
|
+
raise ValueError("roll_deg must be finite")
|
|
406
|
+
matrix = _quaternion_to_matrix(self._orientation)
|
|
407
|
+
beam = matrix[:, 2]
|
|
408
|
+
self._roll_deg = roll_deg
|
|
409
|
+
# Plot3D has no camera-roll state. Apply roll about the current beam
|
|
410
|
+
# directly and fold it into the authoritative orientation, clearing
|
|
411
|
+
# the two convenience tilt offsets so changing roll cannot move +z.
|
|
412
|
+
delta = roll_deg - getattr(self, "_roll_deg_before", 0.0)
|
|
413
|
+
self._orientation = matrix_to_quaternion(axis_angle_matrix(beam, delta) @ matrix)
|
|
414
|
+
self._base_orientation = self._orientation.copy()
|
|
415
|
+
self._tilt_x_deg = self._tilt_y_deg = 0.0
|
|
416
|
+
self._roll_deg_before = roll_deg
|
|
417
|
+
self._recompute()
|
|
418
|
+
return self
|
|
419
|
+
|
|
420
|
+
def set_fine_tilt(self, tilt_x_deg: float = 0.0, tilt_y_deg: float = 0.0):
|
|
421
|
+
tx, ty = float(tilt_x_deg), float(tilt_y_deg)
|
|
422
|
+
if not np.isfinite([tx, ty]).all():
|
|
423
|
+
raise ValueError("fine tilt values must be finite")
|
|
424
|
+
self._tilt_x_deg, self._tilt_y_deg = tx, ty
|
|
425
|
+
self._recompute()
|
|
426
|
+
return self
|
|
427
|
+
|
|
428
|
+
set_tilt = set_fine_tilt
|
|
429
|
+
|
|
430
|
+
def set_energy(self, energy_kev: float):
|
|
431
|
+
self._energy_kev = self._positive(energy_kev, "energy_kev")
|
|
432
|
+
try:
|
|
433
|
+
self.model.energy_kev = self._energy_kev
|
|
434
|
+
except Exception:
|
|
435
|
+
from de_ebsd.diffraction.kinematic import KinematicPatternModel
|
|
436
|
+
|
|
437
|
+
self.model = KinematicPatternModel(
|
|
438
|
+
self.crystal, energy_kev=self._energy_kev, dmin=self._dmin
|
|
439
|
+
)
|
|
440
|
+
self._recompute()
|
|
441
|
+
return self
|
|
442
|
+
|
|
443
|
+
def set_dmin(self, dmin: float):
|
|
444
|
+
self._dmin = self._positive(dmin, "dmin")
|
|
445
|
+
from de_ebsd.diffraction.kinematic import KinematicPatternModel
|
|
446
|
+
|
|
447
|
+
self.model = KinematicPatternModel(
|
|
448
|
+
self.crystal, energy_kev=self._energy_kev, dmin=self._dmin
|
|
449
|
+
)
|
|
450
|
+
self._model_dmin = self._dmin
|
|
451
|
+
if self._sphere_artist is not None:
|
|
452
|
+
normals = np.asarray(
|
|
453
|
+
getattr(self.model, "band_normals", np.empty((0, 3))), dtype=float
|
|
454
|
+
).reshape(-1, 3)
|
|
455
|
+
path = band_path(normals)
|
|
456
|
+
self._sphere_artist.set_data(path[:, 0], path[:, 1], path[:, 2])
|
|
457
|
+
self._recompute()
|
|
458
|
+
return self
|
|
459
|
+
|
|
460
|
+
def set_semiangle(self, semiangle_deg: float):
|
|
461
|
+
self._semiangle_deg = self._fov(semiangle_deg)
|
|
462
|
+
if self._projection_ax is not None:
|
|
463
|
+
bound = math.tan(math.radians(self._semiangle_deg))
|
|
464
|
+
self._projection_ax.set_xlim(-bound, bound)
|
|
465
|
+
self._projection_ax.set_ylim(-bound, bound)
|
|
466
|
+
self._recompute()
|
|
467
|
+
return self
|
|
468
|
+
|
|
469
|
+
def set_excitation_width(self, excitation_width: float):
|
|
470
|
+
self._excitation_width = self._positive(excitation_width, "excitation_width")
|
|
471
|
+
self._recompute()
|
|
472
|
+
return self
|
|
473
|
+
|
|
474
|
+
def set_show_lines(self, visible: bool):
|
|
475
|
+
self._show_lines = bool(visible)
|
|
476
|
+
self._recompute()
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
def set_show_spots(self, visible: bool):
|
|
480
|
+
self._show_spots = bool(visible)
|
|
481
|
+
self._recompute()
|
|
482
|
+
return self
|
|
483
|
+
|
|
484
|
+
def set_show_labels(self, visible: bool):
|
|
485
|
+
self._show_labels = bool(visible)
|
|
486
|
+
self._recompute()
|
|
487
|
+
return self
|
|
488
|
+
|
|
489
|
+
def reset(self):
|
|
490
|
+
self._energy_kev, self._dmin, self._semiangle_deg = (
|
|
491
|
+
self._initial_energy_kev,
|
|
492
|
+
self._initial_dmin,
|
|
493
|
+
self._initial_semiangle_deg,
|
|
494
|
+
)
|
|
495
|
+
self._show_lines, self._show_spots, self._show_labels = (
|
|
496
|
+
self._initial_show_lines,
|
|
497
|
+
self._initial_show_spots,
|
|
498
|
+
self._initial_show_labels,
|
|
499
|
+
)
|
|
500
|
+
self._zone_axis = None
|
|
501
|
+
self._roll_deg = self._tilt_x_deg = self._tilt_y_deg = 0.0
|
|
502
|
+
self._roll_deg_before = self._initial_roll_deg
|
|
503
|
+
self._base_orientation = self._initial_orientation.copy()
|
|
504
|
+
self._orientation = self._initial_orientation.copy()
|
|
505
|
+
if self._dmin != self._model_dmin:
|
|
506
|
+
from de_ebsd.diffraction.kinematic import KinematicPatternModel
|
|
507
|
+
|
|
508
|
+
self.model = KinematicPatternModel(
|
|
509
|
+
self.crystal, energy_kev=self._energy_kev, dmin=self._dmin
|
|
510
|
+
)
|
|
511
|
+
self._model_dmin = self._dmin
|
|
512
|
+
else:
|
|
513
|
+
try:
|
|
514
|
+
self.model.energy_kev = self._energy_kev
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
if self._projection_ax is not None:
|
|
518
|
+
bound = math.tan(math.radians(self._semiangle_deg))
|
|
519
|
+
self._projection_ax.set_xlim(-bound, bound)
|
|
520
|
+
self._projection_ax.set_ylim(-bound, bound)
|
|
521
|
+
self._set_camera_for_beam(np.array([0.0, 0.0, 1.0]))
|
|
522
|
+
self._recompute()
|
|
523
|
+
return self
|
|
524
|
+
|
|
525
|
+
def _set_camera_for_beam(self, beam):
|
|
526
|
+
self._camera = camera_angles(beam)
|
|
527
|
+
if self._sphere_artist is not None:
|
|
528
|
+
self._sphere_artist.set_view(*self._camera)
|
|
529
|
+
|
|
530
|
+
def _on_sphere_event(self, event):
|
|
531
|
+
if self._updating:
|
|
532
|
+
return
|
|
533
|
+
az = getattr(event, "azimuth", None)
|
|
534
|
+
el = getattr(event, "elevation", None)
|
|
535
|
+
if az is None or el is None or not np.isfinite([az, el]).all():
|
|
536
|
+
return
|
|
537
|
+
self._camera = (float(az), float(el))
|
|
538
|
+
beam = camera_direction(float(az), float(el))
|
|
539
|
+
self._base_orientation = matrix_to_quaternion(
|
|
540
|
+
axis_angle_matrix(beam, self._roll_deg) @ camera_frame(float(az), float(el))
|
|
541
|
+
)
|
|
542
|
+
self._zone_axis = None
|
|
543
|
+
self._tilt_x_deg = self._tilt_y_deg = 0.0
|
|
544
|
+
# anyplotlib does not merge camera fields into Plot3D state. Push the
|
|
545
|
+
# received angles back so the next Python update cannot snap the view.
|
|
546
|
+
self._sphere_artist.set_view(float(az), float(el))
|
|
547
|
+
self._recompute()
|
|
548
|
+
|
|
549
|
+
@property
|
|
550
|
+
def controls(self):
|
|
551
|
+
"""Notebook controls; values call the public setters immediately."""
|
|
552
|
+
if hasattr(self, "_controls"):
|
|
553
|
+
return self._controls
|
|
554
|
+
try:
|
|
555
|
+
import ipywidgets as widgets
|
|
556
|
+
except ImportError:
|
|
557
|
+
self._controls = None
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
def compact(control):
|
|
561
|
+
control.layout = widgets.Layout(width="145px")
|
|
562
|
+
return control
|
|
563
|
+
|
|
564
|
+
energy = compact(widgets.FloatText(value=self.energy_kev, description="keV"))
|
|
565
|
+
fov = compact(widgets.FloatText(value=self.semiangle_deg, description="± angle °"))
|
|
566
|
+
dmin = compact(widgets.FloatText(value=self.dmin, description="dmin nm"))
|
|
567
|
+
zone = compact(widgets.Text(value="", description="zone [uvw]"))
|
|
568
|
+
roll = compact(widgets.FloatText(value=self.roll_deg, description="roll °"))
|
|
569
|
+
tx = compact(widgets.FloatText(value=0.0, description="tilt x °"))
|
|
570
|
+
ty = compact(widgets.FloatText(value=0.0, description="tilt y °"))
|
|
571
|
+
lines = widgets.Checkbox(value=self.show_lines, description="Kikuchi lines")
|
|
572
|
+
spots = widgets.Checkbox(value=self.show_spots, description="Kinematic spots")
|
|
573
|
+
labels = widgets.Checkbox(value=self.show_labels, description="hkl labels")
|
|
574
|
+
error = widgets.Label(value="", layout=widgets.Layout(color="#d55e00", width="100%"))
|
|
575
|
+
reset = widgets.Button(description="Reset", button_style="")
|
|
576
|
+
|
|
577
|
+
def observe(change):
|
|
578
|
+
if change["name"] != "value" or self._updating:
|
|
579
|
+
return
|
|
580
|
+
owner = change["owner"]
|
|
581
|
+
try:
|
|
582
|
+
if owner is energy:
|
|
583
|
+
self.set_energy(change["new"])
|
|
584
|
+
elif owner is fov:
|
|
585
|
+
self.set_semiangle(change["new"])
|
|
586
|
+
elif owner is dmin:
|
|
587
|
+
self.set_dmin(change["new"])
|
|
588
|
+
elif owner is roll:
|
|
589
|
+
self.set_roll(change["new"])
|
|
590
|
+
elif owner is tx or owner is ty:
|
|
591
|
+
self.set_fine_tilt(tx.value, ty.value)
|
|
592
|
+
elif owner is lines:
|
|
593
|
+
self.set_show_lines(change["new"])
|
|
594
|
+
elif owner is spots:
|
|
595
|
+
self.set_show_spots(change["new"])
|
|
596
|
+
elif owner is labels:
|
|
597
|
+
self.set_show_labels(change["new"])
|
|
598
|
+
elif owner is zone and str(change["new"]).strip():
|
|
599
|
+
self.set_zone_axis(
|
|
600
|
+
[float(x) for x in str(change["new"]).replace(",", " ").split()]
|
|
601
|
+
)
|
|
602
|
+
error.value = ""
|
|
603
|
+
except (TypeError, ValueError) as exc:
|
|
604
|
+
error.value = str(exc)
|
|
605
|
+
|
|
606
|
+
def do_reset(_):
|
|
607
|
+
self.reset()
|
|
608
|
+
energy.value, dmin.value, fov.value = self.energy_kev, self.dmin, self.semiangle_deg
|
|
609
|
+
roll.value, tx.value, ty.value = self.roll_deg, self._tilt_x_deg, self._tilt_y_deg
|
|
610
|
+
lines.value, spots.value, labels.value = (
|
|
611
|
+
self.show_lines,
|
|
612
|
+
self.show_spots,
|
|
613
|
+
self.show_labels,
|
|
614
|
+
)
|
|
615
|
+
zone.value, error.value = "", ""
|
|
616
|
+
|
|
617
|
+
reset.on_click(do_reset)
|
|
618
|
+
for control in (energy, fov, dmin, zone, roll, tx, ty, lines, spots):
|
|
619
|
+
control.observe(observe)
|
|
620
|
+
self._controls = widgets.VBox(
|
|
621
|
+
[
|
|
622
|
+
widgets.HBox([energy, dmin, fov], layout=widgets.Layout(flex_flow="row wrap")),
|
|
623
|
+
widgets.HBox([zone, roll, tx, ty], layout=widgets.Layout(flex_flow="row wrap")),
|
|
624
|
+
widgets.HBox([lines, spots, labels, reset]),
|
|
625
|
+
error,
|
|
626
|
+
]
|
|
627
|
+
)
|
|
628
|
+
return self._controls
|
|
629
|
+
|
|
630
|
+
def _ipython_display_(self):
|
|
631
|
+
from IPython.display import display
|
|
632
|
+
|
|
633
|
+
if self.controls is not None:
|
|
634
|
+
display(self.controls)
|
|
635
|
+
display(self.figure)
|
|
636
|
+
|
|
637
|
+
def __repr__(self):
|
|
638
|
+
return f"CrystalExplorer(energy_kev={self.energy_kev:g}, dmin={self.dmin:g}, orientation={self.orientation.tolist()!r})"
|
|
639
|
+
|
|
640
|
+
def to_html(self):
|
|
641
|
+
"""Export the native figure with a clear live-kernel limitation note."""
|
|
642
|
+
note = (
|
|
643
|
+
"<p style='font:12px system-ui;color:#666'>"
|
|
644
|
+
"This exported view is a static snapshot; Python pattern recalculation "
|
|
645
|
+
"requires a live Jupyter or SpyDE kernel.</p>"
|
|
646
|
+
)
|
|
647
|
+
return "<!doctype html><meta charset='utf-8'>" + note + self.figure.to_html()
|