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,532 @@
1
+ """Monte Carlo electron transport in a bulk sample.
2
+
3
+ Screened Rutherford elastic scattering with Joy-Luo continuous slowing down,
4
+ batched on the GPU, producing the backscattered exit energy/depth statistics the
5
+ master pattern integrates over. The scattering model is that of EMsoft's
6
+ ``EMMC.cl`` kernel.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ import numpy as np
15
+ import torch
16
+
17
+ from de_ebsd.core.constants import C_AVOGADRO
18
+ from de_ebsd.core.device import resolve_device
19
+ from de_ebsd.crystal.lattice import Crystal
20
+
21
+ if TYPE_CHECKING:
22
+ from de_ebsd.sim.montecarlo.depth import DepthDistribution
23
+
24
+ __all__ = [
25
+ "mean_ionization_potential_kev",
26
+ "MaterialProperties",
27
+ "screening_parameter",
28
+ "elastic_cross_section_na",
29
+ "mean_free_path_nm",
30
+ "stopping_power",
31
+ "sample_scattering_cos",
32
+ "rotate_direction",
33
+ "TrajectoryResult",
34
+ "simulate_trajectory",
35
+ "simulate_depth_distribution",
36
+ "soft_depth_histogram",
37
+ ]
38
+
39
+ # EMsoft Monte Carlo model constants (opencl/EMMC.cl, screened Rutherford +
40
+ # Joy-Luo CSDA). All energies keV, density g/cm^3, atomic weight g/mol, lengths
41
+ # nm. Numeric literals are EMsoft's, kept verbatim for parity.
42
+ _SCREEN_C = 3.4e-3 # alpha = _SCREEN_C * Z^(2/3) / E
43
+ _SIGMA_C = 5.21 * 602.2 # N_A * 5.21e-21 cm^2 relativistic Rutherford prefactor
44
+ _REST_KEV = 511.0 # relativistic factor ((E+511)/(E+1024))^2
45
+ _REST2_KEV = 1024.0
46
+ _STOP_C = 0.00785 # Joy-Luo mass stopping-power prefactor
47
+ _STOP_A = 1.166 # stopping-power log scale: ln(1.166 E/J + 0.9911)
48
+ _STOP_B = 0.9911 # stopping-power log offset
49
+ _CM_TO_NM = 1.0e7
50
+
51
+
52
+ def mean_ionization_potential_kev(z: float) -> float:
53
+ """Berger-Seltzer mean ionization potential J in keV (Joy-Luo form).
54
+
55
+ ``J[eV] = 9.76 Z + 58.5 Z^(-0.19)`` (Joy & Luo, 1989) -- the standard mean
56
+ ionization energy used by the modified-Bethe CSDA stopping power. ``z`` may
57
+ be a fractional composition-mean atomic number.
58
+ """
59
+ if z <= 0:
60
+ raise ValueError("atomic number must be positive")
61
+ return (9.76 * z + 58.5 * z**-0.19) * 1e-3
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class MaterialProperties:
66
+ """Bulk-target properties the Monte Carlo scatters in (a mean material).
67
+
68
+ Attributes
69
+ ----------
70
+ mean_z, mean_a
71
+ Composition- (number-) averaged atomic number and weight (g/mol).
72
+ density
73
+ Mass density in g/cm^3.
74
+ number_density
75
+ Atomic number density in atoms/nm^3.
76
+ mean_ionization_kev
77
+ Berger-Seltzer mean ionization potential J in keV.
78
+ """
79
+
80
+ mean_z: float
81
+ mean_a: float
82
+ density: float
83
+ number_density: float
84
+ mean_ionization_kev: float
85
+
86
+ @classmethod
87
+ def from_crystal(cls, crystal: Crystal) -> MaterialProperties:
88
+ """Reduce a crystal to the mean homogeneous material the MC uses."""
89
+ mean_z = crystal.mean_atomic_number
90
+ mean_a = crystal.mean_atomic_weight
91
+ density = crystal.density
92
+ # atoms/nm^3 = (g/cm^3)(atoms/mol)/(g/mol) [=atoms/cm^3] * 1e-21 cm^3/nm^3
93
+ number_density = density * C_AVOGADRO / mean_a * 1e-21
94
+ return cls(
95
+ mean_z=mean_z,
96
+ mean_a=mean_a,
97
+ density=density,
98
+ number_density=number_density,
99
+ mean_ionization_kev=mean_ionization_potential_kev(mean_z),
100
+ )
101
+
102
+ def __repr__(self) -> str:
103
+ return (
104
+ f"MaterialProperties(Z={self.mean_z:.3g}, A={self.mean_a:.4g} g/mol, "
105
+ f"rho={self.density:.3g} g/cm^3, n={self.number_density:.4g} /nm^3, "
106
+ f"J={self.mean_ionization_kev * 1e3:.1f} eV)"
107
+ )
108
+
109
+
110
+ def screening_parameter(z, energy_kev):
111
+ """Rutherford screening parameter ``alpha = 3.4e-3 Z^(2/3) / E`` (E in keV)."""
112
+ return _SCREEN_C * np.asarray(z, dtype=np.float64) ** (2.0 / 3.0) / energy_kev
113
+
114
+
115
+ def elastic_cross_section_na(z, energy_kev):
116
+ """``N_A * sigma`` for relativistic screened Rutherford (EMsoft ``sig_eNA``).
117
+
118
+ ``5.21*602.2 (Z^2/E^2) 4pi/(alpha(1+alpha)) ((E+511)/(E+1024))^2`` -- the
119
+ Avogadro-scaled total elastic cross-section that feeds the mean free path.
120
+ """
121
+ z = np.asarray(z, dtype=np.float64)
122
+ alpha = screening_parameter(z, energy_kev)
123
+ rel = ((energy_kev + _REST_KEV) / (energy_kev + _REST2_KEV)) ** 2
124
+ return _SIGMA_C * z * z / energy_kev**2 * 4.0 * np.pi / (alpha * (1.0 + alpha)) * rel
125
+
126
+
127
+ def mean_free_path_nm(z, energy_kev, density, a_weight):
128
+ """Elastic mean free path in nm: ``A * 1e7 / (rho * N_A sigma)`` (EMsoft)."""
129
+ return a_weight * _CM_TO_NM / (density * elastic_cross_section_na(z, energy_kev))
130
+
131
+
132
+ def stopping_power(z, energy_kev, a_weight, j_kev):
133
+ """Joy-Luo modified-Bethe stopping power ``de_ds`` (EMsoft form).
134
+
135
+ ``-0.00785 Z/(A E) ln(1.166 E/J + 0.9911)``. This is EMsoft's mass-scaled
136
+ ``de_ds``; the energy lost over a step is ``step_nm * density * de_ds`` (keV),
137
+ so the value is negative (energy decreases). ``J`` is the mean ionization
138
+ potential in keV (see :func:`mean_ionization_potential_kev`).
139
+ """
140
+ z = np.asarray(z, dtype=np.float64)
141
+ return -_STOP_C * z / (a_weight * energy_kev) * np.log(_STOP_A * energy_kev / j_kev + _STOP_B)
142
+
143
+
144
+ def sample_scattering_cos(alpha, u):
145
+ """Cosine of the polar scattering angle, inverse-CDF of screened Rutherford.
146
+
147
+ ``cos(theta) = 1 - 2 alpha u / (1 + alpha - u)`` for ``u`` uniform on [0, 1)
148
+ (EMsoft). ``u -> 0`` is forward (cos=1); ``u -> 1`` is backscatter (cos=-1).
149
+ """
150
+ return 1.0 - 2.0 * alpha * u / (1.0 + alpha - u)
151
+
152
+
153
+ def rotate_direction(c: np.ndarray, cos_theta: float, psi: float) -> np.ndarray:
154
+ """New unit direction after scattering by polar ``theta`` / azimuth ``psi``.
155
+
156
+ EMsoft's direction-cosine rotation (``EMMC.cl``), with the ``|c_z| ~ 1``
157
+ special case handled separately to avoid the ``1/sin`` singularity.
158
+ """
159
+ ct = cos_theta
160
+ st = np.sqrt(max(0.0, 1.0 - ct * ct))
161
+ cp, sp = np.cos(psi), np.sin(psi)
162
+ cx, cy, cz = c
163
+ if abs(cz) >= 0.99999:
164
+ return np.array([st * cp, st * sp, (1.0 if cz >= 0 else -1.0) * ct])
165
+ dsqi = 1.0 / np.sqrt(1.0 - cz * cz)
166
+ return np.array(
167
+ [
168
+ st * (cx * cz * cp - cy * sp) * dsqi + cx * ct,
169
+ st * (cy * cz * cp + cx * sp) * dsqi + cy * ct,
170
+ -st * cp / dsqi + cz * ct,
171
+ ]
172
+ )
173
+
174
+
175
+ @dataclass
176
+ class TrajectoryResult:
177
+ """Outcome of one electron trajectory.
178
+
179
+ Attributes
180
+ ----------
181
+ backscattered
182
+ True if the electron re-crossed the entry surface (z <= 0).
183
+ exit_energy_kev
184
+ Kinetic energy when the trajectory ended.
185
+ escape_depth_nm
186
+ EMsoft ``depth`` value at exit: the pre-step depth over the exit
187
+ direction's z-cosine (a slant path to the surface). Signed like EMsoft's
188
+ ``depth[]``; ``accum_z`` bins its magnitude.
189
+ max_depth_nm
190
+ Deepest z reached along the trajectory.
191
+ n_steps
192
+ Number of scattering steps taken.
193
+ """
194
+
195
+ backscattered: bool
196
+ exit_energy_kev: float
197
+ escape_depth_nm: float
198
+ max_depth_nm: float
199
+ n_steps: int
200
+
201
+
202
+ def simulate_trajectory(
203
+ material: MaterialProperties,
204
+ energy_kev: float,
205
+ tilt_deg: float = 70.0,
206
+ azimuth_deg: float = 0.0,
207
+ e_min_kev: float = 0.05,
208
+ max_steps: int = 1000,
209
+ rng: np.random.Generator | None = None,
210
+ ) -> TrajectoryResult:
211
+ """Track one electron through the bulk (EMsoft single-scattering model).
212
+
213
+ A faithful scalar port of ``EMMC.cl``: enter a ``tilt_deg`` sample at the
214
+ origin, and per step draw an exponential free path, lose energy by the
215
+ Joy-Luo CSDA, and scatter by screened Rutherford until the electron exits the
216
+ surface (backscattered), drops below ``e_min_kev`` (absorbed), or reaches
217
+ ``max_steps``. The vectorized torch port (M-MC.3) mirrors this arithmetic.
218
+ """
219
+ if rng is None:
220
+ rng = np.random.default_rng()
221
+ z, a_weight = material.mean_z, material.mean_a
222
+ rho, j_kev = material.density, material.mean_ionization_kev
223
+
224
+ sig, omega = np.deg2rad(tilt_deg), np.deg2rad(azimuth_deg)
225
+ r = np.zeros(3)
226
+ c = np.array([np.cos(omega) * np.sin(sig), np.sin(omega) * np.sin(sig), np.cos(sig)])
227
+ energy = float(energy_kev)
228
+
229
+ escape_depth = 0.0
230
+ max_depth = 0.0
231
+ backscattered = False
232
+ penetrated = False
233
+ n_steps = 0
234
+ for _ in range(max_steps):
235
+ n_steps += 1
236
+ alpha = _SCREEN_C * z ** (2.0 / 3.0) / energy
237
+ mfp = mean_free_path_nm(z, energy, rho, a_weight)
238
+ step = -mfp * np.log(1.0 - rng.random()) # exponential free path (nm)
239
+
240
+ energy = energy + step * rho * stopping_power(z, energy, a_weight, j_kev)
241
+
242
+ cos_theta = sample_scattering_cos(alpha, rng.random())
243
+ psi = 2.0 * np.pi * rng.random()
244
+ c_new = rotate_direction(c, cos_theta, psi)
245
+
246
+ # EMsoft escape_depth: pre-move depth over the new direction's z-cosine
247
+ # (a slant path to the surface); binning takes its magnitude.
248
+ if abs(c_new[2]) > 1e-5:
249
+ escape_depth = r[2] / c_new[2]
250
+ r = r + step * c_new
251
+ c = c_new
252
+
253
+ max_depth = max(max_depth, r[2])
254
+ if r[2] <= 0.0:
255
+ # a surface reflection that never entered the bulk is not a
256
+ # backscatter event (it sampled no depth); EMsoft drops it too
257
+ backscattered = penetrated
258
+ break
259
+ penetrated = True
260
+ if energy <= e_min_kev:
261
+ break
262
+
263
+ return TrajectoryResult(backscattered, energy, escape_depth, max_depth, n_steps)
264
+
265
+
266
+ def _rotate_batch(c: torch.Tensor, cos_theta: torch.Tensor, psi: torch.Tensor) -> torch.Tensor:
267
+ ct = cos_theta
268
+ st = torch.sqrt(torch.clamp(1.0 - ct * ct, min=0.0))
269
+ cp, sp = torch.cos(psi), torch.sin(psi)
270
+ cx, cy, cz = c[:, 0], c[:, 1], c[:, 2]
271
+ dsqi = 1.0 / torch.sqrt(torch.clamp(1.0 - cz * cz, min=1e-30))
272
+ gx = st * (cx * cz * cp - cy * sp) * dsqi + cx * ct
273
+ gy = st * (cy * cz * cp + cx * sp) * dsqi + cy * ct
274
+ gz = -st * cp / dsqi + cz * ct
275
+ pole = cz.abs() >= 0.99999
276
+ nx = torch.where(pole, st * cp, gx)
277
+ ny = torch.where(pole, st * sp, gy)
278
+ nz = torch.where(pole, torch.sign(cz) * ct, gz)
279
+ return torch.stack([nx, ny, nz], dim=-1)
280
+
281
+
282
+ def simulate_depth_distribution(
283
+ material: MaterialProperties | Crystal,
284
+ beam_energy_kev: float,
285
+ *,
286
+ e_min_kev: float | None = None,
287
+ e_bin_kev: float = 1.0,
288
+ depth_step_nm: float = 1.0,
289
+ depth_max_nm: float = 100.0,
290
+ n_electrons: int = 100_000,
291
+ tilt_deg: float = 70.0,
292
+ azimuth_deg: float = 0.0,
293
+ max_steps: int = 1000,
294
+ device: str | torch.device | None = None,
295
+ seed: int | None = None,
296
+ ) -> DepthDistribution:
297
+ """Native (EMsoft-free) exit depth/energy statistics via a batched MC.
298
+
299
+ Vectorizes :func:`simulate_trajectory` over ``n_electrons`` advanced in
300
+ lockstep (an alive mask retires each one as it backscatters or is absorbed),
301
+ then bins the backscattered electrons into EMsoft's ``accum_z`` layout --
302
+ energy ``iE = round((E - Ehistmin)/Ebinsize)``, depth
303
+ ``iz = round(|escape_depth|/depthstep)`` -- and returns a
304
+ :class:`~de_ebsd.sim.montecarlo.depth.DepthDistribution`, a drop-in for
305
+ :meth:`~de_ebsd.sim.montecarlo.depth.DepthDistribution.from_emsoft`.
306
+
307
+ This is the M-MC.3 hard-mode forward. It counts every backscattered electron
308
+ (no lateral exit-direction window yet; EMsoft's central +/- numsx/10 accum_z
309
+ window is the reconciliation item for M-MC.4). ``e_min_kev`` doubles as the
310
+ energy-bin floor (``Ehistmin``) and the absorption cutoff -- an electron that
311
+ slows below it can only lose more energy, so it could never land in a bin.
312
+ """
313
+ from de_ebsd.sim.montecarlo.depth import DepthDistribution
314
+
315
+ if isinstance(material, Crystal):
316
+ material = MaterialProperties.from_crystal(material)
317
+ if e_min_kev is None:
318
+ e_min_kev = beam_energy_kev - 5.0
319
+
320
+ dev = resolve_device(device)
321
+ gen = torch.Generator(device=dev)
322
+ if seed is not None:
323
+ gen.manual_seed(int(seed))
324
+
325
+ z, a_weight = float(material.mean_z), float(material.mean_a)
326
+ rho, j_kev = float(material.density), float(material.mean_ionization_kev)
327
+ z23 = z ** (2.0 / 3.0)
328
+ n = int(n_electrons)
329
+ f64 = torch.float64
330
+
331
+ sig, omega = np.deg2rad(tilt_deg), np.deg2rad(azimuth_deg)
332
+ c0 = torch.tensor(
333
+ [np.cos(omega) * np.sin(sig), np.sin(omega) * np.sin(sig), np.cos(sig)],
334
+ device=dev,
335
+ dtype=f64,
336
+ )
337
+ pos = torch.zeros(n, 3, device=dev, dtype=f64)
338
+ direction = c0.expand(n, 3).clone()
339
+ energy = torch.full((n,), float(beam_energy_kev), device=dev, dtype=f64)
340
+ alive = torch.ones(n, dtype=torch.bool, device=dev)
341
+ penetrated = torch.zeros(n, dtype=torch.bool, device=dev)
342
+ escape_depth = torch.zeros(n, device=dev, dtype=f64)
343
+ exit_energy = torch.zeros(n, device=dev, dtype=f64)
344
+ backscattered = torch.zeros(n, dtype=torch.bool, device=dev)
345
+
346
+ def _rand():
347
+ return torch.rand(n, generator=gen, device=dev, dtype=f64)
348
+
349
+ for _ in range(max_steps):
350
+ if not bool(alive.any()):
351
+ break
352
+ e = energy
353
+ alpha = _SCREEN_C * z23 / e
354
+ rel = ((e + _REST_KEV) / (e + _REST2_KEV)) ** 2
355
+ sig_ena = _SIGMA_C * z * z / (e * e) * 4.0 * np.pi / (alpha * (1.0 + alpha)) * rel
356
+ mfp = a_weight * _CM_TO_NM / (rho * sig_ena)
357
+ step = -mfp * torch.log(1.0 - _rand()) # exponential free path (nm)
358
+ de_ds = -_STOP_C * z / (a_weight * e) * torch.log(_STOP_A * e / j_kev + _STOP_B)
359
+
360
+ cos_theta = 1.0 - 2.0 * alpha * (u := _rand()) / (1.0 + alpha - u)
361
+ psi = 2.0 * np.pi * _rand()
362
+ c_new = _rotate_batch(direction, cos_theta, psi)
363
+
364
+ cz = c_new[:, 2]
365
+ upd = alive & (cz.abs() > 1e-5)
366
+ escape_depth = torch.where(upd, pos[:, 2] / cz, escape_depth)
367
+
368
+ new_pos = pos + step[:, None] * c_new
369
+ new_e = e + step * rho * de_ds
370
+ exited = alive & (new_pos[:, 2] <= 0.0)
371
+ absorbed = alive & ~exited & (new_e <= e_min_kev)
372
+
373
+ # only electrons that entered the bulk count as backscattered -- a step-1
374
+ # surface reflection sampled no depth (escape_depth 0); EMsoft drops it
375
+ valid = exited & penetrated
376
+ backscattered = backscattered | valid
377
+ exit_energy = torch.where(valid, new_e, exit_energy)
378
+ penetrated = penetrated | (alive & (new_pos[:, 2] > 0.0))
379
+ pos = torch.where(alive[:, None], new_pos, pos)
380
+ direction = torch.where(alive[:, None], c_new, direction)
381
+ energy = torch.where(alive, new_e, energy)
382
+ alive = alive & ~exited & ~absorbed
383
+
384
+ # bin backscattered electrons into EMsoft's accum_z (E, z) layout
385
+ num_e_bins = int(round((beam_energy_kev - e_min_kev) / e_bin_kev)) + 1
386
+ num_z_bins = int(depth_max_nm / depth_step_nm) + 1
387
+ edis = escape_depth.abs()
388
+ i_e = torch.round((exit_energy - e_min_kev) / e_bin_kev).long()
389
+ i_z = torch.round(edis / depth_step_nm).long()
390
+ valid = (
391
+ backscattered
392
+ & (exit_energy > e_min_kev)
393
+ & (exit_energy <= beam_energy_kev)
394
+ & (i_e >= 0)
395
+ & (i_e < num_e_bins)
396
+ & (i_z >= 0)
397
+ & (i_z < num_z_bins)
398
+ )
399
+ accum = torch.zeros(num_e_bins * num_z_bins, device=dev, dtype=f64)
400
+ flat = i_e[valid] * num_z_bins + i_z[valid]
401
+ accum.index_add_(0, flat, torch.ones(int(valid.sum()), device=dev, dtype=f64))
402
+ accum_z = accum.reshape(num_e_bins, num_z_bins).cpu().numpy()[:, :, None, None]
403
+ energies_kev = e_min_kev + e_bin_kev * np.arange(num_e_bins)
404
+
405
+ return DepthDistribution(
406
+ energies_kev=energies_kev,
407
+ depth_step=float(depth_step_nm),
408
+ accum_z=accum_z,
409
+ n_total=float(n),
410
+ )
411
+
412
+
413
+ def _bilinear_accumulate(accum, contrib, e_val, z_val, e_min, e_bin, dz, num_e, num_z):
414
+ ef = (e_val - e_min) / e_bin
415
+ zf = z_val / dz
416
+ e0 = torch.floor(ef)
417
+ z0 = torch.floor(zf)
418
+ e_frac, z_frac = ef - e0, zf - z0
419
+ e0, z0 = e0.long(), z0.long()
420
+ for de, we in ((0, 1.0 - e_frac), (1, e_frac)):
421
+ ie = e0 + de
422
+ for dzi, wz in ((0, 1.0 - z_frac), (1, z_frac)):
423
+ iz = z0 + dzi
424
+ ok = (ie >= 0) & (ie < num_e) & (iz >= 0) & (iz < num_z)
425
+ wgt = contrib * we * wz * ok.to(contrib.dtype)
426
+ flat = ie.clamp(0, num_e - 1) * num_z + iz.clamp(0, num_z - 1)
427
+ accum = torch.index_add(accum, 0, flat, wgt)
428
+ return accum
429
+
430
+
431
+ def soft_depth_histogram(
432
+ mean_z: torch.Tensor,
433
+ mean_a: torch.Tensor,
434
+ density: torch.Tensor,
435
+ mean_ionization_kev: torch.Tensor,
436
+ beam_energy_kev: float,
437
+ *,
438
+ tilt_deg: float = 70.0,
439
+ azimuth_deg: float = 0.0,
440
+ e_min_kev: float | None = None,
441
+ e_bin_kev: float = 1.0,
442
+ depth_step_nm: float = 1.0,
443
+ depth_max_nm: float = 100.0,
444
+ n_electrons: int = 40_000,
445
+ max_steps: int = 400,
446
+ tau_depth: float = 1.0,
447
+ tau_energy: float = 0.5,
448
+ seed: int | None = None,
449
+ device: str | torch.device | None = None,
450
+ ) -> torch.Tensor:
451
+ """Differentiable exit depth/energy histogram (soft mode, M-MC.5).
452
+
453
+ The reparameterized, soft counterpart of :func:`simulate_depth_distribution`:
454
+ the random draws are detached noise, so positions/energies (and hence the
455
+ ``accum_z`` weights) are smooth functions of the material parameters
456
+ ``mean_z``, ``mean_a``, ``density``, ``mean_ionization_kev`` (pass them as
457
+ ``requires_grad`` tensors to get gradients). A fixed ``max_steps`` electron
458
+ count with sigmoid survival/exit gates replaces the discrete termination, and
459
+ contributions are binned bilinearly (:func:`_bilinear_accumulate`) instead of
460
+ hard-rounded. The forward histogram approaches the hard-mode counts as
461
+ ``tau_depth``/``tau_energy`` -> 0; finite tau trades a little bias for smooth
462
+ gradients. Returns the ``(num_e_bins, num_z_bins)`` weighted histogram.
463
+
464
+ First-step surface reflections are excluded exactly as in hard mode: the exit
465
+ contribution is only accumulated from the second step on (an electron must
466
+ have entered the bulk).
467
+ """
468
+ dev = resolve_device(device)
469
+ gen = torch.Generator(device=dev)
470
+ if seed is not None:
471
+ gen.manual_seed(int(seed))
472
+ f64 = torch.float64
473
+ z = mean_z.to(dev, f64)
474
+ a_weight = mean_a.to(dev, f64)
475
+ rho = density.to(dev, f64)
476
+ j_kev = mean_ionization_kev.to(dev, f64)
477
+ if e_min_kev is None:
478
+ e_min_kev = beam_energy_kev - 5.0
479
+
480
+ num_e = int(round((beam_energy_kev - e_min_kev) / e_bin_kev)) + 1
481
+ num_z = int(depth_max_nm / depth_step_nm) + 1
482
+ n = int(n_electrons)
483
+
484
+ sig, omega = np.deg2rad(tilt_deg), np.deg2rad(azimuth_deg)
485
+ c0 = torch.tensor(
486
+ [np.cos(omega) * np.sin(sig), np.sin(omega) * np.sin(sig), np.cos(sig)],
487
+ device=dev,
488
+ dtype=f64,
489
+ )
490
+ pos = torch.zeros(n, 3, device=dev, dtype=f64)
491
+ direction = c0.expand(n, 3).clone()
492
+ energy = torch.full((n,), float(beam_energy_kev), device=dev, dtype=f64)
493
+ survive = torch.ones(n, device=dev, dtype=f64) # continuous "still in flight"
494
+ accum = torch.zeros(num_e * num_z, device=dev, dtype=f64)
495
+
496
+ def _rand():
497
+ return torch.rand(n, generator=gen, device=dev, dtype=f64)
498
+
499
+ for step_idx in range(max_steps):
500
+ e = energy
501
+ alpha = _SCREEN_C * z ** (2.0 / 3.0) / e
502
+ rel = ((e + _REST_KEV) / (e + _REST2_KEV)) ** 2
503
+ sig_ena = _SIGMA_C * z * z / (e * e) * 4.0 * np.pi / (alpha * (1.0 + alpha)) * rel
504
+ mfp = a_weight * _CM_TO_NM / (rho * sig_ena)
505
+ step = -mfp * torch.log(1.0 - _rand())
506
+ de_ds = -_STOP_C * z / (a_weight * e) * torch.log(_STOP_A * e / j_kev + _STOP_B)
507
+ cos_theta = 1.0 - 2.0 * alpha * (u := _rand()) / (1.0 + alpha - u)
508
+ psi = 2.0 * np.pi * _rand()
509
+ c_new = _rotate_batch(direction, cos_theta, psi)
510
+
511
+ cz = c_new[:, 2]
512
+ cz_safe = torch.where(cz.abs() > 1e-5, cz, torch.full_like(cz, 1e-5))
513
+ escape = (pos[:, 2] / cz_safe).abs()
514
+ new_pos = pos + step[:, None] * c_new
515
+ new_e = e + step * rho * de_ds
516
+
517
+ # soft gates: exited (crossed z<=0), absorbed (dropped below e_min)
518
+ exit_gate = torch.sigmoid(-new_pos[:, 2] / tau_depth)
519
+ absorb_gate = torch.sigmoid((e_min_kev - new_e) / tau_energy)
520
+ if step_idx >= 1: # first-step surface reflections do not count
521
+ contrib = survive * exit_gate * (1.0 - absorb_gate)
522
+ accum = _bilinear_accumulate(
523
+ accum, contrib, new_e, escape, e_min_kev, e_bin_kev, depth_step_nm, num_e, num_z
524
+ )
525
+ leave = exit_gate + absorb_gate - exit_gate * absorb_gate # soft OR
526
+ survive = survive * (1.0 - leave)
527
+
528
+ pos, direction, energy = new_pos, c_new, new_e
529
+ if float(survive.sum()) < 1e-6:
530
+ break
531
+
532
+ return accum.reshape(num_e, num_z)