Skip to main content

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}