de-ebsd 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. de_ebsd/__init__.py +76 -0
  2. de_ebsd/core/__init__.py +23 -0
  3. de_ebsd/core/constants.py +81 -0
  4. de_ebsd/core/device.py +12 -0
  5. de_ebsd/core/geometry.py +67 -0
  6. de_ebsd/core/lambert.py +148 -0
  7. de_ebsd/core/rotation.py +139 -0
  8. de_ebsd/crystal/__init__.py +22 -0
  9. de_ebsd/crystal/data/__init__.py +0 -0
  10. de_ebsd/crystal/data/element_properties.py +29 -0
  11. de_ebsd/crystal/data/scattering_tables.py +414 -0
  12. de_ebsd/crystal/lattice.py +517 -0
  13. de_ebsd/crystal/scattering.py +339 -0
  14. de_ebsd/crystal/scattering_torch.py +233 -0
  15. de_ebsd/crystal/structure_factors.py +291 -0
  16. de_ebsd/diffraction/__init__.py +45 -0
  17. de_ebsd/diffraction/backends.py +318 -0
  18. de_ebsd/diffraction/bethe.py +28 -0
  19. de_ebsd/diffraction/kinematic.py +389 -0
  20. de_ebsd/diffraction/kvectors.py +120 -0
  21. de_ebsd/diffraction/prism.py +204 -0
  22. de_ebsd/diffraction/reflections.py +234 -0
  23. de_ebsd/index/__init__.py +61 -0
  24. de_ebsd/index/_kernels.py +222 -0
  25. de_ebsd/index/backproject.py +171 -0
  26. de_ebsd/index/crosscorr.py +654 -0
  27. de_ebsd/index/dictionary.py +362 -0
  28. de_ebsd/index/io.py +324 -0
  29. de_ebsd/index/live/__init__.py +5 -0
  30. de_ebsd/index/live/background.py +103 -0
  31. de_ebsd/index/live/calibration.py +70 -0
  32. de_ebsd/index/live/common.py +44 -0
  33. de_ebsd/index/live/deformation.py +289 -0
  34. de_ebsd/index/live/indexer.py +386 -0
  35. de_ebsd/index/preprocess.py +195 -0
  36. de_ebsd/index/refine/__init__.py +7 -0
  37. de_ebsd/index/refine/highres.py +479 -0
  38. de_ebsd/index/refine/levenberg.py +33 -0
  39. de_ebsd/index/refine/orientation.py +367 -0
  40. de_ebsd/index/refine/pattern_center.py +356 -0
  41. de_ebsd/index/spherical/__init__.py +41 -0
  42. de_ebsd/index/spherical/correlation.py +106 -0
  43. de_ebsd/index/spherical/harmonics.py +311 -0
  44. de_ebsd/index/spherical/indexer.py +345 -0
  45. de_ebsd/index/spherical/wigner.py +190 -0
  46. de_ebsd/sim/__init__.py +20 -0
  47. de_ebsd/sim/detector.py +145 -0
  48. de_ebsd/sim/masterpattern.py +231 -0
  49. de_ebsd/sim/microscope.py +93 -0
  50. de_ebsd/sim/montecarlo/__init__.py +19 -0
  51. de_ebsd/sim/montecarlo/depth.py +167 -0
  52. de_ebsd/sim/montecarlo/transport.py +532 -0
  53. de_ebsd/sim/simulator.py +291 -0
  54. de_ebsd/viz/__init__.py +53 -0
  55. de_ebsd/viz/camera.py +56 -0
  56. de_ebsd/viz/depth_widget.js +76 -0
  57. de_ebsd/viz/explorer.py +647 -0
  58. de_ebsd/viz/figures.py +67 -0
  59. de_ebsd/viz/maps.py +277 -0
  60. de_ebsd/viz/patterns.py +164 -0
  61. de_ebsd/viz/setup_widget.js +171 -0
  62. de_ebsd/viz/views.py +125 -0
  63. de_ebsd/viz/widgets.py +191 -0
  64. de_ebsd-0.2.0.dist-info/METADATA +251 -0
  65. de_ebsd-0.2.0.dist-info/RECORD +69 -0
  66. de_ebsd-0.2.0.dist-info/WHEEL +5 -0
  67. de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
  68. de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
  69. de_ebsd-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
@@ -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};