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/sim/simulator.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""The master-pattern simulation chain.
|
|
2
|
+
|
|
3
|
+
:class:`MasterPatternSimulator` composes a crystal, a microscope and a backend,
|
|
4
|
+
exposing each intermediate (structure factors, reflections, k-vectors) as an
|
|
5
|
+
inspectable, showable object before ``compute()`` runs the full simulation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import torch
|
|
14
|
+
|
|
15
|
+
from de_ebsd.core.device import resolve_device
|
|
16
|
+
from de_ebsd.crystal.lattice import Crystal
|
|
17
|
+
from de_ebsd.crystal.structure_factors import StructureFactors, compute_structure_factors
|
|
18
|
+
from de_ebsd.diffraction.backends import (
|
|
19
|
+
DynamicalMatrix,
|
|
20
|
+
MasterPatternBackend,
|
|
21
|
+
SimulationInputs,
|
|
22
|
+
build_dynamical_matrix,
|
|
23
|
+
classify_reflections,
|
|
24
|
+
resolve_backend,
|
|
25
|
+
)
|
|
26
|
+
from de_ebsd.diffraction.bethe import BetheParameters
|
|
27
|
+
from de_ebsd.diffraction.kvectors import reduce_to_fundamental_sector, sample_incident_directions
|
|
28
|
+
from de_ebsd.diffraction.reflections import ReflectionList, master_reflection_list
|
|
29
|
+
from de_ebsd.sim.masterpattern import MasterPattern
|
|
30
|
+
from de_ebsd.sim.microscope import Microscope
|
|
31
|
+
from de_ebsd.sim.montecarlo.depth import DepthDistribution
|
|
32
|
+
|
|
33
|
+
__all__ = ["MasterPatternSimulator"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MasterPatternSimulator:
|
|
37
|
+
"""Simulate an EBSD master pattern for one crystal and beam energy.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
crystal
|
|
42
|
+
The crystal structure.
|
|
43
|
+
microscope
|
|
44
|
+
Beam energy and sample geometry.
|
|
45
|
+
npx
|
|
46
|
+
Half-width of the square Lambert grid; the master pattern maps have
|
|
47
|
+
shape ``(2 npx + 1, 2 npx + 1)`` per hemisphere.
|
|
48
|
+
dmin
|
|
49
|
+
Smallest interplanar spacing (nm) still included as a reflection.
|
|
50
|
+
Lowering it adds beams and raises accuracy and cost.
|
|
51
|
+
bethe
|
|
52
|
+
Bethe-potential truncation thresholds.
|
|
53
|
+
monte_carlo
|
|
54
|
+
Energy/depth statistics for the depth integration (required by the
|
|
55
|
+
the Bloch-wave backends; when omitted, a native Monte Carlo is run).
|
|
56
|
+
backend
|
|
57
|
+
``"bloch_prism"`` (default), ``"bloch"``, or any
|
|
58
|
+
:class:`~de_ebsd.diffraction.backends.MasterPatternBackend` instance.
|
|
59
|
+
Both named backends solve the same Bloch-wave model. ``"bloch"`` is the
|
|
60
|
+
float64 reference, one direction at a time; ``"bloch_prism"`` batches it
|
|
61
|
+
on the GPU and reproduces it (r = 1.0000 against the EMsoft master of
|
|
62
|
+
Si at npx = 500), so it is the default.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
crystal: Crystal,
|
|
68
|
+
microscope: Microscope,
|
|
69
|
+
npx: int = 150,
|
|
70
|
+
dmin: float = 0.1,
|
|
71
|
+
bethe: BetheParameters | None = None,
|
|
72
|
+
monte_carlo: DepthDistribution | None = None,
|
|
73
|
+
backend: str | MasterPatternBackend = "bloch_prism",
|
|
74
|
+
) -> None:
|
|
75
|
+
if npx < 1:
|
|
76
|
+
raise ValueError("npx must be >= 1")
|
|
77
|
+
if dmin <= 0:
|
|
78
|
+
raise ValueError("dmin must be positive (nm)")
|
|
79
|
+
self.crystal = crystal
|
|
80
|
+
self.microscope = microscope
|
|
81
|
+
self.npx = int(npx)
|
|
82
|
+
self.dmin = float(dmin)
|
|
83
|
+
self.bethe = bethe if bethe is not None else BetheParameters()
|
|
84
|
+
self.monte_carlo = monte_carlo
|
|
85
|
+
self.backend = backend
|
|
86
|
+
self._sf: StructureFactors | None = None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def energy_kev(self) -> float:
|
|
90
|
+
return self.microscope.energy_kev
|
|
91
|
+
|
|
92
|
+
def structure_factors(self) -> StructureFactors:
|
|
93
|
+
"""U_g with absorption for all reflections down to dmin (cached)."""
|
|
94
|
+
if self._sf is None:
|
|
95
|
+
self._sf = compute_structure_factors(self.crystal, self.energy_kev, self.dmin)
|
|
96
|
+
return self._sf
|
|
97
|
+
|
|
98
|
+
def reflections(self, k=None) -> ReflectionList:
|
|
99
|
+
"""The reflection list; with ``k``, classified strong/weak for that beam.
|
|
100
|
+
|
|
101
|
+
``k`` is a beam direction in the Cartesian crystal frame (any length;
|
|
102
|
+
it is normalized). For cubic crystals Cartesian components coincide
|
|
103
|
+
with lattice directions.
|
|
104
|
+
"""
|
|
105
|
+
structure_factors = self.structure_factors()
|
|
106
|
+
if k is None:
|
|
107
|
+
return master_reflection_list(structure_factors)
|
|
108
|
+
return classify_reflections(structure_factors, _unit(k), self.bethe)
|
|
109
|
+
|
|
110
|
+
def dynamical_matrix(self, k) -> DynamicalMatrix:
|
|
111
|
+
"""The Bloch dynamical matrix (with Bethe corrections) for one beam."""
|
|
112
|
+
structure_factors = self.structure_factors()
|
|
113
|
+
refl = classify_reflections(structure_factors, _unit(k), self.bethe)
|
|
114
|
+
matrix = build_dynamical_matrix(structure_factors, refl)
|
|
115
|
+
return DynamicalMatrix(matrix=matrix, hkl=refl.strong(), k=refl.k, reflections=refl)
|
|
116
|
+
|
|
117
|
+
def compute(
|
|
118
|
+
self,
|
|
119
|
+
device: torch.device | str | None = None,
|
|
120
|
+
dtype: torch.dtype = torch.float32,
|
|
121
|
+
chunk_size: int = 262_144,
|
|
122
|
+
mc_electrons: int = 200_000,
|
|
123
|
+
symmetry: bool = True,
|
|
124
|
+
energy_resolved: bool = False,
|
|
125
|
+
progress: Callable[[int, int], None] | None = None,
|
|
126
|
+
) -> MasterPattern:
|
|
127
|
+
"""Compute the master pattern on the Lambert grid.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
device
|
|
132
|
+
Torch device (e.g. ``"cuda"``); default: torch's current default.
|
|
133
|
+
dtype
|
|
134
|
+
Floating point type for the direction batch (FP32 recommended).
|
|
135
|
+
chunk_size
|
|
136
|
+
Number of beam directions per batch, bounding peak memory.
|
|
137
|
+
mc_electrons
|
|
138
|
+
Electron count for the native Monte Carlo depth profile built when
|
|
139
|
+
the backend needs one and no ``monte_carlo`` was supplied. Ignored if
|
|
140
|
+
a depth profile is provided or the backend does not use one.
|
|
141
|
+
symmetry
|
|
142
|
+
Fold the beam directions onto the point-group fundamental sector so
|
|
143
|
+
each symmetry-distinct direction is computed once (exact; ~15x fewer
|
|
144
|
+
evaluations for cubic). Disable to evaluate every grid point.
|
|
145
|
+
energy_resolved
|
|
146
|
+
Compute one Lambert map per Monte Carlo energy bin (structure factors
|
|
147
|
+
and depth profile per energy), giving an ``(nE, 2, n, n)`` master.
|
|
148
|
+
Requires a depth-integrating backend; ignored otherwise. Default is a
|
|
149
|
+
single map at the beam energy.
|
|
150
|
+
progress
|
|
151
|
+
Called as ``progress(done, total)`` after each batch of beam
|
|
152
|
+
directions, counting batches over all energies, so a caller can show
|
|
153
|
+
how far a long simulation has got. At the default *chunk_size* a
|
|
154
|
+
master of any usual size is one batch per energy, so a single-energy
|
|
155
|
+
compute reports only its end. The Monte Carlo, the symmetry fold and
|
|
156
|
+
the first energy all run before the first call. It is called on the
|
|
157
|
+
calling thread, inside the loop, so it should be quick and never
|
|
158
|
+
block; an exception raised in it stops the simulation.
|
|
159
|
+
"""
|
|
160
|
+
device = resolve_device(device)
|
|
161
|
+
backend = resolve_backend(self.backend)
|
|
162
|
+
|
|
163
|
+
# depth-integrating backends need a Monte Carlo exit-depth profile; when
|
|
164
|
+
# the user supplied none, run the native (EMsoft-free) MC at the
|
|
165
|
+
# experimental tilt so a plain compute() stays standalone and accurate.
|
|
166
|
+
monte_carlo = self.monte_carlo
|
|
167
|
+
if monte_carlo is None and backend.uses_depth_profile:
|
|
168
|
+
monte_carlo = DepthDistribution.from_simulation(
|
|
169
|
+
self.crystal,
|
|
170
|
+
self.energy_kev,
|
|
171
|
+
tilt_deg=self.microscope.sample_tilt,
|
|
172
|
+
n_electrons=mc_electrons,
|
|
173
|
+
device=device,
|
|
174
|
+
seed=0,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# beam directions and the (energy-independent) symmetry fold
|
|
178
|
+
grid = sample_incident_directions(self.npx, device=device, dtype=dtype)
|
|
179
|
+
directions = grid.flat()
|
|
180
|
+
if symmetry:
|
|
181
|
+
reps, inverse = reduce_to_fundamental_sector(
|
|
182
|
+
directions, self.crystal.space_group, cache_key=self.npx
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
reps, inverse = directions, None
|
|
186
|
+
n = 2 * self.npx + 1
|
|
187
|
+
|
|
188
|
+
if energy_resolved and backend.uses_depth_profile:
|
|
189
|
+
energies = np.asarray(monte_carlo.energies_kev, dtype=float)
|
|
190
|
+
else:
|
|
191
|
+
energies = np.array([self.energy_kev])
|
|
192
|
+
|
|
193
|
+
starts = range(0, reps.shape[0], chunk_size)
|
|
194
|
+
total, done = len(energies) * len(starts), 0
|
|
195
|
+
maps = []
|
|
196
|
+
for energy in energies:
|
|
197
|
+
structure_factors = (
|
|
198
|
+
self.structure_factors()
|
|
199
|
+
if abs(energy - self.energy_kev) < 1e-9
|
|
200
|
+
else compute_structure_factors(self.crystal, float(energy), self.dmin)
|
|
201
|
+
)
|
|
202
|
+
backend.prepare(
|
|
203
|
+
SimulationInputs(
|
|
204
|
+
structure_factors=structure_factors,
|
|
205
|
+
bethe=self.bethe,
|
|
206
|
+
monte_carlo=monte_carlo,
|
|
207
|
+
device=device,
|
|
208
|
+
dtype=dtype,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
rep_values = torch.empty(reps.shape[0], device=device, dtype=dtype)
|
|
212
|
+
for start in starts:
|
|
213
|
+
stop = min(start + chunk_size, reps.shape[0])
|
|
214
|
+
rep_values[start:stop] = backend.intensities(reps[start:stop])
|
|
215
|
+
done += 1
|
|
216
|
+
if progress is not None:
|
|
217
|
+
progress(done, total)
|
|
218
|
+
values = rep_values if inverse is None else rep_values[inverse]
|
|
219
|
+
maps.append(values.reshape(2, n, n))
|
|
220
|
+
|
|
221
|
+
return MasterPattern(
|
|
222
|
+
data=torch.stack(maps, dim=0),
|
|
223
|
+
energies_kev=energies,
|
|
224
|
+
metadata={
|
|
225
|
+
# the object, not its repr: the indexers derive the phase and
|
|
226
|
+
# point group from it (save() stringifies metadata on write)
|
|
227
|
+
"crystal": self.crystal,
|
|
228
|
+
"backend": backend.name,
|
|
229
|
+
"npx": self.npx,
|
|
230
|
+
"dmin": self.dmin,
|
|
231
|
+
"energy_kev": self.energy_kev,
|
|
232
|
+
"bethe": repr(self.bethe),
|
|
233
|
+
"energy_resolved": len(energies) > 1,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def refine(self, **changes) -> MasterPatternSimulator:
|
|
238
|
+
"""A new simulator with updated parameters, reusing valid caches.
|
|
239
|
+
|
|
240
|
+
Accepted keys: ``npx``, ``dmin``, ``bethe``, ``backend``,
|
|
241
|
+
``monte_carlo``, ``microscope``, ``crystal``. Example::
|
|
242
|
+
|
|
243
|
+
better = sim.refine(npx=300, dmin=0.05)
|
|
244
|
+
|
|
245
|
+
Structure factors are carried over when the crystal, energy, and dmin
|
|
246
|
+
are unchanged (raising ``npx`` or adjusting Bethe thresholds is free).
|
|
247
|
+
"""
|
|
248
|
+
allowed = {"npx", "dmin", "bethe", "backend", "monte_carlo", "microscope", "crystal"}
|
|
249
|
+
unknown = set(changes) - allowed
|
|
250
|
+
if unknown:
|
|
251
|
+
raise TypeError(f"unknown refine() parameters: {sorted(unknown)}")
|
|
252
|
+
new = MasterPatternSimulator(
|
|
253
|
+
crystal=changes.get("crystal", self.crystal),
|
|
254
|
+
microscope=changes.get("microscope", self.microscope),
|
|
255
|
+
npx=changes.get("npx", self.npx),
|
|
256
|
+
dmin=changes.get("dmin", self.dmin),
|
|
257
|
+
bethe=changes.get("bethe", self.bethe),
|
|
258
|
+
monte_carlo=changes.get("monte_carlo", self.monte_carlo),
|
|
259
|
+
backend=changes.get("backend", self.backend),
|
|
260
|
+
)
|
|
261
|
+
same_sf = (
|
|
262
|
+
new.crystal is self.crystal
|
|
263
|
+
and new.dmin == self.dmin
|
|
264
|
+
and new.energy_kev == self.energy_kev
|
|
265
|
+
)
|
|
266
|
+
if same_sf:
|
|
267
|
+
new._sf = self._sf
|
|
268
|
+
return new
|
|
269
|
+
|
|
270
|
+
def show_setup(self, detector=None):
|
|
271
|
+
"""Interactive geometry widget for this simulation's setup."""
|
|
272
|
+
from de_ebsd.viz.widgets import MicroscopeWidget
|
|
273
|
+
|
|
274
|
+
return MicroscopeWidget.from_setup(
|
|
275
|
+
microscope=self.microscope, detector=detector, crystal=self.crystal
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def __repr__(self) -> str:
|
|
279
|
+
backend = self.backend if isinstance(self.backend, str) else self.backend.name
|
|
280
|
+
return (
|
|
281
|
+
f"MasterPatternSimulator({self.crystal!r}, {self.microscope!r}, "
|
|
282
|
+
f"npx={self.npx}, dmin={self.dmin} nm, backend={backend!r})"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _unit(k) -> np.ndarray:
|
|
287
|
+
v = np.asarray(k, dtype=np.float64).reshape(3)
|
|
288
|
+
norm = np.linalg.norm(v)
|
|
289
|
+
if norm == 0:
|
|
290
|
+
raise ValueError("beam direction must be nonzero")
|
|
291
|
+
return v / norm
|
de_ebsd/viz/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The anyplotlib display layer: every ``.show()`` in de_ebsd renders through here.
|
|
2
|
+
|
|
3
|
+
anyplotlib and the widget stack are imported lazily, so headless compute jobs
|
|
4
|
+
never pay for (or require) them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CrystalExplorer",
|
|
9
|
+
"DepthWidget",
|
|
10
|
+
"MicroscopeWidget",
|
|
11
|
+
"PhaseOverview",
|
|
12
|
+
"bar_figure",
|
|
13
|
+
"crystal_structure_figure",
|
|
14
|
+
"hemispheres_figure",
|
|
15
|
+
"image_figure",
|
|
16
|
+
"phase_overview_figure",
|
|
17
|
+
"sampling_sphere_figure",
|
|
18
|
+
"show_correlation",
|
|
19
|
+
"show_map",
|
|
20
|
+
"show_match",
|
|
21
|
+
"show_refinement",
|
|
22
|
+
"sphere_figure",
|
|
23
|
+
"to_uint8_rgb",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_MODULES = {
|
|
27
|
+
"explorer": ("CrystalExplorer",),
|
|
28
|
+
"figures": ("bar_figure", "image_figure", "to_uint8_rgb"),
|
|
29
|
+
"patterns": (
|
|
30
|
+
"crystal_structure_figure",
|
|
31
|
+
"hemispheres_figure",
|
|
32
|
+
"phase_overview_figure",
|
|
33
|
+
"sampling_sphere_figure",
|
|
34
|
+
"sphere_figure",
|
|
35
|
+
),
|
|
36
|
+
"maps": ("show_correlation", "show_map", "show_match", "show_refinement"),
|
|
37
|
+
"views": ("PhaseOverview",),
|
|
38
|
+
"widgets": ("DepthWidget", "MicroscopeWidget"),
|
|
39
|
+
}
|
|
40
|
+
_ORIGIN = {name: module for module, names in _MODULES.items() for name in names}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def __getattr__(name: str):
|
|
44
|
+
module = _ORIGIN.get(name)
|
|
45
|
+
if module is None:
|
|
46
|
+
raise AttributeError(f"module 'de_ebsd.viz' has no attribute {name!r}")
|
|
47
|
+
import importlib
|
|
48
|
+
|
|
49
|
+
return getattr(importlib.import_module(f"de_ebsd.viz.{module}"), name)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __dir__() -> list[str]:
|
|
53
|
+
return sorted(__all__)
|
de_ebsd/viz/camera.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Camera and great-circle geometry for the orientation explorer's 3-D sphere."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
__all__ = ["band_path", "camera_angles", "camera_direction", "camera_frame"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def camera_direction(azimuth: float, elevation: float) -> np.ndarray:
|
|
13
|
+
"""Near-facing sphere direction for anyplotlib's Plot3D camera convention."""
|
|
14
|
+
a, e = np.deg2rad([float(azimuth), float(elevation)])
|
|
15
|
+
return np.array([math.cos(e) * math.sin(a), -math.cos(e) * math.cos(a), math.sin(e)])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def camera_angles(direction: np.ndarray) -> tuple[float, float]:
|
|
19
|
+
n = np.asarray(direction, dtype=float) / np.linalg.norm(direction)
|
|
20
|
+
return float(np.rad2deg(np.arctan2(n[0], -n[1]))), float(
|
|
21
|
+
np.rad2deg(np.arcsin(np.clip(n[2], -1, 1)))
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def camera_frame(azimuth: float, elevation: float) -> np.ndarray:
|
|
26
|
+
"""The exact right/up/facing basis used by anyplotlib's Plot3D renderer."""
|
|
27
|
+
a, e = np.deg2rad([float(azimuth), float(elevation)])
|
|
28
|
+
ca, sa, ce, se = np.cos(a), np.sin(a), np.cos(e), np.sin(e)
|
|
29
|
+
facing = np.array([ce * sa, -ce * ca, se])
|
|
30
|
+
right = np.array([ca, sa, 0.0])
|
|
31
|
+
up = np.array([-se * sa, se * ca, ce])
|
|
32
|
+
return np.column_stack([right, up, facing])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def band_path(normals: np.ndarray) -> np.ndarray:
|
|
36
|
+
"""Pack reciprocal-plane great circles into a dense native point path.
|
|
37
|
+
|
|
38
|
+
Plot3D has one connected line primitive and no pen-lift/NaN contract. A
|
|
39
|
+
fine point cloud preserves each great circle without drawing false chords
|
|
40
|
+
between unrelated planes.
|
|
41
|
+
"""
|
|
42
|
+
normals = np.asarray(normals, dtype=float).reshape(-1, 3)
|
|
43
|
+
rings = []
|
|
44
|
+
t = np.linspace(0.0, 2.0 * np.pi, 256)
|
|
45
|
+
for normal in normals:
|
|
46
|
+
n = normal / max(np.linalg.norm(normal), 1e-15)
|
|
47
|
+
if not np.isfinite(n).all():
|
|
48
|
+
continue
|
|
49
|
+
ref = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
|
50
|
+
u = ref - np.dot(ref, n) * n
|
|
51
|
+
u /= max(np.linalg.norm(u), 1e-15)
|
|
52
|
+
v = np.cross(n, u)
|
|
53
|
+
rings.append(np.cos(t)[:, None] * u + np.sin(t)[:, None] * v)
|
|
54
|
+
if not rings:
|
|
55
|
+
return np.zeros((2, 3), dtype=float)
|
|
56
|
+
return np.concatenate(rings, axis=0)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
function render({model,el}) {
|
|
2
|
+
const host=el.shadowRoot||el.attachShadow({mode:'open'});
|
|
3
|
+
host.innerHTML=`<style>
|
|
4
|
+
:host {display:block;max-width:580px;color-scheme:light dark} *{box-sizing:border-box}
|
|
5
|
+
section {--fg:var(--spyde-fg,var(--jp-ui-font-color1,light-dark(#242a33,#d8dee9)));
|
|
6
|
+
--bg:var(--spyde-panel-bg,var(--jp-layout-color1,light-dark(#f6f7f9,#20242d)));
|
|
7
|
+
--line:var(--spyde-border,var(--jp-border-color2,light-dark(#ccd0d7,#4c5565)));
|
|
8
|
+
--accent:var(--spyde-accent,light-dark(#1672b8,#5ba7f7));--cut:light-dark(#a45c17,#e5a05c);
|
|
9
|
+
border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg);
|
|
10
|
+
font:12px var(--jp-ui-font-family,system-ui,sans-serif);overflow:hidden}
|
|
11
|
+
header,.controls,.detail,.note {padding:9px 12px;line-height:1.5}
|
|
12
|
+
header {border-bottom:1px solid var(--line)} .controls {display:flex;flex-wrap:wrap;gap:12px}
|
|
13
|
+
label {display:flex;gap:8px;align-items:center} input {max-width:180px} select {font:inherit;color:inherit;background:var(--bg)}
|
|
14
|
+
svg {display:block;width:100%;font:12px system-ui} svg text {fill:var(--fg)}
|
|
15
|
+
.detail {border-top:1px solid var(--line);font-variant-numeric:tabular-nums} .note {font-size:11px}
|
|
16
|
+
</style><section><header><strong>Depth model · exit statistics and propagation slices</strong></header>
|
|
17
|
+
<div class="controls"><label>Energy <select aria-label="Depth energy"></select></label>
|
|
18
|
+
<label>Slice <input aria-label="Depth slice" type="range" min="0" step="1"><output></output></label></div>
|
|
19
|
+
<svg height="280" role="img" aria-label="Depth slices, exit yield and integration cutoff"></svg>
|
|
20
|
+
<div class="detail" aria-live="polite"></div><div class="note">Bars show lateral-summed exit yield per incident electron, before absorption correction.
|
|
21
|
+
Colored slices are retained by the backend's 99% rule (maximum over lateral pixels and lower energies).
|
|
22
|
+
Depth is normal to the surface, not a ray path length. Selecting a slice only inspects the model.</div></section>`;
|
|
23
|
+
const svg=host.querySelector('svg'), select=host.querySelector('select'), slider=host.querySelector('input');
|
|
24
|
+
const energies=model.get('energies_kev');
|
|
25
|
+
energies.forEach((value,index)=>{const o=document.createElement('option');o.value=index;o.textContent=`${value} keV`;select.append(o);});
|
|
26
|
+
select.onchange=()=>{model.set('energy_index',Number(select.value));model.save_changes();};
|
|
27
|
+
slider.oninput=()=>{model.set('slice_index',Number(slider.value));model.save_changes();};
|
|
28
|
+
const node=(tag,attrs={},value='')=>{
|
|
29
|
+
const n=document.createElementNS('http://www.w3.org/2000/svg',tag);
|
|
30
|
+
for(const [key,v] of Object.entries(attrs))n.setAttribute(key,v);n.textContent=value;svg.append(n);return n;
|
|
31
|
+
};
|
|
32
|
+
const text=(x,y,value,anchor='start')=>node('text',{x,y,'text-anchor':anchor},value);
|
|
33
|
+
function draw() {
|
|
34
|
+
const ei=Math.min(energies.length-1,model.get('energy_index')), p=model.get('profiles')[ei], n=p.length,
|
|
35
|
+
selected=Math.min(n-1,model.get('slice_index')), count=model.get('integration_bins')[ei], dz=model.get('depth_step');
|
|
36
|
+
const W=svg.clientWidth||560, top=42, bottom=236, h=bottom-top;
|
|
37
|
+
svg.setAttribute('viewBox',`0 0 ${W} 280`);svg.replaceChildren();
|
|
38
|
+
select.value=ei;slider.max=n-1;slider.value=selected;host.querySelector('output').textContent=`${selected+1} / ${n}`;
|
|
39
|
+
const left=62, slabW=55, barX=150, barW=Math.max(40,W-barX-45), max=Math.max(...p,1e-30),total=p.reduce((a,b)=>a+b,0);
|
|
40
|
+
text(12,19,`${n} bins × ${dz} nm · modeled window ${n*dz} nm`);
|
|
41
|
+
text(left,34,'Surface');text(barX,34,'Exit yield / bin');
|
|
42
|
+
text(W-20,19,`max ${max.toPrecision(3)}`,'end');
|
|
43
|
+
node('line',{x1:left-8,y1:top,x2:W-20,y2:top,stroke:'var(--fg)'});
|
|
44
|
+
// Aggregate dense profiles only for painting; selected-bin values stay exact.
|
|
45
|
+
const stride=Math.max(1,Math.ceil(n/180));
|
|
46
|
+
for(let start=0;start<n;start+=stride) {
|
|
47
|
+
const stop=Math.min(start+stride,n),y=top+h*start/n,hh=h*(stop-start)/n;
|
|
48
|
+
const value=p.slice(start,stop).reduce((a,b)=>a+b,0)/(stop-start);
|
|
49
|
+
const active=start<count;
|
|
50
|
+
node('rect',{x:left,y,width:slabW,height:hh,fill:active?'var(--accent)':'var(--line)',
|
|
51
|
+
'fill-opacity':.15+.55*value/max,stroke:'var(--bg)','stroke-width':.5});
|
|
52
|
+
const bar=node('rect',{x:barX,y,width:barW*value/max,height:Math.max(.3,hh-.5),
|
|
53
|
+
fill:active?'var(--accent)':'var(--line)'});
|
|
54
|
+
const tip=document.createElementNS('http://www.w3.org/2000/svg','title');
|
|
55
|
+
tip.textContent=`${start*dz}–${stop*dz} nm; mean yield/bin ${value.toPrecision(4)}`;bar.append(tip);
|
|
56
|
+
}
|
|
57
|
+
for(const fraction of [0,.5,1]) {
|
|
58
|
+
const y=top+h*fraction;text(left-9,y+4,`${(n*dz*fraction).toFixed(0)}`,'end');
|
|
59
|
+
}
|
|
60
|
+
text(12,260,'z (nm)');
|
|
61
|
+
const cy=top+h*count/n;
|
|
62
|
+
node('line',{x1:left-5,y1:cy,x2:W-20,y2:cy,stroke:'var(--cut)','stroke-width':2,'stroke-dasharray':'5 3'});
|
|
63
|
+
text(W-20,263,`99% window: ${count*dz} nm (${count} slices)`,'end');
|
|
64
|
+
const sy=top+h*selected/n;
|
|
65
|
+
node('rect',{x:left-3,y:sy,width:slabW+6,height:Math.max(2,h/n),fill:'none',stroke:'var(--fg)','stroke-width':2});
|
|
66
|
+
node('line',{x1:barX,y1:sy+h/(2*n),x2:barX+barW,y2:sy+h/(2*n),stroke:'var(--fg)','stroke-width':1});
|
|
67
|
+
const cum=p.slice(0,selected+1).reduce((a,b)=>a+b,0);
|
|
68
|
+
host.querySelector('.detail').textContent=`Slice ${selected+1}: ${selected*dz}–${(selected+1)*dz} nm · `+
|
|
69
|
+
`yield ${p[selected].toPrecision(4)} · cumulative ${total?(100*cum/total).toFixed(2):'0.00'}% of this energy's yield. `+
|
|
70
|
+
`${selected<count?'Used':'Outside integration window'}. PRISM propagation point: ${(selected+1)*dz} nm.`;
|
|
71
|
+
}
|
|
72
|
+
const names=['energy_index','slice_index'];for(const name of names)model.on(`change:${name}`,draw);
|
|
73
|
+
const observer=new ResizeObserver(draw);observer.observe(host.querySelector('section'));draw();
|
|
74
|
+
return ()=>{observer.disconnect();for(const name of names)model.off(`change:${name}`,draw);};
|
|
75
|
+
}
|
|
76
|
+
export default {render};
|