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
|
@@ -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)
|