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
de_ebsd/viz/figures.py ADDED
@@ -0,0 +1,67 @@
1
+ """Shared anyplotlib primitives.
2
+
3
+ Every ``.show()`` in de_ebsd renders through :mod:`de_ebsd.viz`, so the
4
+ package has one way to draw. Helpers return an ``anyplotlib.Figure``:
5
+ interactive in Jupyter, exportable via ``figure.save_html(path)``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ __all__ = ["apl", "bar_figure", "image_figure", "label", "to_uint8_rgb"]
13
+
14
+
15
+ def apl():
16
+ """Import anyplotlib lazily so headless jobs never pay for the display stack."""
17
+ try:
18
+ import anyplotlib
19
+ except ImportError as error: # pragma: no cover
20
+ raise ImportError(
21
+ "anyplotlib is required for .show(); install it with `pip install anyplotlib`"
22
+ ) from error
23
+ return anyplotlib
24
+
25
+
26
+ def label(panel, title: str) -> None:
27
+ """Attach a title to a plot panel if the panel supports one."""
28
+ if not title:
29
+ return
30
+ for method in ("set_title", "set_label"):
31
+ setter = getattr(panel, method, None)
32
+ if callable(setter):
33
+ try:
34
+ setter(title)
35
+ except Exception: # noqa: BLE001 - a missing title must never break .show()
36
+ pass
37
+ return
38
+
39
+
40
+ def to_uint8_rgb(image: np.ndarray) -> np.ndarray:
41
+ """Normalize a 2D array to a grayscale (H, W, 3) uint8 texture."""
42
+ image = np.asarray(image, dtype=np.float64)
43
+ lo, hi = float(np.nanmin(image)), float(np.nanmax(image))
44
+ scale = 255.0 / (hi - lo) if hi > lo else 0.0
45
+ gray = ((image - lo) * scale).astype(np.uint8)
46
+ return np.repeat(gray[..., None], 3, axis=-1)
47
+
48
+
49
+ def bar_figure(values, labels, title: str = "", colors=None):
50
+ """Bar chart of one value per labeled item (structure factors, reflections)."""
51
+ values = np.asarray(values, dtype=np.float64)
52
+ fig, ax = apl().subplots(1, 1, figsize=(max(560, 18 * len(values)), 340))
53
+ kwargs = {"x_labels": list(labels), "show_values": False}
54
+ if colors is not None:
55
+ kwargs["colors"] = list(colors)
56
+ else:
57
+ kwargs["color"] = "#4fc3f7"
58
+ label(ax.bar(values, **kwargs), title)
59
+ return fig
60
+
61
+
62
+ def image_figure(image: np.ndarray, title: str = "", cmap: str = "gray"):
63
+ """Single image panel. Row 0 is drawn at the bottom (Lambert +Y up)."""
64
+ image = np.asarray(image, dtype=np.float64)
65
+ fig, ax = apl().subplots(1, 1, figsize=(520, 520))
66
+ label(ax.imshow(image, cmap=cmap, origin="lower"), title)
67
+ return fig
de_ebsd/viz/maps.py ADDED
@@ -0,0 +1,277 @@
1
+ """Indexing result figures: pattern match, orientation map, SO(3) correlation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from de_ebsd.core.constants import DIVISION_FLOOR
8
+ from de_ebsd.viz.figures import apl, label
9
+
10
+ __all__ = ["show_correlation", "show_map", "show_match", "show_refinement"]
11
+
12
+
13
+ def _to_numpy(x) -> np.ndarray:
14
+ detach = getattr(x, "detach", None)
15
+ if detach is not None:
16
+ x = detach().cpu().numpy()
17
+ return np.asarray(x, dtype=np.float64)
18
+
19
+
20
+ def _to_image(x) -> np.ndarray:
21
+ image = np.squeeze(_to_numpy(x))
22
+ if image.ndim != 2:
23
+ raise ValueError(f"expected a 2-D pattern, got shape {image.shape}")
24
+ return image
25
+
26
+
27
+ def _standardize(image: np.ndarray) -> np.ndarray:
28
+ image = image - image.mean()
29
+ std = float(image.std())
30
+ return image / std if std > DIVISION_FLOOR else image
31
+
32
+
33
+ def _ncc(a: np.ndarray, b: np.ndarray) -> float:
34
+ a0 = a - a.mean()
35
+ b0 = b - b.mean()
36
+ na = float(np.linalg.norm(a0))
37
+ nb = float(np.linalg.norm(b0))
38
+ if na < DIVISION_FLOOR or nb < DIVISION_FLOOR:
39
+ return 0.0
40
+ return float(np.vdot(a0, b0) / (na * nb))
41
+
42
+
43
+ def show_match(experimental, master, detector, orientation, energy_kev=None):
44
+ """The indexing sanity check: experimental vs simulated pattern at the fit.
45
+
46
+ Renders three panels side by side -- the experimental pattern, the pattern
47
+ :meth:`de_ebsd.sim.MasterPattern.project` produces at the indexed
48
+ ``orientation``, and their (contrast-standardized) difference -- so a viewer
49
+ can see at a glance whether the orientation is right. The middle panel is
50
+ titled with the normalized cross-correlation (:func:`_ncc`) between the
51
+ experimental and simulated patterns: a contrast-invariant number in
52
+ ``[-1, 1]`` that quantifies the match (``~1`` is a good index).
53
+
54
+ Parameters
55
+ ----------
56
+ experimental
57
+ The measured detector image ``(H, W)`` (numpy array or torch tensor;
58
+ a leading length-1 axis is squeezed).
59
+ master
60
+ The :class:`de_ebsd.sim.MasterPattern` used for indexing.
61
+ detector
62
+ The :class:`de_ebsd.sim.detector.EBSDDetector` geometry.
63
+ orientation
64
+ The indexed orientation as a length-1 :class:`orix.quaternion.Rotation`
65
+ (e.g. a single element of :meth:`SphericalIndexer.index`'s output).
66
+ energy_kev
67
+ Energy bin to project (default: the master's highest).
68
+
69
+ Returns
70
+ -------
71
+ anyplotlib.Figure
72
+ A 1x3 figure (experimental | simulated | difference).
73
+ """
74
+ exp = _to_image(experimental)
75
+ sim = _to_image(master.project(detector, orientation, energy_kev=energy_kev))
76
+ if sim.shape != exp.shape:
77
+ raise ValueError(
78
+ f"simulated pattern {sim.shape} does not match experimental {exp.shape}; "
79
+ "check the detector shape and orientation length (must be 1)"
80
+ )
81
+ diff = _standardize(exp) - _standardize(sim)
82
+ score = _ncc(exp, sim)
83
+
84
+ fig, axes = apl().subplots(1, 3, figsize=(1140, 400))
85
+ ax_e, ax_s, ax_d = np.ravel(axes)[:3]
86
+ label(ax_e.imshow(exp, cmap="gray", origin="lower"), "experimental")
87
+ label(ax_s.imshow(sim, cmap="gray", origin="lower"), f"simulated | NCC = {score:.3f}")
88
+ label(ax_d.imshow(diff, cmap="coolwarm", origin="lower"), "difference")
89
+ fig.match_ncc = score
90
+ return fig
91
+
92
+
93
+ def _ipf_rgb(crystal_map) -> np.ndarray:
94
+ from orix.plot import IPFColorKeyTSL
95
+ from orix.vector import Vector3d
96
+
97
+ point_group = crystal_map.phases[crystal_map.phase_id[0]].point_group
98
+ key = IPFColorKeyTSL(point_group, direction=Vector3d.zvector())
99
+ rgb = key.orientation2color(crystal_map.rotations) # (N, 3) floats in [0, 1]
100
+ rgb = np.asarray(rgb, dtype=np.float64).reshape(*crystal_map.shape, 3)
101
+ return (np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)
102
+
103
+
104
+ def _quality_image(crystal_map) -> np.ndarray:
105
+ if "quality" not in crystal_map.prop:
106
+ raise KeyError("crystal_map.prop has no 'quality'; index_scan stores it there")
107
+ return np.asarray(crystal_map.prop["quality"], dtype=np.float64).reshape(crystal_map.shape)
108
+
109
+
110
+ def show_map(crystal_map, value: str = "ipf"):
111
+ """Show an indexed scan (orix ``CrystalMap``) as an IPF and/or quality map.
112
+
113
+ Parameters
114
+ ----------
115
+ crystal_map
116
+ The :class:`orix.crystal_map.CrystalMap` from
117
+ :meth:`SphericalIndexer.index_scan` (single phase, ``prop['quality']``
118
+ holding the per-point score).
119
+ value
120
+ Which map(s) to draw:
121
+
122
+ * ``"ipf"`` (default) -- the IPF-z orientation map (orix TSL coloring);
123
+ * ``"quality"`` -- the ``prop['quality']`` field, sequential colormap;
124
+ * ``"both"`` -- the two side by side in a 1x2 figure.
125
+
126
+ Returns
127
+ -------
128
+ anyplotlib.Figure
129
+ A single-panel figure (``"ipf"`` / ``"quality"``) or a 1x2 figure
130
+ (``"both"``). Row 0 of the scan is drawn at the top (``origin="upper"``).
131
+ """
132
+ if value not in ("ipf", "quality", "both"):
133
+ raise ValueError("value must be 'ipf', 'quality', or 'both'")
134
+
135
+ if value == "both":
136
+ fig, axes = apl().subplots(1, 2, figsize=(1000, 480))
137
+ ax_ipf, ax_q = np.ravel(axes)[:2]
138
+ label(ax_ipf.imshow(_ipf_rgb(crystal_map), origin="upper"), "IPF-z")
139
+ panel_q = ax_q.imshow(_quality_image(crystal_map), cmap="viridis", origin="upper")
140
+ label(panel_q, "quality")
141
+ _colorbar_label(panel_q, "quality")
142
+ return fig
143
+
144
+ fig, ax = apl().subplots(1, 1, figsize=(560, 520))
145
+ if value == "ipf":
146
+ label(ax.imshow(_ipf_rgb(crystal_map), origin="upper"), "IPF-z orientation map")
147
+ else:
148
+ panel = ax.imshow(_quality_image(crystal_map), cmap="viridis", origin="upper")
149
+ label(panel, "quality map")
150
+ _colorbar_label(panel, "quality")
151
+ return fig
152
+
153
+
154
+ def _colorbar_label(panel, text: str) -> None:
155
+ setter = getattr(panel, "set_colorbar_label", None)
156
+ if callable(setter):
157
+ try:
158
+ setter(text)
159
+ except Exception: # noqa: BLE001 - a missing colorbar must never break .show()
160
+ pass
161
+
162
+
163
+ def show_correlation(correlation):
164
+ """Render the SO(3) cross-correlation volume as a legible 2-D heatmap.
165
+
166
+ The cross-correlation from
167
+ :func:`de_ebsd.index.spherical.correlation.so3_cross_correlation` is a function on
168
+ all of SO(3), sampled on the ``(alpha, beta, gamma)`` grid (shape
169
+ ``(N, N, N)``). This reduces it to an ``(alpha, gamma)`` heatmap by taking
170
+ the maximum over the ``beta`` axis (so the global peak survives the
171
+ projection), marks the peak cell, and titles the panel with the peak value
172
+ and its ``(alpha, beta, gamma)`` grid indices.
173
+
174
+ Parameters
175
+ ----------
176
+ correlation
177
+ The correlation grid, shape ``(N, N, N)`` for a single pattern, or a
178
+ stack ``(Np, N, N, N)`` whose **first** pattern is shown (numpy array or
179
+ torch tensor). Obtain it from
180
+ :meth:`~de_ebsd.index.spherical.indexer.SphericalIndexer.index` with
181
+ ``return_surface=True`` (the third returned element).
182
+
183
+ Returns
184
+ -------
185
+ anyplotlib.Figure
186
+ A single heatmap panel with the peak marked. The peak grid indices and
187
+ value are also attached to the figure as ``fig.peak_index`` (an
188
+ ``(alpha, beta, gamma)`` tuple) and ``fig.peak_value`` for programmatic
189
+ access.
190
+ """
191
+ corr = _to_numpy(correlation)
192
+ if corr.ndim == 4:
193
+ corr = corr[0]
194
+ if corr.ndim != 3:
195
+ raise ValueError(f"correlation must be (N, N, N) or (Np, N, N, N), got shape {corr.shape}")
196
+
197
+ a_idx, b_idx, g_idx = (int(i) for i in np.unravel_index(int(np.argmax(corr)), corr.shape))
198
+ peak = float(corr[a_idx, b_idx, g_idx])
199
+ projection = corr.max(axis=1) # (alpha, gamma), max over beta -- keeps the global peak
200
+
201
+ fig, ax = apl().subplots(1, 1, figsize=(620, 560))
202
+ panel = ax.imshow(projection, cmap="magma", origin="lower")
203
+ label(
204
+ panel,
205
+ f"SO(3) correlation (max over beta) | peak {peak:.4g} "
206
+ f"at (a, b, g) = ({a_idx}, {b_idx}, {g_idx})",
207
+ )
208
+ _ax_setter(panel, "set_xlabel", "gamma index")
209
+ _ax_setter(panel, "set_ylabel", "alpha index")
210
+ # mark the peak: heatmap x is the gamma index, y is the alpha index
211
+ try:
212
+ panel.add_points([(g_idx, a_idx)], color="#4fc3f7", sizes=14, alpha=0.9)
213
+ except Exception: # noqa: BLE001 - the title already reports the peak location
214
+ pass
215
+
216
+ fig.peak_index = (a_idx, b_idx, g_idx)
217
+ fig.peak_value = peak
218
+ return fig
219
+
220
+
221
+ def _ax_setter(panel, method: str, value: str) -> None:
222
+ setter = getattr(panel, method, None)
223
+ if callable(setter):
224
+ try:
225
+ setter(value)
226
+ except Exception: # noqa: BLE001 - a missing axis label must never break .show()
227
+ pass
228
+
229
+
230
+ def show_refinement(history):
231
+ """Plot the refinement NCC trajectory (convergence) vs iteration.
232
+
233
+ Parameters
234
+ ----------
235
+ history
236
+ The per-iteration NCC trajectory from
237
+ :func:`de_ebsd.index.refine.orientation.refine_orientation` (``return_history=True``),
238
+ shape ``(n_iterations, Np)`` -- one column per pattern -- or ``(n_iterations,)`` for a
239
+ single pattern (numpy array or torch tensor). ``history[0]`` is the seed's
240
+ score at ``w = 0``.
241
+
242
+ Returns
243
+ -------
244
+ anyplotlib.Figure
245
+ A line plot of NCC against iteration: one thin curve per pattern, plus a
246
+ thick mean curve when more than one pattern is refined.
247
+ """
248
+ hist = _to_numpy(history)
249
+ if hist.ndim == 1:
250
+ hist = hist[:, None]
251
+ if hist.ndim != 2:
252
+ raise ValueError(
253
+ f"history must be (n_iterations, Np) or (n_iterations,), got shape {hist.shape}"
254
+ )
255
+ n_iterations, n_p = hist.shape
256
+ iters = np.arange(n_iterations, dtype=np.float64)
257
+
258
+ fig, ax = apl().subplots(1, 1, figsize=(680, 420))
259
+ per_pattern_label = "per pattern" if n_p > 1 else "NCC"
260
+ plot = ax.plot(
261
+ hist[:, 0],
262
+ axes=[iters],
263
+ units="iteration",
264
+ y_units="NCC",
265
+ color="#4fc3f7",
266
+ linewidth=1.0 if n_p > 1 else 2.0,
267
+ alpha=0.5 if n_p > 1 else 1.0,
268
+ label=per_pattern_label,
269
+ )
270
+ for p in range(1, n_p):
271
+ plot.add_line(hist[:, p], x_axis=iters, color="#4fc3f7", linewidth=1.0, alpha=0.5)
272
+ if n_p > 1:
273
+ plot.add_line(hist.mean(axis=1), x_axis=iters, color="#ff7043", linewidth=2.5, label="mean")
274
+ _ax_setter(plot, "set_title", "Refinement convergence")
275
+ _ax_setter(plot, "set_xlabel", "iteration")
276
+ _ax_setter(plot, "set_ylabel", "NCC")
277
+ return fig
@@ -0,0 +1,164 @@
1
+ """Master-pattern, crystal-structure and direction-sampling figures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from de_ebsd.viz.figures import apl, label, to_uint8_rgb
8
+
9
+ __all__ = [
10
+ "crystal_structure_figure",
11
+ "hemispheres_figure",
12
+ "phase_overview_figure",
13
+ "sampling_sphere_figure",
14
+ "sphere_figure",
15
+ ]
16
+
17
+ _ELEMENT_COLORS = {
18
+ "H": (1.00, 1.00, 1.00),
19
+ "C": (0.36, 0.40, 0.45),
20
+ "N": (0.25, 0.40, 0.95),
21
+ "O": (0.93, 0.20, 0.22),
22
+ "Al": (0.75, 0.65, 0.65),
23
+ "Si": (0.94, 0.66, 0.48),
24
+ "Ti": (0.75, 0.76, 0.78),
25
+ "Cr": (0.54, 0.60, 0.78),
26
+ "Mn": (0.61, 0.48, 0.78),
27
+ "Fe": (0.88, 0.40, 0.20),
28
+ "Co": (0.94, 0.56, 0.63),
29
+ "Ni": (0.31, 0.82, 0.31),
30
+ "Cu": (0.78, 0.50, 0.20),
31
+ "Zn": (0.49, 0.50, 0.69),
32
+ }
33
+ _DEFAULT_COLOR = (0.40, 0.75, 0.90)
34
+
35
+
36
+ def hemispheres_figure(north: np.ndarray, south: np.ndarray, title: str = "", cmap: str = "gray"):
37
+ """North and south hemisphere maps side by side."""
38
+ fig, axes = apl().subplots(1, 2, figsize=(940, 470))
39
+ ax_n, ax_s = np.ravel(axes)[:2]
40
+ panel_n = ax_n.imshow(np.asarray(north, dtype=np.float64), cmap=cmap, origin="lower")
41
+ panel_s = ax_s.imshow(np.asarray(south, dtype=np.float64), cmap=cmap, origin="lower")
42
+ label(panel_n, f"{title} - north" if title else "north")
43
+ label(panel_s, f"{title} - south" if title else "south")
44
+ return fig
45
+
46
+
47
+ def sphere_figure(
48
+ equirectangular: np.ndarray,
49
+ title: str = "",
50
+ azimuth: float = -60.0,
51
+ elevation: float = 30.0,
52
+ n_grid: int = 96,
53
+ ):
54
+ """The master pattern textured onto an interactive 3D sphere.
55
+
56
+ ``equirectangular`` is sampled on a latitude/longitude grid: row 0 is the
57
+ north pole, columns span longitude -pi..pi (``MasterPattern.to_equirectangular``).
58
+ """
59
+ fig, ax = apl().subplots(1, 1, figsize=(560, 560))
60
+ _sphere_panel(ax, equirectangular, title, azimuth, elevation, n_grid)
61
+ return fig
62
+
63
+
64
+ def _sphere_panel(ax, equirectangular, title, azimuth, elevation, n_grid):
65
+ theta = np.linspace(0.0, np.pi, n_grid)
66
+ phi = np.linspace(-np.pi, np.pi, 2 * n_grid)
67
+ t, p = np.meshgrid(theta, phi, indexing="ij")
68
+ x = np.sin(t) * np.cos(p)
69
+ y = np.sin(t) * np.sin(p)
70
+ z = np.cos(t)
71
+
72
+ surface = ax.plot_surface(
73
+ x, y, z, bounds=((-1.0, 1.0),) * 3, azimuth=azimuth, elevation=elevation, gpu="auto"
74
+ )
75
+ surface.set_texture(to_uint8_rgb(equirectangular), cull_backfaces=True)
76
+ label(surface, title)
77
+ return surface
78
+
79
+
80
+ def phase_overview_figure(crystal, equirectangular: np.ndarray, *, name="Phase", n_grid=96):
81
+ """Dominant master pattern and compact structure in one figure.
82
+
83
+ One figure means one anywidget in Jupyter and one registration through
84
+ SpyDE's Electron bridge.
85
+ """
86
+ fig, axes = apl().subplots(1, 2, figsize=(820, 400), width_ratios=[1.45, 1.0])
87
+ master_ax, crystal_ax = np.ravel(axes)[:2]
88
+ _sphere_panel(master_ax, equirectangular, f"{name} master pattern", -60.0, 30.0, n_grid)
89
+ _crystal_structure_panel(crystal_ax, crystal, f"{name} structure")
90
+ return fig
91
+
92
+
93
+ def crystal_structure_figure(crystal, title: str = ""):
94
+ """Expanded atoms of one unit cell as a 3-D plot, in Cartesian nanometres."""
95
+ fig, ax = apl().subplots(1, 1, figsize=(480, 420))
96
+ _crystal_structure_panel(ax, crystal, title or "Crystal structure")
97
+ return fig
98
+
99
+
100
+ def _crystal_structure_panel(ax, crystal, title: str):
101
+ fractional = np.concatenate(crystal.positions, axis=0)
102
+ elements = [
103
+ site.element
104
+ for site, positions in zip(crystal.sites, crystal.positions, strict=True)
105
+ for _ in positions
106
+ ]
107
+ cartesian = fractional @ crystal.direct_structure_matrix.T
108
+ colors = np.asarray([_ELEMENT_COLORS.get(e, _DEFAULT_COLOR) for e in elements])
109
+
110
+ # include the cell corners so non-orthogonal cells are not clipped
111
+ corners = np.asarray([[i, j, k] for i in (0.0, 1.0) for j in (0.0, 1.0) for k in (0.0, 1.0)])
112
+ extent = np.vstack((cartesian, corners @ crystal.direct_structure_matrix.T))
113
+ lo, hi = extent.min(axis=0), extent.max(axis=0)
114
+ padding = 0.08 * np.maximum(hi - lo, 1e-6)
115
+ bounds = tuple((float(a), float(b)) for a, b in zip(lo - padding, hi + padding, strict=True))
116
+
117
+ points = ax.scatter3d(
118
+ cartesian[:, 0],
119
+ cartesian[:, 1],
120
+ cartesian[:, 2],
121
+ colors=colors,
122
+ point_size=max(5.0, min(12.0, 48.0 / np.sqrt(len(cartesian)))),
123
+ bounds=bounds,
124
+ x_label="x (nm)",
125
+ y_label="y (nm)",
126
+ z_label="z (nm)",
127
+ azimuth=-55.0,
128
+ elevation=24.0,
129
+ gpu="auto",
130
+ )
131
+ label(points, title)
132
+ return points
133
+
134
+
135
+ def sampling_sphere_figure(directions: np.ndarray, title: str = "Incident-direction sampling"):
136
+ """Sampled unit directions on an interactive reference sphere.
137
+
138
+ The first half of ``directions`` is colored as the northern hemisphere and
139
+ the second half as the southern, matching ``KVectorGrid.flat``.
140
+ """
141
+ directions = np.asarray(directions, dtype=np.float64).reshape(-1, 3)
142
+ if directions.size == 0 or directions.shape[1] != 3:
143
+ raise ValueError("directions must have shape (N, 3) and contain at least one vector")
144
+
145
+ n_north = directions.shape[0] // 2
146
+ colors = np.empty((directions.shape[0], 3), dtype=np.float64)
147
+ colors[:n_north] = (0.35, 0.71, 0.98)
148
+ colors[n_north:] = (0.98, 0.63, 0.36)
149
+
150
+ fig, ax = apl().subplots(1, 1, figsize=(560, 560))
151
+ points = ax.scatter3d(
152
+ directions[:, 0],
153
+ directions[:, 1],
154
+ directions[:, 2],
155
+ colors=colors,
156
+ point_size=3.0,
157
+ bounds=((-1.0, 1.0),) * 3,
158
+ azimuth=-60.0,
159
+ elevation=30.0,
160
+ gpu="auto",
161
+ )
162
+ points.set_sphere(radius=1.0, color="#9aa4b2", alpha=0.10, wireframe=True)
163
+ label(points, title)
164
+ return fig
@@ -0,0 +1,171 @@
1
+ // Detector geometry mirrors core.geometry.sample_to_detector_matrix (row vectors).
2
+ // Exported separately so parity with Python can be tested without a browser.
3
+ export function setupGeometry(s) {
4
+ const rad = a => a * Math.PI / 180;
5
+ const cross = (a,b) => [a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
6
+ let basis = [[0,1,0],[0,0,1],[1,0,0]];
7
+ for (const [axis, angle] of [[0,-s.sample_tilt],[0,s.detector_tilt],[1,-s.azimuthal],[2,-s.twist]]) {
8
+ const u=basis[axis], c=Math.cos(rad(angle)), sn=Math.sin(rad(angle));
9
+ basis=basis.map(v => {
10
+ const uv=cross(u,v), dot=u.reduce((sum,x,i)=>sum+x*v[i],0);
11
+ return v.map((x,i)=>x*c+uv[i]*sn+u[i]*dot*(1-c));
12
+ });
13
+ }
14
+ const H=s.detector_rows*s.px_size/1000, W=s.detector_cols*s.px_size/1000, L=s.pcz*H;
15
+ const toSample = v => [0,1,2].map(j => v.reduce((sum,x,i)=>sum+x*basis[i][j],0));
16
+ const c=Math.cos(rad(s.sample_tilt)), sn=Math.sin(rad(s.sample_tilt));
17
+ // Laboratory X horizontal, Z vertical; positive sample tilt slopes down right.
18
+ const toLab = v => [c*v[0]+sn*v[2],v[1],-sn*v[0]+c*v[2]];
19
+ const corners=[[-s.pcx*W,s.pcy*H,L],[(1-s.pcx)*W,s.pcy*H,L],
20
+ [(1-s.pcx)*W,-(1-s.pcy)*H,L],[-s.pcx*W,-(1-s.pcy)*H,L]].map(toSample);
21
+ const pc=toSample([0,0,L]);
22
+ return {H,W,L,corners,lab:corners.map(toLab),pc,pcLab:toLab(pc),normal:[sn,0,c],
23
+ tangent:[c,0,-sn], center:corners[0].map((_,j)=>corners.reduce((sum,v)=>sum+v[j],0)/4),
24
+ planeAngle:Math.acos(Math.min(1,Math.abs(basis[2][2])))*180/Math.PI,
25
+ footprint:s.probe_diameter_nm>0 && c>1e-12 ? [s.probe_diameter_nm/c,s.probe_diameter_nm] : null};
26
+ }
27
+
28
+ function render({model,el}) {
29
+ const host=el.shadowRoot || el.attachShadow({mode:"open"});
30
+ host.innerHTML=`<style>
31
+ :host {display:block; max-width:580px; color-scheme:light dark}
32
+ * {box-sizing:border-box}
33
+ .setup {--fg:var(--spyde-fg,var(--jp-ui-font-color1,light-dark(#242a33,#d8dee9)));
34
+ --bg:var(--spyde-panel-bg,var(--jp-layout-color1,light-dark(#f6f7f9,#20242d)));
35
+ --line:var(--spyde-border,var(--jp-border-color2,light-dark(#ccd0d7,#4c5565)));
36
+ --beam:var(--spyde-accent,light-dark(#1672b8,#5ba7f7)); --sample:light-dark(#a45c17,#e5a05c);
37
+ --screen:light-dark(#197349,#66c58a); --pc:light-dark(#ad2858,#f080a6);
38
+ color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px;
39
+ font:12px var(--jp-ui-font-family,system-ui,sans-serif);overflow:hidden}
40
+ header {padding:9px 12px;border-bottom:1px solid var(--line);display:flex;gap:10px;flex-wrap:wrap}
41
+ header span {margin-left:auto} svg {display:block;width:100%;font:12px system-ui;touch-action:manipulation}
42
+ svg text {fill:var(--fg)} .insets {display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line)}
43
+ .controls {padding:10px 12px;display:grid;grid-template-columns:1fr 1fr;gap:7px 14px;border-top:1px solid var(--line)}
44
+ label {display:flex;align-items:center;justify-content:space-between;gap:6px;min-width:0}
45
+ input {font:inherit;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:3px;
46
+ width:84px;min-width:0;padding:3px 5px} .facts,.note {padding:7px 12px;line-height:1.55}
47
+ .facts {border-top:1px solid var(--line);font-variant-numeric:tabular-nums} .note {font-size:11px}
48
+ @media(max-width:400px) {.insets {grid-template-columns:1fr}.controls {gap:7px}.controls label {flex-wrap:wrap}.controls input {width:100%}}
49
+ </style><section class="setup"><header><strong>Microscope / detector</strong><span></span></header>
50
+ <svg class="geometry" height="250" role="img" aria-label="Laboratory side view: beam, sample normal and detector"></svg>
51
+ <div class="insets"><svg class="face" height="175" role="img" aria-label="Detector face and Bruker pattern center"></svg>
52
+ <svg class="probe" height="175" role="img" aria-label="Geometric probe footprint on the sample"></svg></div>
53
+ <div class="facts" aria-live="polite"></div><div class="controls"></div>
54
+ <div class="note">Geometry editor only: existing simulation results do not change. In Jupyter, read edits with
55
+ <code>to_microscope()</code> / <code>to_detector()</code>, then recompute. Screen pitch includes binning.</div></section>`;
56
+ const root=host.querySelector('.setup'), main=host.querySelector('.geometry'), face=host.querySelector('.face'),
57
+ probe=host.querySelector('.probe'), controls=host.querySelector('.controls'), facts=host.querySelector('.facts');
58
+ const fields=[['energy_kev','Beam (keV)',0.001,null,0.1],['sample_tilt','Incidence (°)',0,90,0.1],
59
+ ['probe_diameter_nm','Probe Ø (nm; 0 = unset)',0,null,1],['detector_tilt','Screen tilt from vertical (°)',-90,90,0.1],
60
+ ['pcx','PCx / width',null,null,0.001],['pcy','PCy / height',null,null,0.001],
61
+ ['pcz','PCz / height',0.000001,null,0.001],['px_size','Screen pitch (µm)',0.000001,null,1],
62
+ ['detector_rows','Rows',1,null,1],['detector_cols','Columns',1,null,1],
63
+ ['azimuthal','Azimuth (°)',-180,180,0.1],['twist','Screen twist (°)',-180,180,0.1]];
64
+ const inputs=new Map();
65
+ for (const [key,label,min,max,step] of fields) {
66
+ const wrapper=document.createElement('label'), input=document.createElement('input');
67
+ wrapper.append(document.createTextNode(label)); input.type='number'; input.step=step;
68
+ if(min!==null) input.min=min; if(max!==null) input.max=max;
69
+ input.setAttribute('aria-label',label); wrapper.append(input); controls.append(wrapper); inputs.set(key,input);
70
+ input.addEventListener('change',()=>{
71
+ if(input.value.trim()==='' || !input.checkValidity() || !Number.isFinite(input.valueAsNumber)) {
72
+ input.reportValidity(); input.value=model.get(key); return;
73
+ }
74
+ model.set(key,input.valueAsNumber); model.save_changes();
75
+ });
76
+ }
77
+ const node=(svg,tag,attrs={},text='')=>{
78
+ const n=document.createElementNS('http://www.w3.org/2000/svg',tag);
79
+ for(const [k,v] of Object.entries(attrs)) n.setAttribute(k,v);
80
+ n.textContent=text; svg.append(n); return n;
81
+ };
82
+ const text=(svg,x,y,value,anchor='start')=>node(svg,'text',{x,y,'text-anchor':anchor},value);
83
+ const line=(svg,a,b,color,width=1,dash='')=>node(svg,'line',
84
+ {x1:a[0],y1:a[1],x2:b[0],y2:b[1],stroke:color,'stroke-width':width,'stroke-dasharray':dash});
85
+ const arrow=(svg,a,b,color)=>{
86
+ line(svg,a,b,color,2); const angle=Math.atan2(b[1]-a[1],b[0]-a[0]);
87
+ const p=[b,[b[0]-8*Math.cos(angle-.4),b[1]-8*Math.sin(angle-.4)],
88
+ [b[0]-8*Math.cos(angle+.4),b[1]-8*Math.sin(angle+.4)]];
89
+ node(svg,'polygon',{points:p.map(v=>v.join(',')).join(' '),fill:color});
90
+ };
91
+ const fmt=n=>Number(n).toFixed(2);
92
+ function draw() {
93
+ const s=Object.fromEntries(fields.map(([key])=>[key,Number(model.get(key))]));
94
+ const g=setupGeometry(s), has=model.get('has_detector');
95
+ const css=getComputedStyle(root), beam=css.getPropertyValue('--beam'), sample=css.getPropertyValue('--sample'),
96
+ screen=css.getPropertyValue('--screen'), pcColor=css.getPropertyValue('--pc');
97
+ for(const [key,input] of inputs) if(input!==host.activeElement) input.value=s[key];
98
+ host.querySelector('header span').textContent=`${model.get('crystal_label') || 'Sample'} · ${fmt(s.energy_kev)} keV`;
99
+ for(const svg of [main,face,probe]) {svg.replaceChildren(); svg.setAttribute('viewBox',`0 0 ${svg.clientWidth || 280} ${svg.getAttribute('height')}`);}
100
+ const w=main.clientWidth||560, H=250, size=has?Math.max(g.H,g.W,g.L):1;
101
+ const scaleVec=(v,n)=>v.map(x=>x*n);
102
+ const a=scaleVec(g.tangent,-size*.42), b=scaleVec(g.tangent,size*.42),
103
+ normal=scaleVec(g.normal,size*.40), source=[0,0,size*.72];
104
+ const all=[a,b,normal,source,[0,0,0],...(has?g.lab:[])];
105
+ const xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0]));
106
+ const zmin=Math.min(...all.map(p=>p[2])),zmax=Math.max(...all.map(p=>p[2]));
107
+ const k=Math.min((w-96)/(xmax-xmin),(H-76)/(zmax-zmin));
108
+ const P=v=>[48+(w-96-(xmax-xmin)*k)/2+(v[0]-xmin)*k,40+(zmax-v[2])*k];
109
+ const origin=P([0,0,0]), A=P(a), B=P(b);
110
+ text(main,12,18,'Laboratory side view · equal mm scale');
111
+ const back=v=>v.map((x,i)=>x-size*.07*g.normal[i]);
112
+ node(main,'polygon',{points:[a,b,back(b),back(a)].map(v=>P(v).join(',')).join(' '),fill:sample,opacity:.25});
113
+ line(main,A,B,sample,4); arrow(main,origin,P(normal),sample); arrow(main,P(source),origin,beam);
114
+ text(main,P(source)[0]-8,P(source)[1]+9,'beam','end');
115
+ text(main,P(normal)[0]+5,P(normal)[1]+4,'n');
116
+ text(main,12,H-10,`Incidence ${s.sample_tilt.toFixed(1)}° from n · grazing ${(90-s.sample_tilt).toFixed(1)}°`);
117
+ const r=28, end=[origin[0]+r*g.normal[0],origin[1]-r*g.normal[2]];
118
+ node(main,'path',{d:`M ${origin[0]} ${origin[1]-r} A ${r} ${r} 0 0 1 ${end[0]} ${end[1]}`,
119
+ fill:'none',stroke:sample,'stroke-width':1.5});
120
+ node(main,'circle',{cx:origin[0],cy:origin[1],r:3,fill:beam});
121
+ if(has) {
122
+ node(main,'polygon',{points:g.lab.map(v=>P(v).join(',')).join(' '),fill:screen,'fill-opacity':.2,stroke:screen,'stroke-width':3});
123
+ const pc=P(g.pcLab); line(main,origin,pc,pcColor,1.5,'4 3');
124
+ node(main,'circle',{cx:pc[0],cy:pc[1],r:4,fill:pcColor}); text(main,pc[0]+7,pc[1]-7,'PC');
125
+ text(main,w-12,34,`normal distance ${fmt(g.L)} mm`,'end');
126
+ text(main,w-12,51,`screen / sample planes: ${g.planeAngle.toFixed(1)}°`,'end');
127
+ }
128
+ // Detector face, independent of the side projection; row zero is the top.
129
+ const fw=face.clientWidth||280, fh=175;
130
+ text(face,12,20,has?'Detector face · Bruker PC':'No detector supplied');
131
+ if(has) {
132
+ const maxX=Math.max(1,s.pcx),minX=Math.min(0,s.pcx),maxY=Math.max(1,s.pcy),minY=Math.min(0,s.pcy);
133
+ const kf=Math.min((fw-60)/(g.W*(maxX-minX)),98/(g.H*(maxY-minY)));
134
+ const left=30-minX*g.W*kf, top=40-minY*g.H*kf, ww=g.W*kf, hh=g.H*kf;
135
+ node(face,'rect',{x:left,y:top,width:ww,height:hh,fill:screen,'fill-opacity':.13,stroke:screen});
136
+ const x=left+s.pcx*ww,y=top+s.pcy*hh;
137
+ line(face,[x-7,y],[x+7,y],pcColor,2);line(face,[x,y-7],[x,y+7],pcColor,2);
138
+ text(face,fw/2,fh-20,`${s.detector_cols} × ${s.detector_rows} pixels`,'middle');
139
+ text(face,fw/2,fh-4,`${fmt(g.W)} × ${fmt(g.H)} mm · row 0 at top`,'middle');
140
+ face.style.cursor='crosshair';
141
+ face.onclick=event=>{
142
+ const rect=face.getBoundingClientRect();
143
+ model.set('pcx',Math.min(1,Math.max(0,(event.clientX-rect.left-left)/ww)));
144
+ model.set('pcy',Math.min(1,Math.max(0,(event.clientY-rect.top-top)/hh)));model.save_changes();
145
+ };
146
+ }
147
+ const pw=probe.clientWidth||280;
148
+ text(probe,12,20,'Probe footprint · sample-plane inset');
149
+ if(g.footprint) {
150
+ const [major,minor]=g.footprint, rx=Math.min((pw-48)/2,82), ry=rx*minor/major;
151
+ node(probe,'ellipse',{cx:pw/2,cy:83,rx,ry,fill:beam,'fill-opacity':.18,stroke:beam,'stroke-width':2});
152
+ line(probe,[pw/2-rx,83],[pw/2+rx,83],beam,1,'3 3');
153
+ text(probe,pw/2,143,`${fmt(major)} × ${fmt(minor)} nm`,'middle');
154
+ text(probe,pw/2,161,'Parallel circular probe · separate scale','middle');
155
+ } else {
156
+ text(probe,pw/2,78,s.probe_diameter_nm?'No finite footprint at 90°':'Probe diameter not specified','middle');
157
+ text(probe,pw/2,99,'Not an interaction-volume model','middle');
158
+ }
159
+ const centerLab=g.lab.reduce((sum,p)=>sum+p[2],0)/4;
160
+ const minHeight=Math.min(...g.corners.map(p=>p[2]));
161
+ facts.textContent=has?`PC (${s.pcx.toFixed(3)}, ${s.pcy.toFixed(3)}, ${s.pcz.toFixed(3)}) · screen center: ${fmt(centerLab)} mm above impact (lab), ${fmt(g.center[2])} mm above the sample plane (normal). `+
162
+ (minHeight<0?'Warning: part of the screen lies behind the extended sample plane. ':'')+
163
+ 'Click the detector face to set PCx / PCy.':
164
+ 'Sample normal points out of the material. Probe size is descriptive metadata, not a solver input.';
165
+ }
166
+ const names=[...fields.map(f=>f[0]),'crystal_label','has_detector'];
167
+ for(const name of names) model.on(`change:${name}`,draw);
168
+ const observer=new ResizeObserver(draw); observer.observe(root); draw();
169
+ return ()=>{observer.disconnect();for(const name of names) model.off(`change:${name}`,draw);};
170
+ }
171
+ export default {render};