lindhard/electron/transport.rs
1//! Event-by-event electron transport in a layered stack, on precomputed
2//! cross-section tables, with secondary electrons and surface barriers.
3//!
4//! This is the low-energy electron Monte Carlo loop of Kieft and Bosch,
5//! J. Phys. D: Appl. Phys. 41, 215310 (2008), doi:10.1088/0022-3727/41/21/215310
6//! (the scheme on which Nebula is built): an electron at `(r, direction, E)`
7//! in a layer flies a free path drawn from the total inverse mean free path
8//! (the sum of the elastic and inelastic [`CrossSectionTable`] rates of the
9//! layer at `E`), then picks the elastic or the inelastic channel with
10//! probability proportional to the two rates, samples the polar angle `θ` or
11//! the energy loss `W` from that channel's inverse CDF, and updates its state.
12//! An electron ends when `E` falls below the stopping threshold or it leaves
13//! the target. The loop itself is written from the paper; the secondary
14//! electron kinematics ([`crate::electron::secondary`]) and the potential step
15//! ([`crate::electron::boundary`]) are ported from Nebula with attribution.
16//!
17//! # Targets other than a stack
18//!
19//! [`Transport<G>`] is generic over [`Geometry`]; [`Transport::new`] and
20//! [`Transport::with_band_structures`] build the layered `Transport<Stack>`,
21//! [`Transport::on_geometry`] and
22//! [`Transport::on_geometry_with_band_structures`] any other geometry (for
23//! example a [`crate::geometry::VoxelGrid`]). Tables, band parameters,
24//! channels and thresholds are indexed by **material**
25//! ([`Geometry::material_index`]), and [`ElectronState::layer`] is the
26//! electron's material index (the layer index, for a stack). The flight uses
27//! [`Geometry::flight`]: a face between two regions of one material is not an
28//! event and the free path is not redrawn there. A primary starts at
29//! [`Geometry::entry_point`]. An electron leaving through a lateral face of a
30//! voxel grid ends with [`Fate::Escaped`]`(`[`Face::Side`]`)`, whatever the
31//! [`EscapeRule`]. The [`Boundary::Interface`] indices are region indices.
32//! The step barrier is applied to the `x` component of the direction, the
33//! face normal of a stack only, so the `on_geometry` constructors accept
34//! [`BoundaryModel::Transparent`] only. For a stack the results are those of
35//! before the generalisation, bit for bit.
36//!
37//! # Model
38//!
39//! - **Free flight.** `s = -ln(1 - u) / (λ_el⁻¹ + λ_inel⁻¹)`, `u` uniform in
40//! `[0, 1)`. Rates are interpolated linearly in energy on each table's own
41//! grid and held constant beyond its ends.
42//! - **Layer boundaries are crossed exactly.** If the drawn path is longer
43//! than the distance to the layer face along the flight direction, the
44//! electron moves to the face (its depth is set to the face value exactly),
45//! meets the face (below) and **redraws** the path with the rates of the
46//! layer it is then in. This is exact because the free path is memoryless.
47//! - **Elastic collision.** `θ` from the table, azimuth uniform on `[0, 2π)`;
48//! the energy is unchanged (no recoil energy).
49//! - **Inelastic collision.** `W` from the table (clamped to `E`); the energy
50//! becomes `E - W`. With [`SecondaryModel::Off`] the flight direction is
51//! unchanged and nothing else happens. With
52//! [`SecondaryModel::KieftBosch`] `W` is further clamped to `E - E_F` (the
53//! primary cannot end below the Fermi level, as in Nebula's loss table),
54//! and the event may liberate a secondary electron (energy `E_F + W - B`,
55//! direction by the Ivanchenko method) and deflect the primary; see
56//! [`crate::electron::secondary`]. The stopping threshold of every layer
57//! must then exceed its Fermi energy, or secondaries would multiply without
58//! end.
59//! - **Insulator channels (opt-in per layer).** A layer given
60//! [`InsulatorChannels`] through [`Transport::with_insulator_channels`]
61//! adds up to three more rates to the total: Fröhlich LO-phonon emission
62//! and absorption, and polaron trapping (models and sources in
63//! [`crate::electron::phonon`]). A phonon event changes the energy by `∓ħω`
64//! and deflects the electron by `θ` drawn from the Fröhlich angular
65//! distribution, azimuth uniform. A trapped electron deposits all of its
66//! remaining energy where it is trapped and its history ends with
67//! [`Fate::PolaronTrapped`]. Layers default to no insulator channel, which
68//! is the right choice for a metal; the per-layer choice is in
69//! [`LayerMetadata`].
70//! - **Inner-shell channels (opt-in per layer).** A layer given
71//! [`ShellChannelTable`]s through [`Transport::with_inner_shells`] adds one
72//! rate per shell `j`, read from that shell's table like the others; the
73//! layer's inelastic table is then the valence channel. The construction
74//! is **channel-first**: the inelastic rate is the sum of the valence and
75//! shell rates, a channel is chosen with probability proportional to its
76//! rate (below), and the loss `ω` is drawn from the chosen channel's own
77//! inverse CDF. (The conditional choice of
78//! [`crate::electron::inelastic::ShellResolvedChannels::sample_channel`]
79//! belongs with a loss drawn from the total DIIMFP and is not used.) A
80//! shell channel is **closed** (rate zero) when the electron cannot pay its
81//! binding energy `B_j`: when `E <= B_j` without a secondary model and
82//! `E - E_F <= B_j` with one. An open channel's `ω` is kept in
83//! `[B_j, E]` (`[B_j, E - E_F]` with a secondary model), so the primary
84//! never ends below the Fermi level and the shell's secondary energy is
85//! never negative. With [`SecondaryModel::Off`] the event only takes `ω`;
86//! with [`SecondaryModel::KieftBosch`] it liberates an electron of energy
87//! `E_F + ω - B_j` and leaves `B_j - E_F` in the solid
88//! ([`crate::electron::secondary`], "Inner-shell events"). Layers default
89//! to no shell channel, which is exactly the valence-only transport.
90//! - **Channel choice.** With the rates in the fixed order elastic,
91//! inelastic (valence), phonon emission, phonon absorption, polaron
92//! trapping, then the inner shells in the order given, the channel is the
93//! first whose cumulative rate exceeds `u · total`; channels with zero
94//! rate are never chosen (a rounding overshoot falls to the last channel
95//! with a positive rate). A layer without shells sums and compares exactly
96//! the five rates of before.
97//! - **Faces.** With [`BoundaryModel::Transparent`] a face changes nothing:
98//! the electron enters the next layer or leaves the target at its energy
99//! and direction. With [`BoundaryModel::StepBarrier`] every face is a step
100//! in the inner potential (vacuum outside the target has `U = 0`), crossed
101//! with the quantum-mechanical transmission probability and refraction of
102//! [`crate::electron::boundary`], or reflected specularly; an electron
103//! below the barrier is always reflected. Energies inside a layer are then
104//! measured from that layer's band bottom and an escaped electron carries
105//! its vacuum energy `E - U`. The back face under [`EscapeRule::FrontOnly`]
106//! absorbs without a barrier.
107//! - **Inverse CDFs between grid energies** are interpolated linearly in
108//! energy between the two bracketing rows, at the same cumulative
109//! probability (a zero-rate bracketing row is ignored).
110//!
111//! # Electrons of one history
112//!
113//! A history starts with its primary. With [`BoundaryModel::StepBarrier`] the
114//! primary is incident from vacuum on the front face, its [`Primary`] energy
115//! is a vacuum energy, and it first meets the front-face step (it may be
116//! reflected there, ending the history as [`Fate::Escaped`] through
117//! [`Face::Front`]). Secondaries are pushed on a last-in, first-out stack and
118//! each is transported in full, like the primary, once the electron before it
119//! has ended; secondaries of secondaries are pushed on the same stack. The
120//! history ends when the stack is empty.
121//!
122//! An electron stops when its energy falls below the **stopping threshold**
123//! of its layer: the cutoff, measured from the band bottom
124//! ([`CutoffReference::BandBottom`]) or from the vacuum level
125//! ([`CutoffReference::VacuumLevel`], threshold `U + cutoff`, Nebula's rule
126//! in `source/drivers/cpu/cpu_driver.inl` at the commit named in
127//! [`crate::electron::boundary`]). A secondary below the threshold of its
128//! layer is not created; its energy is recorded as deposited.
129//!
130//! # Energy reference of the inelastic table
131//!
132//! The transport looks up both tables of a layer at the electron's kinetic
133//! energy `E` as it measures it: from the band bottom of the layer (with the
134//! step barrier, the vacuum energy plus the inner potential `U`). The models
135//! of [`crate::electron::inelastic`] have their own axis instead: `T`, the
136//! energy above the *model's* Fermi energy, with kinematics on
137//! `T' = T + E_F(model)` and losses up to `T` (Shinotsuka et al. 2017, eqs.
138//! (2)-(3), as in [`crate::electron::inelastic::penn`]). The table has to
139//! bridge the two, and it does so when it is built
140//! ([`crate::electron::inelastic::table`], "Energy axis"):
141//!
142//! - **Band-bottom axis** ([`EnergyAxis::BandBottom`], with the model's Fermi
143//! energy set to the band's): the row at `E` is the model's at
144//! `T = E - E_F`, so its kinematics are on `T' = E` and its losses stop at
145//! `E - E_F`. This is the convention of the cstool table compiler which
146//! builds Nebula's tables (rows at the kinetic energy `K`, kinematics on
147//! `K`, losses below `K - F`, the "Fermi correction" of
148//! `compile_full_imfp_icdf` in `cstool/dielectric_function/compile.py`,
149//! Nebula-simulator/cstool commit `0c739eb`), and `F` is what cstool
150//! passes there, [`BandStructure::min_excitation_ev`]: the Fermi energy of
151//! a metal, the conduction-band bottom of an insulator. It is how
152//! `lindhard run` builds the table of every material with a band. The
153//! losses of such a table are below the clamp of
154//! [`SecondaryModel::KieftBosch`] above, which therefore does not act
155//! (test `band_bottom_table_never_reaches_the_fermi_clamp`,
156//! `tests/electron_secondaries.rs`). One exception remains, for either
157//! axis: between the last empty row of a table and its first row with
158//! losses the rate is interpolated from zero and the loss is drawn from
159//! that first row, so it can exceed what an electron below that row may
160//! lose; there the clamps (`W <= E`, and `W <= E - E_F` under Kieft-Bosch)
161//! are still the bound.
162//! - **Model axis** ([`EnergyAxis::ModelFermiLevel`], the builder's default,
163//! and what `lindhard run` builds for a material without a band, with
164//! `[electron.inelastic] fermi_energy_ev`): the row read at `E` is the
165//! model's at `T = E`. With the model's Fermi energy 0 the kinematics are
166//! right (`T' = E`) but the losses reach `E`; in a layer with a band and
167//! the Kieft-Bosch model the clamp takes every loss beyond `E - E_F` to
168//! `E - E_F`, leaving the primary at the Fermi level and giving the
169//! secondary its whole energy. Setting the model's Fermi energy to the
170//! band's on this axis counts `E_F` twice (`T' = E + E_F`). The tests
171//! `inelastic_table_is_read_at_the_band_bottom_energy` and
172//! `losses_beyond_the_fermi_level_are_clamped_to_it` pin how the transport
173//! reads and clamps such a table.
174//!
175//! Inner-shell channel tables
176//! ([`crate::electron::inelastic::table::build_shell_channel_tables`]) are
177//! built on the axis of the options they are given, like the valence table,
178//! and are read the same way, at the band-bottom energy `E`. `lindhard run`
179//! does not build them yet (no input, #156); a library caller picks the axis.
180//! On top of the table, the transport applies the shell's own limits on the
181//! band-bottom axis: the channel is closed unless the largest allowed loss
182//! (`E`, or `E - E_F` under the secondary model) exceeds `B_j`, and `ω` is
183//! clamped into `[B_j`, that loss`]`. Near an edge, a table on the model
184//! axis (losses up to `E` above the model's Fermi energy) has losses above
185//! `E - E_F` that this clamp moves down to `E - E_F`, as for valence losses;
186//! a band-bottom table's losses are already below `E - E_F`, apart from the
187//! interpolation exception above.
188//!
189//! **Measured effect (#173).** Until #241 `lindhard run` built every table
190//! on the model axis with Fermi energy 0. With the single-pole Penn default,
191//! the Al input of `validation/experiments/se_yield.py` (`E_F` = 11.66 eV)
192//! then sent 78 to 82 % of the inelastic events of electrons 5 to 20 eV above
193//! the Fermi level into the clamp (Al 400 eV, 200 histories). A diagnostic
194//! table in the cstool convention (the model's rows at `T = E - E_F`,
195//! relabelled `E`, so on a grid shifted by `E_F`) removed every clamped
196//! event and changed δ by a few per cent, against a factor of about 5 to
197//! explain (1000 histories, seed 1): single pole Al 7.61 to 8.17 and Au 3.23
198//! to 3.37 (800 eV); Mermin Al 1.34 to 1.26 (400 eV). With 200 histories:
199//! single pole Cu 1.67 to 1.80, Mermin Au 2.62 to 2.58 and Cu 2.02 (800 eV).
200//! The gap between the single pole and Mermin was the same in both. The axis
201//! was therefore a real mismatch but not the cause of the δ overestimate of
202//! #173; that is the single-pole model's inelastic mean free path at low
203//! energy ([`crate::electron::inelastic::penn`], "Low energies").
204//!
205//! With the band-bottom table `lindhard run` builds since #241 (on the
206//! input's own grid), the same Al input at 400 eV (200 histories, seed 1, so
207//! about ±0.1 to 0.2 in δ) has no clamped event in any energy bin, and δ is
208//! 6.44 against 6.89 with the table built as before. The commands are in
209//! `lindhard-cli/examples/inelastic_low_energy.rs` (modes `clamp` and
210//! `legacy`).
211//!
212//! [`EnergyAxis::BandBottom`]: crate::electron::inelastic::table::EnergyAxis::BandBottom
213//! [`EnergyAxis::ModelFermiLevel`]: crate::electron::inelastic::table::EnergyAxis::ModelFermiLevel
214//!
215//! # Random draw order
216//!
217//! The order of draws from the per-history stream is part of the contract (the
218//! tests replay it). With the step barrier, the primary first draws one
219//! uniform for its entry. Then, per flight with a nonzero total rate: one
220//! uniform for the path. If a collision happens, one for the channel, then
221//! `θ` and the azimuth (elastic), `W` (inelastic), or `cos θ` and the azimuth
222//! (phonon emission or absorption) or nothing (polaron trapping). Under the
223//! Kieft-Bosch secondary model an inelastic event that liberates an electron
224//! then draws the secondary's azimuth and, with the instantaneous momentum on,
225//! two more uniforms. An inner-shell event draws one uniform for `ω`, then,
226//! under the Kieft-Bosch model, the secondary's azimuth and, with the
227//! instantaneous momentum on, two more uniforms (a shell event always
228//! liberates an electron). With no insulator or inner-shell channel switched
229//! on, the draws are exactly those of a run without them. A path drawn for a flight that ends at a layer face is discarded;
230//! with the step barrier, the face draws one uniform if the electron has the
231//! normal energy to get over (and none if it is surely reflected). A flight
232//! with zero total rate draws nothing. Secondaries continue on the same
233//! stream, in stack order, after the electron before them ends.
234//!
235//! # Reproducibility
236//!
237//! [`Transport::run`] goes through [`crate::rng::run_particles`]: history `i`
238//! uses the stream `(seed, i)`, and chunk tallies are merged in chunk order,
239//! so a tally whose [`ElectronTally::merge`] is order-faithful (a sum, or a
240//! concatenation) is bit-identical at any thread count.
241//!
242//! # Hooks
243//!
244//! The [`ElectronTally`] trait mirrors [`crate::ion::bca::BcaTally`]: every
245//! event hook has a no-op default. Per-electron hooks (`step`, `elastic`,
246//! `inelastic`, `secondary`, `interface`, `barrier`, `reflected`, `stopped`,
247//! `escaped`, `absorbed`, `phonon`, `polaron_trapped`) fire for the primary and
248//! for every secondary;
249//! [`ElectronTally::begin_secondary`] and [`ElectronTally::end_secondary`]
250//! bracket each secondary, and [`ElectronTally::end_history`] comes last, with
251//! the primary's fate. The insulator channels report through
252//! [`ElectronTally::phonon`] and [`ElectronTally::polaron_trapped`]. An
253//! inner-shell event calls [`ElectronTally::inelastic`] like a valence one,
254//! then [`ElectronTally::inner_shell`] with the shell, then (with a
255//! secondary model) [`ElectronTally::secondary`].
256//!
257//! [`ElectronTally::table_lookup`] is a diagnostic hook, also with a no-op
258//! default: it reports, per channel, whether each rate evaluation read the
259//! table inside its energy grid or used the constant continuation beyond an
260//! end ([`GridCoverage`]). It draws nothing and changes nothing, so a tally
261//! that uses it sees the same histories as one that does not.
262//! [`crate::tally::TableCoverageTally`] counts these evaluations. The rates
263//! of inner-shell channel tables are not reported to it.
264
265use serde::Serialize;
266
267use rand_core::Rng;
268
269use crate::electron::boundary::{cross_step, BandStructure, StepOutcome};
270use crate::electron::data::{CrossSectionTable, SamplingAxis, ShellChannelTable};
271use crate::electron::inelastic::InnerShell;
272use crate::electron::phonon::{
273 sample_cos_theta, FrohlichPhonon, InsulatorChannels, PolaronTrapping,
274};
275use crate::electron::secondary::{kieft_bosch, kieft_bosch_shell, SecondaryEvent, SecondaryModel};
276use crate::geometry::{ExitOutcome, Flight, Geometry, Stack};
277use crate::ion::crystal::Divergence;
278use crate::rng::{run_particles, uniform};
279use crate::vec3::{cross, normalize};
280
281/// Errors from building a [`Transport`] or starting a run.
282#[derive(Debug, Clone, PartialEq, thiserror::Error)]
283pub enum TransportError {
284 /// The number of table pairs differs from the number of layers.
285 #[error("the stack has {layers} layers but {tables} table pairs were given")]
286 LayerCount {
287 /// Layers in the stack.
288 layers: usize,
289 /// Table pairs given.
290 tables: usize,
291 },
292 /// A table has the wrong sampling axis for its slot.
293 #[error("layer {layer}: the {slot} table samples {found:?}, expected {expected:?}")]
294 WrongAxis {
295 /// Layer index.
296 layer: usize,
297 /// `"elastic"` or `"inelastic"`.
298 slot: &'static str,
299 /// Axis found.
300 found: SamplingAxis,
301 /// Axis expected.
302 expected: SamplingAxis,
303 },
304 /// A configuration or primary value is out of range.
305 #[error("invalid {what}: {why}")]
306 Invalid {
307 /// The quantity.
308 what: &'static str,
309 /// Why it was rejected.
310 why: String,
311 },
312}
313
314fn invalid<T>(what: &'static str, why: impl Into<String>) -> Result<T, TransportError> {
315 Err(TransportError::Invalid {
316 what,
317 why: why.into(),
318 })
319}
320
321/// What happens when an electron reaches a face of the target. Whether it can
322/// get out at all is decided by the [`BoundaryModel`]; an electron that does
323/// leave never returns.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
325#[serde(rename_all = "kebab-case")]
326pub enum EscapeRule {
327 /// Both the front face (`x = 0`) and the back face of a finite stack
328 /// release the electron ([`Fate::Escaped`] with [`Face::Front`] or
329 /// [`Face::Back`]).
330 BothFaces,
331 /// The front face releases the electron; the back face of a finite stack
332 /// absorbs it ([`Fate::Absorbed`]).
333 FrontOnly,
334}
335
336pub use crate::geometry::Face;
337
338/// A face an electron met: between two layers, or a face of the target.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum Boundary {
341 /// The face between layer `from` (where the electron came from) and
342 /// layer `to`.
343 Interface {
344 /// Layer the electron was in.
345 from: usize,
346 /// Layer on the other side.
347 to: usize,
348 },
349 /// A face of the target, with vacuum on the other side.
350 Surface(Face),
351}
352
353/// What a face does to an electron.
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
355#[serde(tag = "model", rename_all = "kebab-case")]
356pub enum BoundaryModel {
357 /// Faces change nothing (no inner potential, no reflection).
358 Transparent,
359 /// Each face is a step in the inner potential
360 /// ([`crate::electron::boundary`]); every layer needs a
361 /// [`BandStructure`].
362 StepBarrier {
363 /// Transmit with the quantum-mechanical probability `T` (Verduin
364 /// Eq. 3.145); if false, every electron that can get over does.
365 quantum_transmission: bool,
366 /// Refract on transmission (Verduin Eq. 3.139); if false, keep the
367 /// direction.
368 refraction: bool,
369 },
370}
371
372impl BoundaryModel {
373 /// The step barrier with quantum transmission and refraction, Nebula's
374 /// defaults (`boundary_intersect` template arguments at the commit named
375 /// in [`crate::electron::boundary`]).
376 pub const STEP_BARRIER: Self = Self::StepBarrier {
377 quantum_transmission: true,
378 refraction: true,
379 };
380}
381
382/// Where the energy cutoff is measured from.
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
384#[serde(rename_all = "kebab-case")]
385pub enum CutoffReference {
386 /// From the band bottom of the electron's layer: an electron stops below
387 /// `cutoff`.
388 BandBottom,
389 /// From the vacuum level: an electron stops below `U + cutoff`, `U` the
390 /// inner potential of its layer (zero without a [`BandStructure`]), so
391 /// electrons that could never leave are not followed. Nebula's rule.
392 VacuumLevel,
393}
394
395/// How a history (or one electron of it) ended.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum Fate {
398 /// The energy fell below the stopping threshold inside the target.
399 Stopped,
400 /// The electron left the target through a face.
401 Escaped(Face),
402 /// The electron reached the back face under [`EscapeRule::FrontOnly`].
403 Absorbed,
404 /// The electron has no interaction available and no face to reach (zero
405 /// total rate heading parallel to the faces, or away from every face).
406 Trapped,
407 /// The electron hit [`TransportConfig::max_events`] and was cut off.
408 EventCap,
409 /// The electron was trapped as a polaron (an opt-in insulator channel,
410 /// [`crate::electron::phonon::PolaronTrapping`]) and deposited its
411 /// remaining energy there.
412 PolaronTrapped,
413}
414
415/// Which way a Fröhlich LO-phonon event went.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub enum PhononEvent {
418 /// The electron emitted a phonon and lost `ħω`.
419 Emission,
420 /// The electron absorbed a phonon and gained `ħω`.
421 Absorption,
422}
423
424/// One of the two cross-section tables of a layer ([`LayerTables`]).
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
426#[serde(rename_all = "kebab-case")]
427pub enum TableChannel {
428 /// The elastic table.
429 Elastic,
430 /// The inelastic table.
431 Inelastic,
432}
433
434/// Where an energy lies against the energy grid of one table, as reported to
435/// [`ElectronTally::table_lookup`].
436///
437/// The grid is the table's own [`CrossSectionTable::energy_ev`], from its
438/// first value `E_min` to its last `E_max`. Inside `[E_min, E_max]` the rate
439/// and the inverse CDF are interpolated between rows (an energy equal to an
440/// endpoint reads that row exactly); outside it they are the endpoint row's,
441/// held constant (module docs, "Free flight"). This enum says which of the
442/// two happened; it measures how often that continuation is used, not
443/// whether it is accurate.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
445#[serde(rename_all = "kebab-case")]
446pub enum GridCoverage {
447 /// `E < E_min`: the first row's values were used.
448 Below,
449 /// `E_min <= E <= E_max` (both endpoints included).
450 Within,
451 /// `E > E_max`: the last row's values were used.
452 Above,
453}
454
455impl GridCoverage {
456 /// Where `energy_ev` lies against the grid of `table`.
457 pub fn of(table: &CrossSectionTable, energy_ev: f64) -> Self {
458 let g = table.energy_ev();
459 if energy_ev < g[0] {
460 Self::Below
461 } else if energy_ev > g[g.len() - 1] {
462 Self::Above
463 } else {
464 Self::Within
465 }
466 }
467}
468
469/// Run configuration, recorded in the [`RunMetadata`].
470#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
471pub struct TransportConfig {
472 /// An electron stops when its energy falls below this (measured as
473 /// [`TransportConfig::cutoff_reference`] says), eV.
474 pub cutoff_ev: f64,
475 /// What a face of the target does to an electron reaching it.
476 pub escape_rule: EscapeRule,
477 /// Safety cap on collisions and face reflections per electron (the
478 /// primary and each secondary count separately). An electron that
479 /// reaches it ends with [`Fate::EventCap`].
480 pub max_events: u64,
481 /// Secondary-electron generation at inelastic events.
482 pub secondaries: SecondaryModel,
483 /// What the faces do.
484 pub boundary: BoundaryModel,
485 /// Where the cutoff is measured from.
486 pub cutoff_reference: CutoffReference,
487}
488
489impl TransportConfig {
490 /// A configuration with the given cutoff (from the band bottom),
491 /// [`EscapeRule::BothFaces`], a cap of 10 million events per electron, no
492 /// secondaries and transparent faces.
493 pub fn new(cutoff_ev: f64) -> Self {
494 Self {
495 cutoff_ev,
496 escape_rule: EscapeRule::BothFaces,
497 max_events: 10_000_000,
498 secondaries: SecondaryModel::Off,
499 boundary: BoundaryModel::Transparent,
500 cutoff_reference: CutoffReference::BandBottom,
501 }
502 }
503
504 fn needs_bands(&self) -> bool {
505 self.secondaries != SecondaryModel::Off || self.boundary != BoundaryModel::Transparent
506 }
507}
508
509/// The tables of one layer.
510#[derive(Debug, Clone, PartialEq)]
511pub struct LayerTables {
512 /// Elastic table ([`SamplingAxis::ElasticPolarAngle`]).
513 pub elastic: CrossSectionTable,
514 /// Inelastic table ([`SamplingAxis::InelasticEnergyLoss`]).
515 pub inelastic: CrossSectionTable,
516}
517
518/// A primary electron: it starts on the front face (`x = 0`, `y = z = 0`) in
519/// the first layer, or, with [`BoundaryModel::StepBarrier`], just outside it
520/// in vacuum (then `energy_ev` is the vacuum energy and the direction must
521/// point into the target).
522#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
523pub struct Primary {
524 /// Kinetic energy, eV; must exceed the cutoff and, once inside, the
525 /// stopping threshold of the first layer.
526 pub energy_ev: f64,
527 /// Direction of flight (any nonzero finite vector; normalised on use).
528 /// Normal incidence is `[1, 0, 0]`; a negative `x` component starts the
529 /// electron moving out of the target.
530 pub direction: [f64; 3],
531}
532
533impl Primary {
534 /// Normal incidence at `energy_ev`.
535 pub fn normal(energy_ev: f64) -> Self {
536 Self {
537 energy_ev,
538 direction: [1.0, 0.0, 0.0],
539 }
540 }
541}
542
543/// Lateral profile of the beam on the front face, about its landing point.
544/// Lengths are metres.
545///
546/// Each profile is a plain geometric law (no tabulated data): a Gaussian spot
547/// has independent normal `y` and `z` offsets with standard deviation
548/// `sigma_m` each (sampled by the Box-Muller transform, two draws); a disc is
549/// uniform in area (`r = R sqrt(U)`, azimuth uniform; two draws); a rectangle
550/// is uniform in `y` and `z` over its full widths (two draws). A point takes
551/// no draws.
552#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
553#[serde(tag = "model", rename_all = "kebab-case")]
554pub enum BeamProfile {
555 /// Every primary lands exactly at the landing point.
556 Point,
557 /// Gaussian spot, standard deviation per axis.
558 Gaussian {
559 /// Standard deviation of each of `y` and `z`, m (finite, `> 0`).
560 sigma_m: f64,
561 },
562 /// Uniform disc.
563 Disc {
564 /// Disc radius, m (finite, `> 0`).
565 radius_m: f64,
566 },
567 /// Uniform rectangle centred on the landing point.
568 Rectangle {
569 /// Full width along `y`, m (finite, `> 0`).
570 width_y_m: f64,
571 /// Full width along `z`, m (finite, `> 0`).
572 width_z_m: f64,
573 },
574}
575
576/// Where a run's primaries land and how they diverge: a finite lateral
577/// profile about a landing point on the front face, and an angular spread
578/// about the nominal [`Primary::direction`]. The default is a point beam
579/// without divergence, which draws no random numbers, so a run with it is
580/// bit-identical to one made before this type existed.
581///
582/// Per history the position is sampled first, then the direction, both from
583/// that history's own stream, so a run is identical at any thread count. The
584/// divergence is the ion beam's
585/// ([`Divergence`]: Gaussian per-plane
586/// angles, or uniform in solid angle within a cone). Where the nominal
587/// direction points into the target (`x > 0`), deflections that would send
588/// the primary out of it are rejected and redrawn (the inward conditioning of
589/// the ion side), so the draw count is then variable but still a function of
590/// the history's stream alone.
591#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
592pub struct BeamSpread {
593 /// Landing point `(y, z)` on the front face, m.
594 pub landing_m: [f64; 2],
595 /// Lateral profile about the landing point.
596 pub profile: BeamProfile,
597 /// Angular spread about the nominal direction.
598 pub divergence: Divergence,
599}
600
601impl Default for BeamSpread {
602 fn default() -> Self {
603 Self {
604 landing_m: [0.0; 2],
605 profile: BeamProfile::Point,
606 divergence: Divergence::None,
607 }
608 }
609}
610
611/// Whether a beam size is finite and strictly positive (shared by the
612/// transport and exposure beam validators).
613pub(crate) fn finite_positive(v: f64) -> bool {
614 v.is_finite() && v > 0.0
615}
616
617/// Lateral offset `(y, z)` of a Gaussian spot from two uniform draws
618/// `u1, u2` in `[0, 1)`: the Box-Muller transform (Box & Muller, Ann. Math.
619/// Stat. 29, 610 (1958)), `r = sigma sqrt(-2 ln(1 - u1))`, azimuth
620/// `phi = 2 pi u2`, offset `(r cos phi, r sin phi)`. `1 - u1` is in `(0, 1]`,
621/// so the radius is finite; `ln(1 - u1)` is evaluated as `ln_1p(-u1)`.
622pub(crate) fn gaussian_offset(sigma_m: f64, u1: f64, u2: f64) -> [f64; 2] {
623 let r = sigma_m * (-2.0 * (-u1).ln_1p()).sqrt();
624 let (s, c) = (std::f64::consts::TAU * u2).sin_cos();
625 [r * c, r * s]
626}
627
628/// Lateral offset `(y, z)` uniform in area on a disc of radius `radius_m`
629/// from two uniform draws `u1, u2` in `[0, 1)`: `r = R sqrt(u1)`, azimuth
630/// `phi = 2 pi u2`, offset `(r cos phi, r sin phi)`.
631pub(crate) fn disc_offset(radius_m: f64, u1: f64, u2: f64) -> [f64; 2] {
632 let r = radius_m * u1.sqrt();
633 let (s, c) = (std::f64::consts::TAU * u2).sin_cos();
634 [r * c, r * s]
635}
636
637/// Redraw cap of the inward conditioning of [`BeamSpread`].
638const MAX_DIVERGENCE_REDRAWS: u32 = 10_000;
639
640impl BeamSpread {
641 /// Whether this is the default point beam (it is then left out of the
642 /// run metadata).
643 pub fn is_point(&self) -> bool {
644 *self == Self::default()
645 }
646
647 /// Check the parameters.
648 pub fn validate(&self) -> Result<(), TransportError> {
649 if !self.landing_m.iter().all(|v| v.is_finite()) {
650 return invalid("beam landing point", "must be finite");
651 }
652 let ok = finite_positive;
653 let good = match self.profile {
654 BeamProfile::Point => true,
655 BeamProfile::Gaussian { sigma_m } => ok(sigma_m),
656 BeamProfile::Disc { radius_m } => ok(radius_m),
657 BeamProfile::Rectangle {
658 width_y_m,
659 width_z_m,
660 } => ok(width_y_m) && ok(width_z_m),
661 };
662 if !good {
663 return invalid("beam profile", "sizes must be finite and positive");
664 }
665 self.divergence
666 .validate()
667 .or_else(|e| invalid("beam divergence", e.to_string()))
668 }
669
670 /// Draw the start `(y, z)`, m: the landing point plus a profile offset.
671 pub fn sample_position<R: Rng + ?Sized>(&self, rng: &mut R) -> [f64; 2] {
672 let [y0, z0] = self.landing_m;
673 let (dy, dz) = match self.profile {
674 BeamProfile::Point => (0.0, 0.0),
675 BeamProfile::Gaussian { sigma_m } => {
676 let (u1, u2) = (uniform(rng), uniform(rng));
677 let [dy, dz] = gaussian_offset(sigma_m, u1, u2);
678 (dy, dz)
679 }
680 BeamProfile::Disc { radius_m } => {
681 let (u1, u2) = (uniform(rng), uniform(rng));
682 let [dy, dz] = disc_offset(radius_m, u1, u2);
683 (dy, dz)
684 }
685 BeamProfile::Rectangle {
686 width_y_m,
687 width_z_m,
688 } => (
689 width_y_m * (uniform(rng) - 0.5),
690 width_z_m * (uniform(rng) - 0.5),
691 ),
692 };
693 [y0 + dy, z0 + dz]
694 }
695
696 /// Draw the direction about the unit `central` direction.
697 fn sample_direction<R: Rng + ?Sized>(
698 &self,
699 central: [f64; 3],
700 rng: &mut R,
701 ) -> Result<[f64; 3], TransportError> {
702 if self.divergence == Divergence::None {
703 return Ok(central);
704 }
705 for _ in 0..MAX_DIVERGENCE_REDRAWS {
706 let d = self.divergence.sample_direction(central, rng);
707 if central[0] <= 0.0 || d[0] > 0.0 {
708 return Ok(d);
709 }
710 }
711 invalid(
712 "beam divergence",
713 "no inward direction was drawn; the nominal direction is too close to the surface",
714 )
715 }
716}
717
718/// The state of an electron after an event. Positions are `[x, y, z]` in
719/// metres, `x` the depth.
720#[derive(Debug, Clone, Copy, PartialEq)]
721pub struct ElectronState {
722 /// Position, m.
723 pub pos: [f64; 3],
724 /// Unit direction of flight.
725 pub dir: [f64; 3],
726 /// Kinetic energy, eV.
727 pub energy_ev: f64,
728 /// Index of the layer the electron is in (or was last in, after an
729 /// escape).
730 pub layer: usize,
731}
732
733/// Event hooks called by [`Transport`]. All default to no-ops except `merge`.
734/// The `ElectronState` passed is the state **after** the event.
735pub trait ElectronTally: Send {
736 /// A primary history starts. With [`BoundaryModel::StepBarrier`], `start`
737 /// is the primary in vacuum, before it meets the front face.
738 fn begin_history(&mut self, _index: u64, _start: &ElectronState) {}
739
740 /// A flight segment of `length_m` ended at `end.pos` (at a collision, a
741 /// layer face or the target face). It started at `from` with energy
742 /// `end.energy_ev` (the energy does not change in flight).
743 fn step(&mut self, _from: [f64; 3], _end: &ElectronState, _length_m: f64) {}
744
745 /// The rate of `channel`'s table in layer `at.layer` was evaluated at
746 /// `at.energy_ev`, which lies `coverage` against that table's grid.
747 ///
748 /// Called twice (elastic, then inelastic) at the start of every pass of
749 /// the transport loop that evaluates the rates, before the free path is
750 /// drawn: for every flight, whether it ends at a collision, at a layer
751 /// face (the path is then discarded and the rates are evaluated again in
752 /// the next pass) or nowhere (zero total rate, [`Fate::Trapped`]); after
753 /// a face reflection; and for a channel whose rate is zero. It is not
754 /// called for the primary's entry through the front face (no table is
755 /// read), nor once an electron has reached the event cap, nor for the
756 /// tables of inner-shell channels. The inverse CDF
757 /// sampled at a collision uses the grid position of the same evaluation
758 /// and is not reported again. So these are **rate evaluations**, not
759 /// collisions: their number exceeds the number of collisions by the
760 /// number of boundary-limited and rate-less flights.
761 fn table_lookup(
762 &mut self,
763 _at: &ElectronState,
764 _channel: TableChannel,
765 _coverage: GridCoverage,
766 ) {
767 }
768
769 /// An elastic collision deflected the electron by `theta` rad.
770 fn elastic(&mut self, _after: &ElectronState, _theta: f64) {}
771
772 /// An inelastic collision took `w_ev` from the electron. `after` carries
773 /// the new direction if the secondary model deflected it.
774 fn inelastic(&mut self, _after: &ElectronState, _w_ev: f64) {}
775
776 /// Called right after [`ElectronTally::inelastic`] for every inelastic
777 /// event drawn from an inner-shell channel
778 /// ([`Transport::with_inner_shells`]), with or without a secondary
779 /// model: the shell (its element, subshell and binding energy) and the
780 /// loss `w_ev` (the same as passed to [`ElectronTally::inelastic`]). Not
781 /// called for valence events. Comes before [`ElectronTally::secondary`].
782 fn inner_shell(&mut self, _after: &ElectronState, _shell: &InnerShell, _w_ev: f64) {}
783
784 /// Called right after [`ElectronTally::inelastic`] (and
785 /// [`ElectronTally::inner_shell`], for a shell event) for every inelastic
786 /// event when a [`SecondaryModel`] is on: the event's energy bookkeeping,
787 /// and the secondary that was created (`None` if none was). The
788 /// secondary is transported later, between
789 /// [`ElectronTally::begin_secondary`] and
790 /// [`ElectronTally::end_secondary`].
791 fn secondary(
792 &mut self,
793 _primary: &ElectronState,
794 _event: &SecondaryEvent,
795 _created: Option<&ElectronState>,
796 ) {
797 }
798
799 /// A Fröhlich LO-phonon event changed the energy by `-ħω` (emission) or
800 /// `+ħω` (absorption), `hbar_omega_ev` given, and deflected the electron by
801 /// `theta` rad. `after` is the state after the event.
802 fn phonon(
803 &mut self,
804 _after: &ElectronState,
805 _kind: PhononEvent,
806 _hbar_omega_ev: f64,
807 _theta: f64,
808 ) {
809 }
810
811 /// The electron was trapped as a polaron at `at` and deposits its whole
812 /// remaining energy `at.energy_ev` there. The history (or the secondary)
813 /// then ends with [`Fate::PolaronTrapped`] (no [`ElectronTally::stopped`]
814 /// call).
815 fn polaron_trapped(&mut self, _at: &ElectronState) {}
816
817 /// The electron crossed from layer `from_layer` into `to_layer`; `at` is
818 /// its state on the face, inside the new layer (after refraction, with
819 /// [`BoundaryModel::StepBarrier`]).
820 fn interface(&mut self, _at: &ElectronState, _from_layer: usize, _to_layer: usize) {}
821
822 /// With [`BoundaryModel::StepBarrier`]: the electron got through the face
823 /// `boundary`, and its kinetic energy changed by `delta_u_ev` (the step
824 /// `U' - U`). `at` is its state just past the face (in vacuum, for a
825 /// surface). Comes before [`ElectronTally::interface`] or
826 /// [`ElectronTally::escaped`].
827 fn barrier(&mut self, _at: &ElectronState, _boundary: Boundary, _delta_u_ev: f64) {}
828
829 /// With [`BoundaryModel::StepBarrier`]: the electron was reflected at the
830 /// face `boundary`; `at` is its state after the reflection. At the
831 /// primary's entry this is followed by [`ElectronTally::escaped`].
832 fn reflected(&mut self, _at: &ElectronState, _boundary: Boundary) {}
833
834 /// A secondary of the given `generation` (1 for a secondary of the
835 /// primary, 2 for one of a secondary, ...) starts at `start`.
836 fn begin_secondary(&mut self, _start: &ElectronState, _generation: u32) {}
837
838 /// The current secondary ended with `fate`.
839 fn end_secondary(&mut self, _fate: Fate) {}
840
841 /// The electron fell below the stopping threshold (or was trapped) at
842 /// `at`. A stop has `at.energy_ev` below the threshold of `at.layer`, a
843 /// trapped electron at least that energy
844 /// ([`Transport::stopping_thresholds_ev`]).
845 fn stopped(&mut self, _at: &ElectronState) {}
846
847 /// The electron left the target through `face`; `at.energy_ev` is the
848 /// energy it leaves with (its vacuum energy, with the step barrier).
849 fn escaped(&mut self, _at: &ElectronState, _face: Face) {}
850
851 /// The electron was absorbed at the back face ([`EscapeRule::FrontOnly`]).
852 fn absorbed(&mut self, _at: &ElectronState) {}
853
854 /// The history ended (its last secondary included); `fate` is the
855 /// primary's.
856 fn end_history(&mut self, _index: u64, _fate: Fate) {}
857
858 /// Fold `other` (a later chunk) into `self`.
859 fn merge(&mut self, other: Self)
860 where
861 Self: Sized;
862}
863
864/// A minimal summary tally: counts, path length and energy bookkeeping.
865///
866/// The fate counts (`stopped` to `event_capped`) are per history, by the
867/// primary's fate; the event counts and energies cover every electron. With
868/// every electron ending stopped, trapped or escaped, the energies balance:
869/// incident energy plus `secondary_energy_ev` equals `inelastic_loss_ev +
870/// escaped_energy_ev + rest_energy_ev + barrier_ev`.
871#[derive(Debug, Clone, Default, PartialEq)]
872pub struct SummaryTally {
873 /// Histories run.
874 pub histories: u64,
875 /// Histories whose primary stopped below the threshold.
876 pub stopped: u64,
877 /// Primaries that left through the front face.
878 pub escaped_front: u64,
879 /// Primaries that left through the back face.
880 pub escaped_back: u64,
881 /// Primaries that left through a lateral face of a voxel grid.
882 pub escaped_side: u64,
883 /// Primaries absorbed at the back face.
884 pub absorbed: u64,
885 /// Trapped primaries.
886 pub trapped: u64,
887 /// Primaries cut off by the event cap.
888 pub event_capped: u64,
889 /// Primaries trapped as polarons.
890 pub polaron_trapped: u64,
891 /// Secondaries created (and transported).
892 pub secondaries: u64,
893 /// Secondaries that left the target through either face.
894 pub secondaries_escaped: u64,
895 /// Secondaries cut off by the event cap ([`Fate::EventCap`]), one per
896 /// capped secondary track. A numerical-truncation diagnostic: zero
897 /// `event_capped` primaries does not mean no secondary was capped.
898 pub secondaries_event_capped: u64,
899 /// Face reflections (step barrier).
900 pub reflections: u64,
901 /// Elastic collisions.
902 pub elastic_events: u64,
903 /// Inelastic collisions (valence and inner-shell).
904 pub inelastic_events: u64,
905 /// Inelastic collisions in an inner-shell channel (part of
906 /// `inelastic_events`).
907 pub inner_shell_events: u64,
908 /// Layer-face crossings between layers.
909 pub interface_crossings: u64,
910 /// LO-phonon emissions.
911 pub phonon_emissions: u64,
912 /// LO-phonon absorptions.
913 pub phonon_absorptions: u64,
914 /// Total flight path, m.
915 pub path_m: f64,
916 /// Total energy lost in inelastic collisions, eV.
917 pub inelastic_loss_ev: f64,
918 /// Energy lost in inner-shell collisions, eV (part of
919 /// `inelastic_loss_ev`).
920 pub inner_shell_loss_ev: f64,
921 /// Energy carried out of the target by escaped electrons, eV.
922 pub escaped_energy_ev: f64,
923 /// Energy of stopped electrons when they stopped, eV.
924 pub rest_energy_ev: f64,
925 /// Energy given to the lattice by phonon emission, eV.
926 pub phonon_emitted_ev: f64,
927 /// Energy taken from the lattice by phonon absorption, eV.
928 pub phonon_absorbed_ev: f64,
929 /// Energy deposited by electrons trapped as polarons, eV.
930 pub polaron_deposited_ev: f64,
931 /// Kinetic energy of the secondaries created, eV.
932 pub secondary_energy_ev: f64,
933 /// Sum of [`SecondaryEvent::binding_ev`], eV.
934 pub binding_ev: f64,
935 /// Sum of [`SecondaryEvent::deposited_ev`], eV.
936 pub deposited_ev: f64,
937 /// Kinetic energy taken by potential steps, the sum of `-ΔU` over face
938 /// transmissions, eV.
939 pub barrier_ev: f64,
940}
941
942impl ElectronTally for SummaryTally {
943 fn step(&mut self, _from: [f64; 3], _end: &ElectronState, length_m: f64) {
944 self.path_m += length_m;
945 }
946 fn elastic(&mut self, _after: &ElectronState, _theta: f64) {
947 self.elastic_events += 1;
948 }
949 fn inelastic(&mut self, _after: &ElectronState, w_ev: f64) {
950 self.inelastic_events += 1;
951 self.inelastic_loss_ev += w_ev;
952 }
953 fn inner_shell(&mut self, _after: &ElectronState, _shell: &InnerShell, w_ev: f64) {
954 self.inner_shell_events += 1;
955 self.inner_shell_loss_ev += w_ev;
956 }
957 fn secondary(&mut self, _p: &ElectronState, e: &SecondaryEvent, c: Option<&ElectronState>) {
958 if c.is_some() {
959 self.secondaries += 1;
960 self.secondary_energy_ev += e.secondary_ev;
961 }
962 self.binding_ev += e.binding_ev;
963 self.deposited_ev += e.deposited_ev;
964 }
965 fn phonon(
966 &mut self,
967 _after: &ElectronState,
968 kind: PhononEvent,
969 hbar_omega_ev: f64,
970 _theta: f64,
971 ) {
972 match kind {
973 PhononEvent::Emission => {
974 self.phonon_emissions += 1;
975 self.phonon_emitted_ev += hbar_omega_ev;
976 }
977 PhononEvent::Absorption => {
978 self.phonon_absorptions += 1;
979 self.phonon_absorbed_ev += hbar_omega_ev;
980 }
981 }
982 }
983 fn polaron_trapped(&mut self, at: &ElectronState) {
984 self.polaron_deposited_ev += at.energy_ev;
985 }
986 fn interface(&mut self, _at: &ElectronState, _from: usize, _to: usize) {
987 self.interface_crossings += 1;
988 }
989 fn barrier(&mut self, _at: &ElectronState, _b: Boundary, delta_u_ev: f64) {
990 self.barrier_ev -= delta_u_ev;
991 }
992 fn reflected(&mut self, _at: &ElectronState, _b: Boundary) {
993 self.reflections += 1;
994 }
995 fn end_secondary(&mut self, fate: Fate) {
996 match fate {
997 Fate::Escaped(_) => self.secondaries_escaped += 1,
998 Fate::EventCap => self.secondaries_event_capped += 1,
999 _ => {}
1000 }
1001 }
1002 fn stopped(&mut self, at: &ElectronState) {
1003 self.rest_energy_ev += at.energy_ev;
1004 }
1005 fn escaped(&mut self, at: &ElectronState, _face: Face) {
1006 self.escaped_energy_ev += at.energy_ev;
1007 }
1008 fn end_history(&mut self, _index: u64, fate: Fate) {
1009 self.histories += 1;
1010 match fate {
1011 Fate::Stopped => self.stopped += 1,
1012 Fate::Escaped(Face::Front) => self.escaped_front += 1,
1013 Fate::Escaped(Face::Back) => self.escaped_back += 1,
1014 Fate::Escaped(Face::Side) => self.escaped_side += 1,
1015 Fate::Absorbed => self.absorbed += 1,
1016 Fate::Trapped => self.trapped += 1,
1017 Fate::EventCap => self.event_capped += 1,
1018 Fate::PolaronTrapped => self.polaron_trapped += 1,
1019 }
1020 }
1021 fn merge(&mut self, o: Self) {
1022 self.histories += o.histories;
1023 self.stopped += o.stopped;
1024 self.escaped_front += o.escaped_front;
1025 self.escaped_back += o.escaped_back;
1026 self.escaped_side += o.escaped_side;
1027 self.absorbed += o.absorbed;
1028 self.trapped += o.trapped;
1029 self.event_capped += o.event_capped;
1030 self.polaron_trapped += o.polaron_trapped;
1031 self.secondaries += o.secondaries;
1032 self.secondaries_escaped += o.secondaries_escaped;
1033 self.secondaries_event_capped += o.secondaries_event_capped;
1034 self.reflections += o.reflections;
1035 self.elastic_events += o.elastic_events;
1036 self.inelastic_events += o.inelastic_events;
1037 self.inner_shell_events += o.inner_shell_events;
1038 self.interface_crossings += o.interface_crossings;
1039 self.phonon_emissions += o.phonon_emissions;
1040 self.phonon_absorptions += o.phonon_absorptions;
1041 self.path_m += o.path_m;
1042 self.inelastic_loss_ev += o.inelastic_loss_ev;
1043 self.inner_shell_loss_ev += o.inner_shell_loss_ev;
1044 self.escaped_energy_ev += o.escaped_energy_ev;
1045 self.rest_energy_ev += o.rest_energy_ev;
1046 self.phonon_emitted_ev += o.phonon_emitted_ev;
1047 self.phonon_absorbed_ev += o.phonon_absorbed_ev;
1048 self.polaron_deposited_ev += o.polaron_deposited_ev;
1049 self.secondary_energy_ev += o.secondary_energy_ev;
1050 self.binding_ev += o.binding_ev;
1051 self.deposited_ev += o.deposited_ev;
1052 self.barrier_ev += o.barrier_ev;
1053 }
1054}
1055
1056/// Identity of the tables used in one layer, for [`RunMetadata`].
1057#[derive(Debug, Clone, PartialEq, Serialize)]
1058pub struct LayerMetadata {
1059 /// Layer index (0 = front).
1060 pub index: usize,
1061 /// Depth of the front face, m; `None` for a material of a geometry other
1062 /// than a [`Stack`] (a voxel grid has no layer extent).
1063 pub front_m: Option<f64>,
1064 /// Depth of the back face, m; `None` for a semi-infinite substrate (or
1065 /// for a geometry other than a [`Stack`]).
1066 pub back_m: Option<f64>,
1067 /// Elastic table model identity.
1068 pub elastic_model: String,
1069 /// Elastic table provenance.
1070 pub elastic_provenance: String,
1071 /// Inelastic table model identity.
1072 pub inelastic_model: String,
1073 /// Inelastic table provenance.
1074 pub inelastic_provenance: String,
1075 /// Band parameters of the layer, with their provenance, if given.
1076 pub band_structure: Option<BandStructure>,
1077 /// Fröhlich LO-phonon channel of this layer (its parameters and their
1078 /// provenance), or `None` when off (the default, and the choice for a
1079 /// metal).
1080 pub phonon: Option<FrohlichPhonon>,
1081 /// Polaron-trapping channel of this layer, or `None` when off.
1082 pub polaron: Option<PolaronTrapping>,
1083 /// Inner-shell channels of this layer, in sampling order
1084 /// ([`Transport::with_inner_shells`]); empty (and not serialized) when
1085 /// none.
1086 #[serde(skip_serializing_if = "Vec::is_empty")]
1087 pub inner_shells: Vec<InnerShellMetadata>,
1088}
1089
1090/// Identity of one inner-shell channel table of a layer, for
1091/// [`LayerMetadata`].
1092#[derive(Debug, Clone, PartialEq, Serialize)]
1093pub struct InnerShellMetadata {
1094 /// Atomic number.
1095 pub z: u8,
1096 /// Subshell label (ENDF, e.g. `"K"`).
1097 pub subshell: String,
1098 /// Binding energy, eV.
1099 pub binding_energy_ev: f64,
1100 /// Where the binding energy came from.
1101 pub binding_provenance: String,
1102 /// The channel table's model identity.
1103 pub model: String,
1104 /// The channel table's provenance.
1105 pub provenance: String,
1106}
1107
1108/// What a run was configured with, for output metadata.
1109#[derive(Debug, Clone, PartialEq, Serialize)]
1110pub struct RunMetadata {
1111 /// Energy cutoff, eV.
1112 pub cutoff_ev: f64,
1113 /// Where the cutoff is measured from.
1114 pub cutoff_reference: CutoffReference,
1115 /// Escape rule at the target faces.
1116 pub escape_rule: EscapeRule,
1117 /// Collision and reflection cap per electron.
1118 pub max_events: u64,
1119 /// Secondary-electron model.
1120 pub secondaries: SecondaryModel,
1121 /// Face model.
1122 pub boundary: BoundaryModel,
1123 /// Run seed.
1124 pub seed: u64,
1125 /// Primaries run.
1126 pub n_histories: u64,
1127 /// Chunk size of the parallel driver.
1128 pub chunk_size: u64,
1129 /// The primary electron.
1130 pub primary: Primary,
1131 /// Landing profile and divergence; left out for a point beam.
1132 #[serde(skip_serializing_if = "BeamSpread::is_point")]
1133 pub beam: BeamSpread,
1134 /// Per-layer table identities.
1135 pub layers: Vec<LayerMetadata>,
1136}
1137
1138/// The result of [`Transport::run`].
1139#[derive(Debug, Clone, PartialEq)]
1140pub struct TransportRun<T> {
1141 /// The merged tally.
1142 pub tally: T,
1143 /// The run configuration.
1144 pub metadata: RunMetadata,
1145}
1146
1147/// The electron transport engine: a stack, the tables of each layer, their
1148/// band parameters (optional) and a configuration.
1149#[derive(Debug, Clone)]
1150pub struct Transport<G = Stack> {
1151 geometry: G,
1152 /// `(front, back)` depth of each material's layer, for the metadata of a
1153 /// stack run (`None` for other geometries).
1154 extents: Vec<(Option<f64>, Option<f64>)>,
1155 tables: Vec<LayerTables>,
1156 channels: Vec<InsulatorChannels>,
1157 /// Inner-shell channel tables per layer (empty by default).
1158 shells: Vec<Vec<ShellChannelTable>>,
1159 bands: Option<Vec<BandStructure>>,
1160 /// Inner potential per layer, eV (zero without band parameters).
1161 inner: Vec<f64>,
1162 /// Stopping threshold per layer, eV.
1163 threshold: Vec<f64>,
1164 config: TransportConfig,
1165}
1166
1167/// An electron waiting on the history's stack, with its region and generation.
1168type Pending = (ElectronState, usize, u32);
1169
1170impl Transport<Stack> {
1171 /// Check and assemble, without band parameters. `tables[i]` belongs to
1172 /// `stack.layers()[i]`. Fails if the configuration needs band parameters
1173 /// (a [`SecondaryModel`] other than `Off`, or the step barrier); use
1174 /// [`Transport::with_band_structures`] then.
1175 pub fn new(
1176 stack: Stack,
1177 tables: Vec<LayerTables>,
1178 config: TransportConfig,
1179 ) -> Result<Self, TransportError> {
1180 let extents = stack_extents(&stack);
1181 Self::build(stack, extents, tables, None, config)
1182 }
1183
1184 /// Check and assemble with band parameters: `bands[i]` belongs to
1185 /// `stack.layers()[i]`. They supply the Fermi energy and band gap of the
1186 /// secondary model and the inner potential of the step barrier and of
1187 /// [`CutoffReference::VacuumLevel`].
1188 pub fn with_band_structures(
1189 stack: Stack,
1190 tables: Vec<LayerTables>,
1191 bands: Vec<BandStructure>,
1192 config: TransportConfig,
1193 ) -> Result<Self, TransportError> {
1194 let extents = stack_extents(&stack);
1195 Self::build(stack, extents, tables, Some(bands), config)
1196 }
1197
1198 /// The target.
1199 pub fn stack(&self) -> &Stack {
1200 &self.geometry
1201 }
1202}
1203
1204/// `(front, back)` depth of each layer of `stack`, for [`LayerMetadata`].
1205fn stack_extents(stack: &Stack) -> Vec<(Option<f64>, Option<f64>)> {
1206 stack
1207 .layers()
1208 .iter()
1209 .map(|l| {
1210 (
1211 Some(l.front_m()),
1212 l.back_m().is_finite().then_some(l.back_m()),
1213 )
1214 })
1215 .collect()
1216}
1217
1218impl<G: Geometry> Transport<G> {
1219 /// [`Transport::new`] on any [`Geometry`] (a [`crate::geometry::VoxelGrid`],
1220 /// a mesh, a CSG solid): `tables[m]` belongs to material `m` of the
1221 /// geometry ([`Geometry::material`]), whatever the regions that hold it.
1222 /// Everything per material (tables, band parameters, insulator and
1223 /// inner-shell channels, stopping thresholds) is indexed by material; the
1224 /// electron's [`ElectronState::layer`] is then its material index.
1225 ///
1226 /// The primary starts at [`Geometry::entry_point`].
1227 pub fn on_geometry(
1228 geometry: G,
1229 tables: Vec<LayerTables>,
1230 config: TransportConfig,
1231 ) -> Result<Self, TransportError> {
1232 Self::on_geometry_checked(geometry, tables, None, config)
1233 }
1234
1235 /// [`Transport::with_band_structures`] on any [`Geometry`]; `bands[m]`
1236 /// belongs to material `m`.
1237 pub fn on_geometry_with_band_structures(
1238 geometry: G,
1239 tables: Vec<LayerTables>,
1240 bands: Vec<BandStructure>,
1241 config: TransportConfig,
1242 ) -> Result<Self, TransportError> {
1243 Self::on_geometry_checked(geometry, tables, Some(bands), config)
1244 }
1245
1246 fn on_geometry_checked(
1247 geometry: G,
1248 tables: Vec<LayerTables>,
1249 bands: Option<Vec<BandStructure>>,
1250 config: TransportConfig,
1251 ) -> Result<Self, TransportError> {
1252 if config.boundary != BoundaryModel::Transparent {
1253 // The step is applied to the normal component along x
1254 // (`cross_step`), which is the face normal of a stack only.
1255 return invalid(
1256 "boundary",
1257 "the step barrier is implemented for the layered Stack only; \
1258 use BoundaryModel::Transparent on other geometries",
1259 );
1260 }
1261 let extents = vec![(None, None); geometry.n_materials()];
1262 Self::build(geometry, extents, tables, bands, config)
1263 }
1264
1265 /// The target geometry.
1266 pub fn geometry(&self) -> &G {
1267 &self.geometry
1268 }
1269
1270 /// Number of materials, which is the number of table pairs.
1271 pub fn n_materials(&self) -> usize {
1272 self.tables.len()
1273 }
1274
1275 fn build(
1276 geometry: G,
1277 extents: Vec<(Option<f64>, Option<f64>)>,
1278 tables: Vec<LayerTables>,
1279 bands: Option<Vec<BandStructure>>,
1280 config: TransportConfig,
1281 ) -> Result<Self, TransportError> {
1282 if tables.len() != geometry.n_materials() {
1283 return Err(TransportError::LayerCount {
1284 layers: geometry.n_materials(),
1285 tables: tables.len(),
1286 });
1287 }
1288 for (layer, t) in tables.iter().enumerate() {
1289 for (slot, table, expected) in [
1290 ("elastic", &t.elastic, SamplingAxis::ElasticPolarAngle),
1291 ("inelastic", &t.inelastic, SamplingAxis::InelasticEnergyLoss),
1292 ] {
1293 if table.axis() != expected {
1294 return Err(TransportError::WrongAxis {
1295 layer,
1296 slot,
1297 found: table.axis(),
1298 expected,
1299 });
1300 }
1301 }
1302 }
1303 if !(config.cutoff_ev.is_finite() && config.cutoff_ev > 0.0) {
1304 return invalid("cutoff", "must be finite and positive");
1305 }
1306 if config.max_events == 0 {
1307 return invalid("max_events", "must be at least 1");
1308 }
1309 match &bands {
1310 Some(b) if b.len() != geometry.n_materials() => {
1311 return invalid(
1312 "band structures",
1313 format!(
1314 "the stack has {} layers but {} band structures were given",
1315 geometry.n_materials(),
1316 b.len()
1317 ),
1318 );
1319 }
1320 None if config.needs_bands() => {
1321 return invalid(
1322 "band structures",
1323 "secondary generation and the step barrier need one per layer \
1324 (Transport::with_band_structures)",
1325 );
1326 }
1327 _ => {}
1328 }
1329 let inner: Vec<f64> = match &bands {
1330 Some(b) => b.iter().map(BandStructure::inner_potential_ev).collect(),
1331 None => vec![0.0; tables.len()],
1332 };
1333 let threshold: Vec<f64> = inner
1334 .iter()
1335 .map(|&u| match config.cutoff_reference {
1336 CutoffReference::BandBottom => config.cutoff_ev,
1337 CutoffReference::VacuumLevel => u + config.cutoff_ev,
1338 })
1339 .collect();
1340 if config.secondaries != SecondaryModel::Off {
1341 // Every secondary starts at E_F + W, so a threshold at or below
1342 // the Fermi level would let them multiply without end; with it
1343 // above, the energy above the Fermi level, which inelastic events
1344 // conserve or reduce, bounds the number of electrons followed.
1345 let b = bands.as_ref().expect("checked above");
1346 for (layer, (band, &t)) in b.iter().zip(&threshold).enumerate() {
1347 if t <= band.fermi_ev() {
1348 return invalid(
1349 "cutoff",
1350 format!(
1351 "layer {layer}: the stopping threshold {t} eV must exceed the \
1352 Fermi energy {} eV under the secondary model",
1353 band.fermi_ev()
1354 ),
1355 );
1356 }
1357 }
1358 }
1359 let channels = vec![InsulatorChannels::none(); tables.len()];
1360 let shells = vec![Vec::new(); tables.len()];
1361 Ok(Self {
1362 geometry,
1363 extents,
1364 tables,
1365 channels,
1366 shells,
1367 bands,
1368 inner,
1369 threshold,
1370 config,
1371 })
1372 }
1373
1374 /// Switch on the insulator channels `channels` in layer `layer` (they
1375 /// replace that layer's previous choice). Every layer starts with
1376 /// [`InsulatorChannels::none`]; only opt a layer in when it is a polar
1377 /// insulator (never a metal). The choice is recorded in the
1378 /// [`LayerMetadata`] of the run.
1379 pub fn with_insulator_channels(
1380 mut self,
1381 layer: usize,
1382 channels: InsulatorChannels,
1383 ) -> Result<Self, TransportError> {
1384 if layer >= self.channels.len() {
1385 return invalid(
1386 "layer",
1387 format!(
1388 "{layer} is out of range (the stack has {} layers)",
1389 self.channels.len()
1390 ),
1391 );
1392 }
1393 self.channels[layer] = channels;
1394 Ok(self)
1395 }
1396
1397 /// The insulator channels of each layer.
1398 pub fn insulator_channels(&self) -> &[InsulatorChannels] {
1399 &self.channels
1400 }
1401
1402 /// Add the inner-shell ionisation channels `shells` to layer `layer`
1403 /// (they replace that layer's previous shells; an empty list removes
1404 /// them). Every layer starts with none, which is the valence-only
1405 /// transport of before. The layer's inelastic table is then its
1406 /// **valence** channel: it must not already contain these shells (build
1407 /// both with
1408 /// [`crate::electron::inelastic::table::build_shell_channel_tables`]).
1409 /// The channels are sampled in the given order (module docs, "Inner-shell
1410 /// channels") and recorded in the [`LayerMetadata`] of the run.
1411 ///
1412 /// Fails, leaving nothing changed, if `layer` is out of range, if a
1413 /// shell's table names a different material from the layer's inelastic
1414 /// table (tables built for another target), if a `(Z, subshell)` repeats,
1415 /// or, with band parameters, if a binding energy does not exceed the
1416 /// layer's Fermi energy (an inner shell lies below the Fermi level).
1417 pub fn with_inner_shells(
1418 mut self,
1419 layer: usize,
1420 shells: Vec<ShellChannelTable>,
1421 ) -> Result<Self, TransportError> {
1422 if layer >= self.shells.len() {
1423 return invalid(
1424 "layer",
1425 format!(
1426 "{layer} is out of range (the stack has {} layers)",
1427 self.shells.len()
1428 ),
1429 );
1430 }
1431 let material = self.tables[layer].inelastic.material();
1432 for (i, s) in shells.iter().enumerate() {
1433 let name = format!(
1434 "layer {layer}: inner shell Z = {} {}",
1435 s.z(),
1436 s.subshell().label()
1437 );
1438 if s.table().material() != material {
1439 return invalid(
1440 "inner-shell channel",
1441 format!(
1442 "{name}: its table is for {:?}, the layer's inelastic table for {material:?}",
1443 s.table().material()
1444 ),
1445 );
1446 }
1447 if shells[..i]
1448 .iter()
1449 .any(|o| o.z() == s.z() && o.subshell() == s.subshell())
1450 {
1451 return invalid("inner-shell channel", format!("{name} listed twice"));
1452 }
1453 if let Some(bands) = &self.bands {
1454 let ef = bands[layer].fermi_ev();
1455 if s.binding_energy_ev() <= ef {
1456 return invalid(
1457 "inner-shell channel",
1458 format!(
1459 "{name}: binding energy {} eV does not exceed the Fermi energy \
1460 {ef} eV",
1461 s.binding_energy_ev()
1462 ),
1463 );
1464 }
1465 }
1466 }
1467 self.shells[layer] = shells;
1468 Ok(self)
1469 }
1470
1471 /// The inner-shell channel tables of each layer.
1472 pub fn inner_shells(&self) -> &[Vec<ShellChannelTable>] {
1473 &self.shells
1474 }
1475
1476 /// The configuration.
1477 pub fn config(&self) -> &TransportConfig {
1478 &self.config
1479 }
1480
1481 /// The tables of each layer.
1482 pub fn layer_tables(&self) -> &[LayerTables] {
1483 &self.tables
1484 }
1485
1486 /// The band parameters per layer, if given.
1487 pub fn band_structures(&self) -> Option<&[BandStructure]> {
1488 self.bands.as_deref()
1489 }
1490
1491 /// The stopping threshold of each layer, eV: the cutoff under
1492 /// [`CutoffReference::BandBottom`], `U + cutoff` under
1493 /// [`CutoffReference::VacuumLevel`] (module docs). An electron reported
1494 /// by [`ElectronTally::stopped`] with less energy than its layer's
1495 /// threshold fell below it ([`Fate::Stopped`]); one with at least that
1496 /// energy is [`Fate::Trapped`].
1497 pub fn stopping_thresholds_ev(&self) -> &[f64] {
1498 &self.threshold
1499 }
1500
1501 /// The largest inelastic loss an electron in state `st` may take: `E`
1502 /// without a secondary model, `E - E_F` (the Fermi-level clamp) with one.
1503 fn max_loss_ev(&self, st: &ElectronState) -> f64 {
1504 match (&self.config.secondaries, &self.bands) {
1505 (SecondaryModel::KieftBosch { .. }, Some(b)) => st.energy_ev - b[st.layer].fermi_ev(),
1506 _ => st.energy_ev,
1507 }
1508 }
1509
1510 fn step_barrier(&self) -> Option<(bool, bool)> {
1511 match self.config.boundary {
1512 BoundaryModel::Transparent => None,
1513 BoundaryModel::StepBarrier {
1514 quantum_transmission,
1515 refraction,
1516 } => Some((quantum_transmission, refraction)),
1517 }
1518 }
1519
1520 /// The region and material a primary starts in: at the geometry's
1521 /// [`Geometry::entry_point`].
1522 fn start(&self) -> Result<(usize, usize), TransportError> {
1523 match self.geometry.locate(self.geometry.entry_point()) {
1524 Some(region) => Ok((region, self.geometry.material_index(region))),
1525 None => invalid("entry point", "lies outside the target"),
1526 }
1527 }
1528
1529 fn check_primary(&self, p: &Primary) -> Result<[f64; 3], TransportError> {
1530 let (_, m0) = self.start()?;
1531 if !(p.energy_ev.is_finite() && p.energy_ev > self.config.cutoff_ev) {
1532 return invalid(
1533 "primary energy",
1534 format!("{} eV must exceed the cutoff", p.energy_ev),
1535 );
1536 }
1537 let n = (p.direction[0].powi(2) + p.direction[1].powi(2) + p.direction[2].powi(2)).sqrt();
1538 if !(n.is_finite() && n > 0.0) {
1539 return invalid("primary direction", "must be finite and nonzero");
1540 }
1541 let dir = [p.direction[0] / n, p.direction[1] / n, p.direction[2] / n];
1542 let inside = if self.step_barrier().is_some() {
1543 if dir[0] <= 0.0 {
1544 return invalid(
1545 "primary direction",
1546 "must point into the target (positive x) with the step barrier",
1547 );
1548 }
1549 p.energy_ev + self.inner[m0]
1550 } else {
1551 p.energy_ev
1552 };
1553 if inside <= self.threshold[m0] {
1554 return invalid(
1555 "primary energy",
1556 format!(
1557 "{inside} eV inside the first layer must exceed its stopping threshold {} eV",
1558 self.threshold[m0]
1559 ),
1560 );
1561 }
1562 Ok(dir)
1563 }
1564
1565 /// The metadata of a run with these arguments.
1566 pub fn metadata(
1567 &self,
1568 seed: u64,
1569 n_histories: u64,
1570 chunk_size: u64,
1571 primary: &Primary,
1572 ) -> RunMetadata {
1573 RunMetadata {
1574 cutoff_ev: self.config.cutoff_ev,
1575 cutoff_reference: self.config.cutoff_reference,
1576 escape_rule: self.config.escape_rule,
1577 max_events: self.config.max_events,
1578 secondaries: self.config.secondaries,
1579 boundary: self.config.boundary,
1580 seed,
1581 n_histories,
1582 chunk_size,
1583 primary: *primary,
1584 beam: BeamSpread::default(),
1585 layers: self
1586 .extents
1587 .iter()
1588 .zip(&self.tables)
1589 .zip(&self.channels)
1590 .enumerate()
1591 .map(|(index, ((&(front_m, back_m), t), c))| LayerMetadata {
1592 index,
1593 front_m,
1594 back_m,
1595 elastic_model: t.elastic.model().to_string(),
1596 elastic_provenance: t.elastic.provenance().to_string(),
1597 inelastic_model: t.inelastic.model().to_string(),
1598 inelastic_provenance: t.inelastic.provenance().to_string(),
1599 band_structure: self.bands.as_ref().map(|b| b[index].clone()),
1600 phonon: c.phonon.clone(),
1601 polaron: c.polaron.clone(),
1602 inner_shells: self.shells[index]
1603 .iter()
1604 .map(|s| InnerShellMetadata {
1605 z: s.z(),
1606 subshell: s.subshell().label().to_string(),
1607 binding_energy_ev: s.binding_energy_ev(),
1608 binding_provenance: s.binding_provenance().to_string(),
1609 model: s.table().model().to_string(),
1610 provenance: s.table().provenance().to_string(),
1611 })
1612 .collect(),
1613 })
1614 .collect(),
1615 }
1616 }
1617
1618 /// Run `n_histories` primaries in parallel (on the current rayon pool),
1619 /// bit-identically at any thread count. `new_tally` makes an empty tally;
1620 /// one is made per chunk of `chunk_size` histories.
1621 pub fn run<T, N>(
1622 &self,
1623 seed: u64,
1624 n_histories: u64,
1625 chunk_size: u64,
1626 primary: &Primary,
1627 new_tally: N,
1628 ) -> Result<TransportRun<T>, TransportError>
1629 where
1630 T: ElectronTally,
1631 N: Fn() -> T + Sync,
1632 {
1633 self.run_beam(
1634 seed,
1635 n_histories,
1636 chunk_size,
1637 primary,
1638 &BeamSpread::default(),
1639 new_tally,
1640 )
1641 }
1642
1643 /// [`Self::run`] with a finite beam: each history starts at a position
1644 /// and direction drawn from `beam` ([`BeamSpread`]).
1645 pub fn run_beam<T, N>(
1646 &self,
1647 seed: u64,
1648 n_histories: u64,
1649 chunk_size: u64,
1650 primary: &Primary,
1651 beam: &BeamSpread,
1652 new_tally: N,
1653 ) -> Result<TransportRun<T>, TransportError>
1654 where
1655 T: ElectronTally,
1656 N: Fn() -> T + Sync,
1657 {
1658 self.check_primary(primary)?;
1659 beam.validate()?;
1660 let tally = run_particles(
1661 seed,
1662 n_histories,
1663 chunk_size,
1664 new_tally,
1665 |t, rng, i| {
1666 self.history_beam(t, rng, i, primary, beam)
1667 .expect("primary validated before the run");
1668 },
1669 |total, part| total.merge(part),
1670 );
1671 let mut metadata = self.metadata(seed, n_histories, chunk_size, primary);
1672 metadata.beam = *beam;
1673 Ok(TransportRun { tally, metadata })
1674 }
1675
1676 /// Simulate one primary, and every secondary it leads to, with the given
1677 /// random stream. Returns the primary's fate.
1678 pub fn history<T, R>(
1679 &self,
1680 tally: &mut T,
1681 rng: &mut R,
1682 index: u64,
1683 primary: &Primary,
1684 ) -> Result<Fate, TransportError>
1685 where
1686 T: ElectronTally,
1687 R: Rng,
1688 {
1689 self.history_beam(tally, rng, index, primary, &BeamSpread::default())
1690 }
1691
1692 /// [`Self::history`] for a finite beam: the start position, then the
1693 /// direction, are drawn from `rng` before the transport (no draws for the
1694 /// default [`BeamSpread`]).
1695 pub fn history_beam<T, R>(
1696 &self,
1697 tally: &mut T,
1698 rng: &mut R,
1699 index: u64,
1700 primary: &Primary,
1701 beam: &BeamSpread,
1702 ) -> Result<Fate, TransportError>
1703 where
1704 T: ElectronTally,
1705 R: Rng,
1706 {
1707 let dir = self.check_primary(primary)?;
1708 let [y, z] = beam.sample_position(rng);
1709 let dir = beam.sample_direction(dir, rng)?;
1710 // The beam's lateral landing point is an offset from the
1711 // geometry's entry point, and the start region is the one
1712 // containing the landing point itself.
1713 let entry = self.geometry.entry_point();
1714 let pos = [entry[0], entry[1] + y, entry[2] + z];
1715 let Some(region) = self.geometry.locate(pos) else {
1716 return invalid("beam landing point", "lies outside the target");
1717 };
1718 let material = self.geometry.material_index(region);
1719 let mut st = ElectronState {
1720 pos,
1721 dir,
1722 energy_ev: primary.energy_ev,
1723 layer: material,
1724 };
1725 tally.begin_history(index, &st);
1726 let mut pending: Vec<Pending> = Vec::new();
1727 let fate = match self.enter(tally, rng, &mut st) {
1728 Some(f) => f,
1729 None => self.follow(tally, rng, &mut st, region, 0, &mut pending),
1730 };
1731 while let Some((mut s, region, generation)) = pending.pop() {
1732 tally.begin_secondary(&s, generation);
1733 let f = self.follow(tally, rng, &mut s, region, generation, &mut pending);
1734 tally.end_secondary(f);
1735 }
1736 tally.end_history(index, fate);
1737 Ok(fate)
1738 }
1739
1740 /// With the step barrier, take the primary through the front face from
1741 /// vacuum. Returns its fate if it was reflected.
1742 fn enter<T: ElectronTally, R: Rng>(
1743 &self,
1744 tally: &mut T,
1745 rng: &mut R,
1746 st: &mut ElectronState,
1747 ) -> Option<Fate> {
1748 let (quantum, refraction) = self.step_barrier()?;
1749 let du = self.inner[st.layer];
1750 let boundary = Boundary::Surface(Face::Front);
1751 // The normal energy is positive and the step is up, so the electron
1752 // can always get over: one draw.
1753 let u = uniform(rng);
1754 match cross_step(st.dir, st.energy_ev, du, quantum, refraction, u) {
1755 StepOutcome::Transmitted { dir, energy_ev } => {
1756 st.dir = dir;
1757 st.energy_ev = energy_ev;
1758 tally.barrier(st, boundary, du);
1759 None
1760 }
1761 StepOutcome::Reflected { dir } => {
1762 st.dir = dir;
1763 tally.reflected(st, boundary);
1764 tally.escaped(st, Face::Front);
1765 Some(Fate::Escaped(Face::Front))
1766 }
1767 }
1768 }
1769
1770 fn follow<T: ElectronTally, R: Rng>(
1771 &self,
1772 tally: &mut T,
1773 rng: &mut R,
1774 st: &mut ElectronState,
1775 mut region: usize,
1776 generation: u32,
1777 pending: &mut Vec<Pending>,
1778 ) -> Fate {
1779 let mut events = 0u64;
1780 // Rates and grid positions of the layer's inner-shell channels (an
1781 // empty Vec does not allocate, so a valence-only run pays nothing).
1782 let mut shell_rates: Vec<(f64, GridPos)> = Vec::new();
1783 loop {
1784 if events >= self.config.max_events {
1785 return Fate::EventCap;
1786 }
1787 let tabs = &self.tables[st.layer];
1788 let chans = &self.channels[st.layer];
1789 let (el, el_at) = rate(&tabs.elastic, st.energy_ev);
1790 let (inel, inel_at) = rate(&tabs.inelastic, st.energy_ev);
1791 // Diagnostics only: no draw, no change of state.
1792 tally.table_lookup(
1793 st,
1794 TableChannel::Elastic,
1795 GridCoverage::of(&tabs.elastic, st.energy_ev),
1796 );
1797 tally.table_lookup(
1798 st,
1799 TableChannel::Inelastic,
1800 GridCoverage::of(&tabs.inelastic, st.energy_ev),
1801 );
1802 let (em, ab, tr) = chans.rates(st.energy_ev);
1803 let mut total = el + inel + em + ab + tr;
1804 // Inner-shell channels, after the others (module docs). A
1805 // channel is closed when the primary cannot pay its binding
1806 // energy: the largest loss allowed is `E` without a secondary
1807 // model and `E - E_F` with one (the Fermi-level clamp below).
1808 let shells = &self.shells[st.layer];
1809 shell_rates.clear();
1810 if !shells.is_empty() {
1811 let max_loss = self.max_loss_ev(st);
1812 for s in shells {
1813 let (r, at) = rate(s.table(), st.energy_ev);
1814 let r = if max_loss > s.binding_energy_ev() {
1815 r
1816 } else {
1817 0.0
1818 };
1819 total += r;
1820 shell_rates.push((r, at));
1821 }
1822 }
1823 let mu = st.dir[0];
1824 let free = if total > 0.0 {
1825 -(1.0 - uniform(rng)).ln() / total
1826 } else {
1827 f64::INFINITY
1828 };
1829 // The first event of the flight within the free path (a face
1830 // reached exactly at the free path counts as reached). With an
1831 // unbounded free path the limit is the largest finite length, so
1832 // a face at infinity (the back of a substrate) is no event.
1833 let flight = self
1834 .geometry
1835 .flight(region, st.pos, st.dir, free.min(f64::MAX));
1836 let exit = match flight {
1837 Flight::Event(e) => Some(e),
1838 Flight::Clear { .. } if free.is_infinite() => {
1839 tally.stopped(st);
1840 return Fate::Trapped;
1841 }
1842 Flight::Clear { region: end } => {
1843 region = end;
1844 None
1845 }
1846 };
1847 let from = st.pos;
1848 let Some(exit) = exit else {
1849 for k in 0..3 {
1850 st.pos[k] += free * st.dir[k];
1851 }
1852 tally.step(from, st, free);
1853 events += 1;
1854 let x = uniform(rng) * total;
1855 let base = [el, inel, em, ab, tr];
1856 match choose(x, base.into_iter().chain(shell_rates.iter().map(|r| r.0))) {
1857 0 => {
1858 let u = uniform(rng);
1859 let theta =
1860 sample(&tabs.elastic, el_at, u).clamp(0.0, std::f64::consts::PI);
1861 let phi = std::f64::consts::TAU * uniform(rng);
1862 st.dir = deflect(st.dir, theta, phi);
1863 tally.elastic(st, theta);
1864 }
1865 1 => {
1866 let u = uniform(rng);
1867 let w = sample(&tabs.inelastic, inel_at, u).clamp(0.0, st.energy_ev);
1868 let threshold = self.threshold[st.layer];
1869 match self.config.secondaries {
1870 SecondaryModel::Off => {
1871 st.energy_ev -= w;
1872 tally.inelastic(st, w);
1873 }
1874 SecondaryModel::KieftBosch {
1875 instantaneous_momentum,
1876 momentum_conservation,
1877 } => {
1878 let band =
1879 &self.bands.as_ref().expect("checked in build")[st.layer];
1880 // The primary cannot end below the Fermi level:
1881 // Nebula clamps its loss table to `K - E_F`
1882 // (`kieft_inelastic::create`, commit named in
1883 // `electron::boundary`). The threshold exceeds
1884 // E_F, so the bound is positive.
1885 let w = w.min(st.energy_ev - band.fermi_ev());
1886 let out = kieft_bosch(
1887 instantaneous_momentum,
1888 momentum_conservation,
1889 band,
1890 st.dir,
1891 st.energy_ev,
1892 w,
1893 threshold,
1894 rng,
1895 );
1896 st.energy_ev -= w;
1897 st.dir = out.primary_dir;
1898 tally.inelastic(st, w);
1899 let created = out.secondary.map(|(dir, energy_ev)| ElectronState {
1900 pos: st.pos,
1901 dir,
1902 energy_ev,
1903 layer: st.layer,
1904 });
1905 tally.secondary(st, &out.event, created.as_ref());
1906 if let Some(s) = created {
1907 pending.push((s, region, generation + 1));
1908 }
1909 }
1910 }
1911 if st.energy_ev < threshold {
1912 tally.stopped(st);
1913 return Fate::Stopped;
1914 }
1915 }
1916 k @ (2 | 3) => {
1917 let ph = chans
1918 .phonon
1919 .as_ref()
1920 .expect("a positive phonon rate implies the channel is on");
1921 let hw = ph.hbar_omega_ev();
1922 let (kind, after) = if k == 2 {
1923 (PhononEvent::Emission, st.energy_ev - hw)
1924 } else {
1925 (PhononEvent::Absorption, st.energy_ev + hw)
1926 };
1927 let mu = sample_cos_theta(st.energy_ev, after, uniform(rng));
1928 let theta = mu.acos();
1929 let phi = std::f64::consts::TAU * uniform(rng);
1930 st.dir = deflect(st.dir, theta, phi);
1931 st.energy_ev = after;
1932 tally.phonon(st, kind, hw, theta);
1933 if st.energy_ev < self.threshold[st.layer] {
1934 tally.stopped(st);
1935 return Fate::Stopped;
1936 }
1937 }
1938 4 => {
1939 tally.polaron_trapped(st);
1940 return Fate::PolaronTrapped;
1941 }
1942 k => {
1943 let j = k - 5;
1944 let sh = &shells[j];
1945 let at = shell_rates[j].1;
1946 let b = sh.binding_energy_ev();
1947 let shell = InnerShell::from(sh);
1948 let threshold = self.threshold[st.layer];
1949 // The channel is open, so `b < max_loss`, and the
1950 // loss lies in `[b, max_loss]` (module docs).
1951 let max_loss = self.max_loss_ev(st);
1952 let u = uniform(rng);
1953 let w = sample(sh.table(), at, u).max(b).min(max_loss);
1954 match self.config.secondaries {
1955 SecondaryModel::Off => {
1956 st.energy_ev -= w;
1957 tally.inelastic(st, w);
1958 tally.inner_shell(st, &shell, w);
1959 }
1960 SecondaryModel::KieftBosch {
1961 instantaneous_momentum,
1962 momentum_conservation,
1963 } => {
1964 let band =
1965 &self.bands.as_ref().expect("checked in build")[st.layer];
1966 let out = kieft_bosch_shell(
1967 instantaneous_momentum,
1968 momentum_conservation,
1969 band,
1970 b,
1971 st.dir,
1972 st.energy_ev,
1973 w,
1974 threshold,
1975 rng,
1976 );
1977 st.energy_ev -= w;
1978 st.dir = out.primary_dir;
1979 tally.inelastic(st, w);
1980 tally.inner_shell(st, &shell, w);
1981 let created = out.secondary.map(|(dir, energy_ev)| ElectronState {
1982 pos: st.pos,
1983 dir,
1984 energy_ev,
1985 layer: st.layer,
1986 });
1987 tally.secondary(st, &out.event, created.as_ref());
1988 if let Some(s) = created {
1989 pending.push((s, region, generation + 1));
1990 }
1991 }
1992 }
1993 if st.energy_ev < threshold {
1994 tally.stopped(st);
1995 return Fate::Stopped;
1996 }
1997 }
1998 }
1999 continue;
2000 };
2001 // Reach the face exactly; the path is redrawn afterwards.
2002 let to_face = exit.distance.max(0.0);
2003 st.pos = exit.at;
2004 tally.step(from, st, to_face);
2005 let (to, boundary) = match exit.outcome {
2006 ExitOutcome::Enter { region: to, .. } => {
2007 (Some(to), Boundary::Interface { from: region, to })
2008 }
2009 ExitOutcome::Escape { face, .. } => match face {
2010 Face::Front => (None, Boundary::Surface(Face::Front)),
2011 Face::Side => (None, Boundary::Surface(Face::Side)),
2012 Face::Back if self.config.escape_rule == EscapeRule::BothFaces => {
2013 (None, Boundary::Surface(Face::Back))
2014 }
2015 Face::Back => {
2016 tally.absorbed(st);
2017 return Fate::Absorbed;
2018 }
2019 },
2020 };
2021 let from_material = st.layer;
2022 if let Some((quantum, refraction)) = self.step_barrier() {
2023 let du = to.map_or(0.0, |t| self.inner[self.geometry.material_index(t)])
2024 - self.inner[from_material];
2025 // A draw only if the electron has the normal energy to get
2026 // over (cross_step ignores it otherwise).
2027 let can = st.energy_ev * mu * mu + du > 0.0;
2028 let u = if can { uniform(rng) } else { 1.0 };
2029 match cross_step(st.dir, st.energy_ev, du, quantum, refraction, u) {
2030 StepOutcome::Transmitted { dir, energy_ev } => {
2031 st.dir = dir;
2032 st.energy_ev = energy_ev;
2033 if let Some(t) = to {
2034 region = t;
2035 st.layer = self.geometry.material_index(t);
2036 }
2037 tally.barrier(st, boundary, du);
2038 }
2039 StepOutcome::Reflected { dir } => {
2040 st.dir = dir;
2041 events += 1;
2042 tally.reflected(st, boundary);
2043 continue;
2044 }
2045 }
2046 } else if let Some(t) = to {
2047 region = t;
2048 st.layer = self.geometry.material_index(t);
2049 }
2050 // A periodic crossing re-enters at the opposite face; the
2051 // segment above ended on the face it left through.
2052 if let ExitOutcome::Enter { pos, .. } = exit.outcome {
2053 st.pos = pos;
2054 }
2055 match boundary {
2056 Boundary::Interface { from, to } => {
2057 tally.interface(st, from, to);
2058 if st.energy_ev < self.threshold[st.layer] {
2059 tally.stopped(st);
2060 return Fate::Stopped;
2061 }
2062 }
2063 Boundary::Surface(face) => {
2064 tally.escaped(st, face);
2065 return Fate::Escaped(face);
2066 }
2067 }
2068 }
2069 }
2070}
2071
2072/// The channel for `x = u · total`: the first index whose cumulative rate
2073/// exceeds `x`, skipping zero rates; if rounding leaves `x` at or past the
2074/// sum, the last channel with a positive rate. With only the first two rates
2075/// positive this is `x < el` → elastic, else inelastic, as before the
2076/// insulator and inner-shell channels existed. Only called when the total is
2077/// positive.
2078fn choose(x: f64, rates: impl IntoIterator<Item = f64>) -> usize {
2079 let mut cum = 0.0;
2080 let mut last = 0;
2081 for (k, r) in rates.into_iter().enumerate() {
2082 if r > 0.0 {
2083 cum += r;
2084 last = k;
2085 if x < cum {
2086 return k;
2087 }
2088 }
2089 }
2090 last
2091}
2092
2093/// Where an energy sits on a table's grid: lower row and weight of the upper.
2094#[derive(Clone, Copy)]
2095struct GridPos {
2096 i: usize,
2097 f: f64,
2098}
2099
2100/// Inverse mean free path at `e` (linear in energy, constant beyond the ends)
2101/// and the grid position.
2102fn rate(t: &CrossSectionTable, e: f64) -> (f64, GridPos) {
2103 let g = t.energy_ev();
2104 let r = t.inverse_mfp_per_m();
2105 let n = g.len();
2106 let pos = if e <= g[0] {
2107 GridPos { i: 0, f: 0.0 }
2108 } else if e >= g[n - 1] {
2109 GridPos { i: n - 2, f: 1.0 }
2110 } else {
2111 let i = g.partition_point(|&x| x <= e) - 1;
2112 GridPos {
2113 i,
2114 f: (e - g[i]) / (g[i + 1] - g[i]),
2115 }
2116 };
2117 let v = if pos.f == 0.0 {
2118 r[pos.i]
2119 } else if pos.f == 1.0 {
2120 r[pos.i + 1]
2121 } else {
2122 r[pos.i] + pos.f * (r[pos.i + 1] - r[pos.i])
2123 };
2124 (v, pos)
2125}
2126
2127/// The inverse CDF at probability `u`, interpolated between the bracketing
2128/// rows. Only called where the rate is positive, so one of the rows exists.
2129fn sample(t: &CrossSectionTable, at: GridPos, u: f64) -> f64 {
2130 let row = |i: usize| t.inverse_cdf(i, u).ok();
2131 match (at.f, row(at.i), row(at.i + 1)) {
2132 (f, Some(a), Some(b)) if f > 0.0 && f < 1.0 => a + f * (b - a),
2133 (f, Some(a), _) if f < 1.0 => a,
2134 (_, _, Some(b)) => b,
2135 (_, Some(a), None) => a,
2136 (_, None, None) => unreachable!("a positive rate implies a stored row"),
2137 }
2138}
2139
2140/// Rotate the unit vector `d` by polar angle `theta` about itself and azimuth
2141/// `phi` about `d` (measured from an arbitrary but deterministic reference
2142/// axis), returning a unit vector.
2143fn deflect(d: [f64; 3], theta: f64, phi: f64) -> [f64; 3] {
2144 let (st, ct) = theta.sin_cos();
2145 deflect_cs(d, ct, st, phi)
2146}
2147
2148/// [`deflect`] with the polar angle given by its cosine `ct` and sine `st`.
2149pub(crate) fn deflect_cs(d: [f64; 3], ct: f64, st: f64, phi: f64) -> [f64; 3] {
2150 let helper = if d[0].abs() < 0.9 {
2151 [1.0, 0.0, 0.0]
2152 } else {
2153 [0.0, 1.0, 0.0]
2154 };
2155 let e1 = normalize(cross(helper, d));
2156 let e2 = cross(d, e1);
2157 let (sp, cp) = phi.sin_cos();
2158 normalize([
2159 ct * d[0] + st * (cp * e1[0] + sp * e2[0]),
2160 ct * d[1] + st * (cp * e1[1] + sp * e2[1]),
2161 ct * d[2] + st * (cp * e1[2] + sp * e2[2]),
2162 ])
2163}
2164
2165#[cfg(test)]
2166mod tests {
2167 #[test]
2168 fn beam_offset_helpers_match_the_inline_formulas() {
2169 let tau = std::f64::consts::TAU;
2170 for &(u1, u2) in &[(0.0, 0.0), (0.25, 0.75), (0.5, 0.1), (0.999_999_999, 0.9)] {
2171 // Disc: exact.
2172 let r = 3.0e-6 * f64::sqrt(u1);
2173 let want = [r * (tau * u2).cos(), r * (tau * u2).sin()];
2174 assert_eq!(disc_offset(3.0e-6, u1, u2), want);
2175 // Gaussian: the old exposure form `ln(1 - u1)`, to rounding.
2176 let r = 2.0e-6 * (-2.0 * (1.0 - u1).ln()).sqrt();
2177 let want = [r * (tau * u2).cos(), r * (tau * u2).sin()];
2178 let got = gaussian_offset(2.0e-6, u1, u2);
2179 for k in 0..2 {
2180 assert!((got[k] - want[k]).abs() <= 1e-15 * want[k].abs().max(1e-9));
2181 }
2182 assert!(got.iter().all(|v| v.is_finite()));
2183 }
2184 assert_eq!(gaussian_offset(1.0, 0.0, 0.3), [0.0, 0.0]);
2185 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
2186 assert!(!finite_positive(bad));
2187 }
2188 assert!(finite_positive(1e-12));
2189 }
2190
2191 use super::*;
2192
2193 #[test]
2194 fn deflect_keeps_unit_length_and_angle() {
2195 for d in [
2196 [1.0, 0.0, 0.0],
2197 [0.0, 1.0, 0.0],
2198 normalize([1.0, 2.0, -3.0]),
2199 ] {
2200 for (theta, phi) in [(0.0, 0.0), (0.7, 1.9), (3.0, 5.0)] {
2201 let n = deflect(d, theta, phi);
2202 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
2203 assert!((len - 1.0).abs() < 1e-12);
2204 let dot = n[0] * d[0] + n[1] * d[1] + n[2] * d[2];
2205 assert!((dot - theta.cos()).abs() < 1e-12);
2206 }
2207 }
2208 }
2209}