Skip to main content

lindhard/tally/
electron.rs

1//! The electron tally: energy deposition, emission yields and spectra, wired
2//! to the electron transport hooks.
3//!
4//! [`FullElectronTally`] implements [`ElectronTally`]. Build one prototype
5//! with [`FullElectronTally::new`] and pass clones to
6//! [`Transport::run`](crate::electron::transport::Transport::run). The
7//! merged tally turns into a plain-data, serializable [`ElectronReport`] with
8//! [`FullElectronTally::report`].
9//!
10//! Units: positions and lengths in metres, energies in eV, angles in radians.
11//! Totals are summed over all histories. Divide by [`ElectronReport::histories`]
12//! for per-primary values (the yields are already per primary).
13//!
14//! # What is tallied
15//!
16//! - **Energy deposition.** An inelastic collision deposits the energy it
17//!   leaves in the solid at the collision point: its whole loss `W` without a
18//!   secondary model, and with one only the part no secondary carries away
19//!   (see [the energy balance](#the-energy-balance)). An electron whose
20//!   energy falls below the stopping threshold of its layer deposits what it
21//!   has left at the point where it stopped. Both go into a per-layer total
22//!   and into optional 3D grids: a
23//!   Cartesian `x`-`y`-`z` grid ([`CartesianGrid`]) and a cylindrical `r`-`x`
24//!   grid about the beam axis ([`CylindricalGrid`]). Energy deposited outside
25//!   a grid is kept in that grid's `outside_ev`, so every grid sums to the
26//!   total deposited energy. Placing the loss at the collision point and the
27//!   sub-threshold remainder at the stopping point is this crate's bookkeeping
28//!   convention for a point-collision transport loop, not a physics model.
29//!   The same deposits optionally feed a radial profile in one depth slab,
30//!   on log radial bins and with per-history errors
31//!   ([`ElectronTallyConfig::psf`], [`super::psf`]), which is the input of
32//!   the point-spread-function fits.
33//! - **Ionisation and lattice channels.** Every deposit is also booked to
34//!   one of two channels, per layer and per cell of both grids, see
35//!   [the channel split](#the-ionisation-and-lattice-channels).
36//! - **Emission.** Every electron the `escaped` hook reports is counted at its
37//!   face, with its energy and its polar angle from the outward surface
38//!   normal. It is classed as *slow* (`E < split`, the secondary-electron
39//!   class) or *fast* (`E >= split`, the backscattered class), see
40//!   [the split convention](#the-sebse-split). The front-face counts per
41//!   primary are the backscatter yield `η` (fast) and the secondary yield `δ`
42//!   (slow). The energy spectrum and the polar-angle spectrum (one per class)
43//!   are histograms per face.
44//! - **Generation-volume moments.** Energy-weighted mean and standard
45//!   deviation of the deposition position along `x`, `y`, `z` and of the
46//!   radial distance from the beam axis (the line `y = z = 0` through the
47//!   entry point), plus the energy-weighted RMS radius. See
48//!   [`GenerationVolume`].
49//! - **Stopping points.** Unweighted moments of the depth and radial distance
50//!   of electrons that fell below the stopping threshold (an electron range
51//!   distribution), and the same moments for the primaries alone
52//!   ([`PrimaryStoppingPoints`]: the penetration depth of stopped primaries).
53//! - **Energy balance.** The incident energy plus the Fermi-sea source equals
54//!   the deposited, escaped, trapped and barrier terms, see
55//!   [the energy balance](#the-energy-balance) and [`ElectronEnergyBudget`].
56//! - **Table coverage.** A numerical diagnostic, not a physical result: per
57//!   layer and channel, how many rate evaluations of the transport read the
58//!   cross-section table inside its energy grid and how many used the
59//!   constant continuation below or above it
60//!   ([`super::table_coverage`], [`ElectronReport::table_coverage`]). These
61//!   are rate evaluations, not collisions.
62//! - **Event-cap counts.** Another numerical diagnostic: how many secondary
63//!   tracks the collision cap cut off, and how many primary histories had
64//!   any electron (the primary or a secondary) cut off by it
65//!   ([`EventCapCounts`], [`ElectronReport::event_caps`]). The primary's own
66//!   cap stays in [`FateCounts::event_capped`]; zero there does not mean the
67//!   secondary cascades completed.
68//!
69//! # The SE/BSE split
70//!
71//! An electron leaving the target with energy below 50 eV is by convention a
72//! secondary electron and one at higher energy a backscattered electron:
73//! H. Chen, Y. Zou, S. Mao, M. S. S. Khan, K. Tőkési and Z. J. Ding,
74//! "Influence of energy loss function to the Monte Carlo simulated electron
75//! backscattering coefficient", Sci. Rep. 12, 18201 (2022),
76//! doi:10.1038/s41598-022-20466-3 (open access), Introduction ("These
77//! electrons, with their energies higher than 50 eV, are defined as
78//! backscattered electrons; while the lower energy electrons are the excited
79//! secondary electrons", citing H. Niedrig, Scanning 1, 17 (1978)) and section
80//! "Monte Carlo simulation" ("an escaped electron is counted either as a true
81//! secondary electron (< 50 eV) or a backscattered electron (> 50 eV)"). The
82//! class depends only on the energy, not on whether the electron is a primary
83//! or a secondary.
84//!
85//! That paper leaves `E = 50 eV` itself unassigned. This tally puts it in the
86//! fast (backscattered) class, as Nebula's detector flags do: `DETECTOR_LT50`
87//! ("if energy < 50 eV") and `DETECTOR_GE50` ("if energy >= 50 eV") in
88//! `source/core/cpu_material_manager.h`, Nebula commit `a50a8e8`
89//! (<https://github.com/Nebula-simulator/Nebula>, BSD-3-Clause). Only the
90//! convention is taken from there; no code is ported.
91//!
92//! The split is configurable ([`ElectronTallyConfig::se_bse_split_ev`],
93//! default [`SE_BSE_SPLIT_EV`]) and is recorded, with its source, in
94//! [`ElectronTallyMetadata`].
95//!
96//! # Event kinds and how later channels slot in
97//!
98//! The tally reads only the hooks of [`ElectronTally`] and does not depend on
99//! how many electrons a history contains:
100//!
101//! - Emission is counted per `escaped` call, so secondaries that escape are
102//!   counted with the primaries, as the convention requires.
103//! - `stopped` is classified by energy against the stopping threshold of the
104//!   electron's layer ([`Transport::stopping_thresholds_ev`]: the cutoff, or
105//!   `U + cutoff` under
106//!   [`CutoffReference::VacuumLevel`](crate::electron::transport::CutoffReference::VacuumLevel)):
107//!   below it the electron stopped and its energy is a deposit, at or above
108//!   it the electron is trapped (the loop calls `stopped` for
109//!   [`Fate::Trapped`] too, and a trapped electron always has at least the
110//!   threshold energy, because the loop checks the threshold after every
111//!   energy change, a primary starts above it and a secondary below it is
112//!   not created).
113//! - `absorbed` (the back face under
114//!   [`EscapeRule::FrontOnly`](crate::electron::transport::EscapeRule::FrontOnly))
115//!   counts as trapped energy.
116//! - [`Fate::EventCap`] has no hook of its own, so the energy of the last
117//!   state seen for the capped electron is counted as trapped: at
118//!   `end_secondary` for a secondary, at `end_history` for the primary. The
119//!   last state is kept per electron; the primary's is set aside when the
120//!   first secondary begins, so a secondary cannot overwrite it. Each capped
121//!   secondary is also counted once in [`EventCapCounts::secondary_tracks`],
122//!   and a history with any capped electron once in
123//!   [`EventCapCounts::affected_histories`]; these counts do not touch the
124//!   energy accounting.
125//!
126//! # The energy balance
127//!
128//! Inside a layer, kinetic energies are measured from the band bottom, and
129//! with [`BoundaryModel::StepBarrier`](crate::electron::transport::BoundaryModel::StepBarrier)
130//! an electron gains or loses the step `ΔU = U' - U` in inner potential at
131//! every face it gets through ([`crate::electron::boundary`]; `U = E_F + Φ`
132//! in a metal, Verduin's thesis Eq. 3.136, cited there). The primary's
133//! incident energy is its energy where it starts, its vacuum energy with the
134//! step barrier. Summed over all histories:
135//!
136//! ```text
137//! incident + fermi_sea = deposited + escaped + trapped + barrier
138//! ```
139//!
140//! - `barrier` is the sum of `-ΔU` over every face transmission (the
141//!   `barrier` hook): kinetic energy taken by the potential steps. It is
142//!   `-U` for a primary that enters (its vacuum energy becomes `E + U`
143//!   inside), `+U` for any electron that leaves through a surface, and
144//!   `-(U' - U)` at an interface. A primary reflected at entry changes
145//!   nothing and is counted as escaped with its incident energy.
146//! - With a secondary model, an inelastic event takes `W` from the electron
147//!   and liberates, if anything, an electron of kinetic energy
148//!   `E_SE = E_F + W - B` (Verduin Eq. 3.86, see
149//!   [`crate::electron::secondary`]), which the tally follows like any other
150//!   electron until it stops, escapes or is trapped. Per event
151//!   ([`SecondaryEvent`]) `W = secondary + binding + deposited`, with
152//!   `binding = B - E_F`. Only `deposited`, plus `binding` where it is
153//!   positive (an initial state below the band bottom), stays in the solid
154//!   at the event and is deposited there. Where `binding` is negative (a
155//!   conduction electron, as in a metal, which already had `E_F - B` above
156//!   the band bottom), the liberated electron brings `-binding` of its own:
157//!   that is the `fermi_sea` source term. So `W + fermi_sea = secondary +
158//!   deposit` event by event, and the secondary's energy is counted once,
159//!   where it ends.
160//!
161//! - With the opt-in insulator channels of a layer
162//!   ([`crate::electron::phonon`]), an LO-phonon emission gives `ħω` to the
163//!   lattice (deposited where it happens), an absorption takes `ħω` from it
164//!   (the `phonon_absorbed` source, added to the left side of the balance),
165//!   and an electron trapped as a polaron deposits its whole remaining energy
166//!   where it is trapped (the `polaron` deposit). The balance is then
167//!   `incident + fermi_sea + phonon_absorbed = deposited + escaped + trapped +
168//!   barrier`, with `deposited` including `phonon_emitted` and `polaron`.
169//!   Polaron traps are not counted in the stopping points, which are
170//!   electrons that fell below the stopping threshold.
171//!
172//! Without a secondary model and without the step barrier, `fermi_sea` and
173//! `barrier` are zero and the balance is `incident = deposited + escaped +
174//! trapped`.
175//!
176//! ## Deposits and the energy imparted
177//!
178//! Deposits (`deposited`, the per-layer totals and every grid cell) are
179//! measured from the band bottom. With a secondary model in a layer with a
180//! Fermi energy, a liberated conduction electron's kinetic energy includes
181//! the `-binding` it already had (the `fermi_sea` source), and wherever that
182//! electron stops, its whole remaining energy above the band bottom is
183//! deposited. So deposits include Fermi-sea energy the beam never supplied,
184//! and `deposited` can exceed the energy the beam imparted to the target.
185//! Moving the sources of the balance above to the right side gives the net
186//! energy imparted:
187//!
188//! ```text
189//! incident - escaped = deposited + trapped + barrier - fermi_sea - phonon_absorbed
190//! ```
191//!
192//! Without a secondary model and without the insulator channels the two
193//! source terms are zero.
194//!
195//! # The ionisation and lattice channels
196//!
197//! The kind of a deposit is known at the call site and carried with it, not
198//! reconstructed from totals. Two channels, with `ionisation + lattice =
199//! deposited` per layer, per grid cell and in the budget (to floating-point
200//! rounding; each is its own accumulator):
201//!
202//! - **Ionisation** (electronic excitation, the source of electron-hole
203//!   pairs): the part of an inelastic event left in the solid (`W` without a
204//!   secondary model; `deposited + max(binding, 0)` with one, from the
205//!   `secondary` hook), and the **residual** energy of electrons that fell
206//!   below the stopping threshold ([`ElectronEnergyBudget::residual_ev`]).
207//! - **Lattice**: LO-phonon emission (`phonon_emitted`) and the whole
208//!   remaining energy of an electron trapped as a polaron (`polaron`).
209//!
210//! The residual is the cutoff-dependent term. It is placed in the ionisation
211//! channel by convention: a sub-cutoff electron is below the range where this
212//! engine follows collisions, and its energy goes into further electronic
213//! excitation as it thermalises. This is a bookkeeping convention, not a
214//! computed split; the lattice channel is the lower bound of heating, and the
215//! ionisation channel includes the residual and, like all deposits, Fermi-sea
216//! energy (see [the energy imparted](#deposits-and-the-energy-imparted)).
217//! Elastic nuclear recoil energy is not tallied by the electron engine and is
218//! in neither channel.
219//!
220//! # Determinism
221//!
222//! Counts are integers, histograms have integer bins, and the grids, totals
223//! and weighted moments are `f64` accumulators merged field by field in the
224//! order [`crate::rng::run_particles`] calls `merge` (chunk order). For a
225//! fixed `chunk_size` the report is bit-identical at any thread count
226//! (`lindhard/tests/electron_tally.rs`). Nothing is stored in a hash map.
227
228use std::collections::BTreeMap;
229
230use serde::{Deserialize, Serialize};
231
232use super::dose::{DoseAcc, DoseConfig, DoseError, DoseReport};
233use super::exposure::{ExposureAccumulator, ExposureConfig, ExposureError, ExposureReport};
234use super::hist::{Binning, Histogram};
235use super::moments::{MomentSummary, Moments};
236use super::psf::{PsfConfig, PsfError, RadialAccumulator, RadialProfile};
237use super::table_coverage::{LayerTableCoverage, TableCoverageTally};
238use super::voxel::{VoxelCell, VoxelRecord, VoxelReport, VoxelTallyGrid};
239use crate::electron::secondary::{SecondaryEvent, SecondaryModel};
240use crate::electron::transport::{
241    Boundary, ElectronState, ElectronTally, Face, Fate, GridCoverage, PhononEvent, TableChannel,
242    Transport,
243};
244use crate::geometry::Geometry;
245
246/// Default SE/BSE energy split, eV: an escaping electron below this energy is
247/// in the secondary (slow) class, at or above it in the backscattered (fast)
248/// class. Chen et al., Sci. Rep. 12, 18201 (2022),
249/// doi:10.1038/s41598-022-20466-3, Introduction and section "Monte Carlo
250/// simulation"; the assignment of the boundary itself follows Nebula's
251/// `DETECTOR_GE50` flag. See [the module docs](self#the-sebse-split).
252pub const SE_BSE_SPLIT_EV: f64 = 50.0;
253
254/// The source of the default split, as recorded in [`ElectronTallyMetadata`].
255pub const SE_BSE_SPLIT_SOURCE: &str = "Chen, Zou, Mao, Khan, Tokesi and Ding, Sci. Rep. 12, 18201 \
256     (2022), doi:10.1038/s41598-022-20466-3 (Introduction; section Monte Carlo simulation), citing \
257     Niedrig, Scanning 1, 17 (1978); E = split is counted as backscattered, as Nebula's \
258     DETECTOR_GE50 flag (source/core/cpu_material_manager.h, commit a50a8e8) does";
259
260/// The classification rule, as recorded in [`ElectronTallyMetadata`].
261pub const SE_BSE_SPLIT_RULE: &str =
262    "slow (secondary) if E < split; fast (backscattered) if E >= split; E as reported by the escaped hook";
263
264/// Errors from building a [`FullElectronTally`].
265#[derive(Debug, Clone, PartialEq, thiserror::Error)]
266pub enum ElectronTallyError {
267    /// The SE/BSE split is not finite and non-negative.
268    #[error("invalid SE/BSE split {0} eV: must be finite and non-negative")]
269    Split(f64),
270    /// The radial binning of a cylindrical grid starts below `r = 0`.
271    #[error("the cylindrical grid's radial bins must start at r >= 0, got lo = {0} m")]
272    NegativeRadius(f64),
273    /// The escape polar-angle binning is not within `[0, π]`.
274    #[error("the escape polar-angle bins must lie in [0, pi], got [{lo}, {hi})")]
275    PolarRange {
276        /// Lower edge.
277        lo: f64,
278        /// Upper edge.
279        hi: f64,
280    },
281    /// A grid has more cells than fit in memory addressing.
282    #[error("the {0} grid has too many cells")]
283    GridTooLarge(&'static str),
284    /// The radial-profile (PSF) configuration is invalid.
285    #[error("invalid PSF configuration: {0}")]
286    Psf(#[from] PsfError),
287    /// The dose / depth-dose / transmission configuration is invalid.
288    #[error(transparent)]
289    Dose(#[from] DoseError),
290    /// The exposure settings are invalid.
291    #[error(transparent)]
292    Exposure(#[from] ExposureError),
293}
294
295/// A Cartesian deposition grid over `x` (depth), `y` and `z`, m.
296#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
297pub struct CartesianGrid {
298    /// Depth bins, m.
299    pub x: Binning,
300    /// Lateral `y` bins, m.
301    pub y: Binning,
302    /// Lateral `z` bins, m.
303    pub z: Binning,
304}
305
306impl CartesianGrid {
307    /// Number of voxels, or `None` on overflow.
308    pub fn voxels(&self) -> Option<usize> {
309        self.x
310            .bins
311            .checked_mul(self.y.bins)?
312            .checked_mul(self.z.bins)
313    }
314
315    /// Flat index of voxel `(ix, iy, iz)`: `(ix * ny + iy) * nz + iz`.
316    pub fn index(&self, ix: usize, iy: usize, iz: usize) -> usize {
317        (ix * self.y.bins + iy) * self.z.bins + iz
318    }
319
320    /// Volume of one voxel, m³.
321    pub fn voxel_volume_m3(&self) -> f64 {
322        self.x.width() * self.y.width() * self.z.width()
323    }
324
325    fn locate(&self, pos: [f64; 3]) -> Option<usize> {
326        let ix = self.x.locate(pos[0]).ok()?;
327        let iy = self.y.locate(pos[1]).ok()?;
328        let iz = self.z.locate(pos[2]).ok()?;
329        Some(self.index(ix, iy, iz))
330    }
331}
332
333/// A cylindrical deposition grid about the beam axis (the line `y = z = 0`
334/// through the entry point, along `x`): radial distance `r = (y² + z²)^(1/2)`
335/// and depth `x`, m.
336#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
337pub struct CylindricalGrid {
338    /// Radial bins, m; `r.lo` must be at least 0.
339    pub r: Binning,
340    /// Depth bins, m.
341    pub depth: Binning,
342}
343
344impl CylindricalGrid {
345    /// Number of cells, or `None` on overflow.
346    pub fn cells(&self) -> Option<usize> {
347        self.r.bins.checked_mul(self.depth.bins)
348    }
349
350    /// Flat index of cell `(ir, ix)`: `ir * n_depth + ix`.
351    pub fn index(&self, ir: usize, ix: usize) -> usize {
352        ir * self.depth.bins + ix
353    }
354
355    /// Volume of a cell in radial bin `ir` (an annulus times a depth bin), m³.
356    pub fn cell_volume_m3(&self, ir: usize) -> f64 {
357        let (a, b) = (self.r.edge(ir), self.r.edge(ir + 1));
358        std::f64::consts::PI * (b * b - a * a) * self.depth.width()
359    }
360
361    fn locate(&self, pos: [f64; 3]) -> Option<usize> {
362        let ir = self.r.locate(pos[1].hypot(pos[2])).ok()?;
363        let ix = self.depth.locate(pos[0]).ok()?;
364        Some(self.index(ir, ix))
365    }
366}
367
368/// Settings of a [`FullElectronTally`].
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370pub struct ElectronTallyConfig {
371    /// Optional Cartesian deposition grid.
372    pub cartesian: Option<CartesianGrid>,
373    /// Optional cylindrical `r`-`x` deposition grid.
374    pub cylindrical: Option<CylindricalGrid>,
375    /// Energy of escaping electrons, eV.
376    pub escape_energy: Binning,
377    /// Polar angle of escaping electrons from the outward surface normal, rad
378    /// (`0` along the normal, `π/2` grazing). Must lie within `[0, π]`.
379    pub escape_polar: Binning,
380    /// SE/BSE split, eV: escaping electrons below it are slow (secondary),
381    /// at or above it fast (backscattered). See
382    /// [the module docs](self#the-sebse-split).
383    pub se_bse_split_ev: f64,
384    /// Optional radial profile in a depth slab, log radial bins, with
385    /// per-history errors: the input of the PSF fits of [`super::psf`].
386    #[serde(default)]
387    pub psf: Option<PsfConfig>,
388    /// Optional dose per layer, depth-dose profile and interface
389    /// transmission ([`super::dose`]).
390    #[serde(default)]
391    pub dose: Option<DoseConfig>,
392    /// Optional exposure statistics: the per-electron deposit in target
393    /// volumes and the compound-Poisson distribution of a finite exposure
394    /// ([`super::exposure`]).
395    #[serde(default)]
396    pub exposure: Option<ExposureConfig>,
397}
398
399impl ElectronTallyConfig {
400    /// Spectra with the given binnings, no deposition grids, no PSF profile,
401    /// and the [`SE_BSE_SPLIT_EV`] split.
402    pub fn new(escape_energy: Binning, escape_polar: Binning) -> Self {
403        Self {
404            cartesian: None,
405            cylindrical: None,
406            escape_energy,
407            escape_polar,
408            se_bse_split_ev: SE_BSE_SPLIT_EV,
409            psf: None,
410            dose: None,
411            exposure: None,
412        }
413    }
414}
415
416/// Energy-weighted mean and centred second moment of one coordinate,
417/// mergeable. The pairwise update is that of T. F. Chan, G. H. Golub and
418/// R. J. LeVeque, Stanford report STAN-CS-79-773 (1979) (the update also used
419/// by [`super::moments`]), with the counts replaced by weights, which is the
420/// weighted form of D. H. D. West, "Updating mean and variance estimates: an
421/// improved method", Commun. ACM 22, 532 (1979), doi:10.1145/359146.359153.
422/// With `W = W_a + W_b` and `δ = mean_b - mean_a`:
423/// `mean = mean_a + δ W_b / W`, `M2 = M2_a + M2_b + δ² W_a W_b / W`.
424#[derive(Debug, Clone, Copy, PartialEq, Default)]
425struct WeightedMoments {
426    weight: f64,
427    mean: f64,
428    m2: f64,
429}
430
431impl WeightedMoments {
432    fn push(&mut self, x: f64, w: f64) {
433        self.merge_parts(w, x, 0.0);
434    }
435
436    fn merge(&mut self, o: &WeightedMoments) {
437        self.merge_parts(o.weight, o.mean, o.m2);
438    }
439
440    fn merge_parts(&mut self, w_b: f64, mean_b: f64, m2_b: f64) {
441        if w_b <= 0.0 {
442            return;
443        }
444        if self.weight <= 0.0 {
445            *self = Self {
446                weight: w_b,
447                mean: mean_b,
448                m2: m2_b,
449            };
450            return;
451        }
452        let w = self.weight + w_b;
453        let d = mean_b - self.mean;
454        self.m2 += m2_b + d * d * self.weight * w_b / w;
455        self.mean += d * w_b / w;
456        self.weight = w;
457    }
458
459    fn variance(&self) -> f64 {
460        if self.weight > 0.0 {
461            (self.m2 / self.weight).max(0.0)
462        } else {
463            0.0
464        }
465    }
466}
467
468/// Energy sums, eV.
469#[derive(Debug, Clone, Copy, PartialEq, Default)]
470struct Budget {
471    incident: f64,
472    inelastic: f64,
473    residual: f64,
474    escaped_front: f64,
475    escaped_back: f64,
476    escaped_side: f64,
477    no_interaction: f64,
478    absorbed: f64,
479    event_cap: f64,
480    fermi_sea: f64,
481    barrier: f64,
482    phonon_emitted: f64,
483    phonon_absorbed: f64,
484    polaron: f64,
485}
486
487impl Budget {
488    fn merge(&mut self, o: &Budget) {
489        self.incident += o.incident;
490        self.inelastic += o.inelastic;
491        self.residual += o.residual;
492        self.escaped_front += o.escaped_front;
493        self.escaped_back += o.escaped_back;
494        self.escaped_side += o.escaped_side;
495        self.no_interaction += o.no_interaction;
496        self.absorbed += o.absorbed;
497        self.event_cap += o.event_cap;
498        self.fermi_sea += o.fermi_sea;
499        self.barrier += o.barrier;
500        self.phonon_emitted += o.phonon_emitted;
501        self.phonon_absorbed += o.phonon_absorbed;
502        self.polaron += o.polaron;
503    }
504
505    fn report(&self) -> ElectronEnergyBudget {
506        let deposited = self.inelastic + self.residual + self.phonon_emitted + self.polaron;
507        let escaped = self.escaped_front + self.escaped_back + self.escaped_side;
508        let trapped = self.no_interaction + self.absorbed + self.event_cap;
509        let source = self.incident + self.fermi_sea + self.phonon_absorbed;
510        let sink = deposited + escaped + trapped + self.barrier;
511        let relative_imbalance = if source > 0.0 {
512            (sink - source).abs() / source
513        } else {
514            0.0
515        };
516        ElectronEnergyBudget {
517            incident_ev: self.incident,
518            deposited_ev: deposited,
519            escaped_ev: escaped,
520            trapped_ev: trapped,
521            inelastic_ev: self.inelastic,
522            residual_ev: self.residual,
523            escaped_front_ev: self.escaped_front,
524            escaped_back_ev: self.escaped_back,
525            escaped_side_ev: self.escaped_side,
526            no_interaction_ev: self.no_interaction,
527            absorbed_ev: self.absorbed,
528            event_cap_ev: self.event_cap,
529            fermi_sea_ev: self.fermi_sea,
530            barrier_ev: self.barrier,
531            phonon_emitted_ev: self.phonon_emitted,
532            phonon_absorbed_ev: self.phonon_absorbed,
533            polaron_ev: self.polaron,
534            ionisation_ev: self.inelastic + self.residual,
535            lattice_ev: self.phonon_emitted + self.polaron,
536            relative_imbalance,
537        }
538    }
539}
540
541/// The channel a deposit is booked to (see
542/// [the module docs](self#the-ionisation-and-lattice-channels)).
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544enum Channel {
545    Ionisation,
546    Lattice,
547}
548
549/// One accumulator per channel; the plain total is kept alongside so the
550/// existing total maps are unchanged.
551#[derive(Debug, Clone, PartialEq)]
552struct GridAcc {
553    energy_ev: Vec<f64>,
554    ionisation_ev: Vec<f64>,
555    lattice_ev: Vec<f64>,
556    outside_ev: f64,
557    outside_ionisation_ev: f64,
558    outside_lattice_ev: f64,
559}
560
561impl GridAcc {
562    fn new(cells: usize) -> Self {
563        Self {
564            energy_ev: vec![0.0; cells],
565            ionisation_ev: vec![0.0; cells],
566            lattice_ev: vec![0.0; cells],
567            outside_ev: 0.0,
568            outside_ionisation_ev: 0.0,
569            outside_lattice_ev: 0.0,
570        }
571    }
572
573    fn add(&mut self, cell: Option<usize>, e: f64, channel: Channel) {
574        match cell {
575            Some(i) => {
576                self.energy_ev[i] += e;
577                match channel {
578                    Channel::Ionisation => self.ionisation_ev[i] += e,
579                    Channel::Lattice => self.lattice_ev[i] += e,
580                }
581            }
582            None => {
583                self.outside_ev += e;
584                match channel {
585                    Channel::Ionisation => self.outside_ionisation_ev += e,
586                    Channel::Lattice => self.outside_lattice_ev += e,
587                }
588            }
589        }
590    }
591
592    fn merge(&mut self, o: &GridAcc) {
593        for (a, b) in self.energy_ev.iter_mut().zip(&o.energy_ev) {
594            *a += b;
595        }
596        for (a, b) in self.ionisation_ev.iter_mut().zip(&o.ionisation_ev) {
597            *a += b;
598        }
599        for (a, b) in self.lattice_ev.iter_mut().zip(&o.lattice_ev) {
600            *a += b;
601        }
602        self.outside_ev += o.outside_ev;
603        self.outside_ionisation_ev += o.outside_ionisation_ev;
604        self.outside_lattice_ev += o.outside_lattice_ev;
605    }
606}
607
608#[derive(Debug, Clone, PartialEq)]
609struct ClassAcc {
610    count: u64,
611    energy_ev: f64,
612    polar_hist: Histogram,
613}
614
615impl ClassAcc {
616    fn new(c: &ElectronTallyConfig) -> Self {
617        Self {
618            count: 0,
619            energy_ev: 0.0,
620            polar_hist: Histogram::new(c.escape_polar),
621        }
622    }
623
624    fn merge(&mut self, o: &ClassAcc) {
625        self.count += o.count;
626        self.energy_ev += o.energy_ev;
627        self.polar_hist.merge(&o.polar_hist);
628    }
629
630    fn report(&self, histories: u64) -> EmissionClass {
631        EmissionClass {
632            count: self.count,
633            per_primary: per(self.count as f64, histories),
634            energy_ev: self.energy_ev,
635            mean_energy_ev: per(self.energy_ev, self.count),
636            polar_histogram: self.polar_hist.clone(),
637        }
638    }
639}
640
641#[derive(Debug, Clone, PartialEq)]
642struct FaceAcc {
643    slow: ClassAcc,
644    fast: ClassAcc,
645    energy_hist: Histogram,
646}
647
648impl FaceAcc {
649    fn new(c: &ElectronTallyConfig) -> Self {
650        Self {
651            slow: ClassAcc::new(c),
652            fast: ClassAcc::new(c),
653            energy_hist: Histogram::new(c.escape_energy),
654        }
655    }
656
657    fn merge(&mut self, o: &FaceAcc) {
658        self.slow.merge(&o.slow);
659        self.fast.merge(&o.fast);
660        self.energy_hist.merge(&o.energy_hist);
661    }
662
663    fn report(&self, histories: u64) -> FaceEmission {
664        let count = self.slow.count + self.fast.count;
665        FaceEmission {
666            count,
667            per_primary: per(count as f64, histories),
668            energy_ev: self.slow.energy_ev + self.fast.energy_ev,
669            energy_histogram: self.energy_hist.clone(),
670            slow: self.slow.report(histories),
671            fast: self.fast.report(histories),
672        }
673    }
674}
675
676/// Which [`VoxelCell`] field a deposit goes to.
677#[derive(Debug, Clone, Copy)]
678enum DepositKind {
679    /// Left at an inelastic event: `electronic_ev`.
680    Electronic,
681    /// Given to the lattice (LO-phonon emission, polaron trapping):
682    /// `lattice_ev`.
683    Lattice,
684    /// The energy of an electron that fell below the stopping threshold:
685    /// `rest_ev`.
686    Rest,
687}
688
689impl DepositKind {
690    /// The ionisation/lattice channel this deposit is booked to. The
691    /// sub-cutoff residual goes to ionisation by convention.
692    fn channel(self) -> Channel {
693        match self {
694            DepositKind::Electronic | DepositKind::Rest => Channel::Ionisation,
695            DepositKind::Lattice => Channel::Lattice,
696        }
697    }
698}
699
700/// Sparse per-voxel energy deposition on a [`VoxelTallyGrid`], keyed on the
701/// flat voxel index (`BTreeMap`, so merging and export follow one fixed
702/// order).
703#[derive(Debug, Clone, PartialEq)]
704struct VoxelAcc {
705    grid: VoxelTallyGrid,
706    cells: BTreeMap<u64, VoxelCell>,
707    outside: VoxelCell,
708}
709
710impl VoxelAcc {
711    fn new(grid: VoxelTallyGrid) -> Self {
712        Self {
713            grid,
714            cells: BTreeMap::new(),
715            outside: VoxelCell::default(),
716        }
717    }
718
719    fn add(&mut self, pos: [f64; 3], e: f64, kind: DepositKind) {
720        let cell = match self.grid.locate(pos) {
721            Some(ijk) => self.cells.entry(self.grid.flat_index(ijk)).or_default(),
722            None => &mut self.outside,
723        };
724        match kind {
725            DepositKind::Electronic => cell.electronic_ev += e,
726            DepositKind::Lattice => cell.lattice_ev += e,
727            DepositKind::Rest => cell.rest_ev += e,
728        }
729    }
730
731    fn merge(&mut self, o: &VoxelAcc) {
732        assert!(
733            self.grid == o.grid,
734            "merging electron tallies built on different voxel grids"
735        );
736        self.outside.add(&o.outside);
737        for (&flat, cell) in &o.cells {
738            self.cells.entry(flat).or_default().add(cell);
739        }
740    }
741}
742
743/// Electron counts at one place, split by the generation of the electron:
744/// charge in units of the electron's, per [`ChargeReport`]'s convention.
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
746pub struct ChargeCounts {
747    /// Primaries that terminated here (every terminal fate but escape).
748    pub stopped_primary: u64,
749    /// Secondaries that terminated here, including a liberated electron
750    /// dropped at its creation point because it was below the stopping
751    /// threshold.
752    pub stopped_secondary: u64,
753    /// Holes (liberated electrons) created here by collisions of a primary.
754    pub created_by_primary: u64,
755    /// Holes created here by collisions of a secondary.
756    pub created_by_secondary: u64,
757}
758
759impl ChargeCounts {
760    /// Electrons that terminated here.
761    pub fn stopped(&self) -> u64 {
762        self.stopped_primary + self.stopped_secondary
763    }
764
765    /// Electrons liberated here (holes left behind).
766    pub fn created(&self) -> u64 {
767        self.created_by_primary + self.created_by_secondary
768    }
769
770    /// Net electron number gained here: stopped minus created.
771    pub fn net(&self) -> i64 {
772        self.stopped() as i64 - self.created() as i64
773    }
774
775    fn merge(&mut self, o: &ChargeCounts) {
776        self.stopped_primary += o.stopped_primary;
777        self.stopped_secondary += o.stopped_secondary;
778        self.created_by_primary += o.created_by_primary;
779        self.created_by_secondary += o.created_by_secondary;
780    }
781}
782
783/// Charge counts per cell of one grid, with the per-history net charge
784/// squared and summed for the standard error. Everything is integer, so the
785/// merge is exact and independent of the order of the parts.
786#[derive(Debug, Clone, PartialEq)]
787struct ChargeGrid {
788    counts: Vec<ChargeCounts>,
789    /// Sum over histories of the squared net charge the history left in the
790    /// cell.
791    net_sum_sq: Vec<u64>,
792    outside: ChargeCounts,
793    outside_net_sum_sq: u64,
794    /// Per-history scratch: `(cell, net change)`; `usize::MAX` is outside.
795    touched: Vec<(usize, i64)>,
796}
797
798impl ChargeGrid {
799    fn new(cells: usize) -> Self {
800        Self {
801            counts: vec![ChargeCounts::default(); cells],
802            net_sum_sq: vec![0; cells],
803            outside: ChargeCounts::default(),
804            outside_net_sum_sq: 0,
805            touched: Vec::new(),
806        }
807    }
808
809    /// An electron terminated in `cell`.
810    fn stop(&mut self, cell: Option<usize>, secondary: bool) {
811        let c = self.slot(cell, 1);
812        if secondary {
813            c.stopped_secondary += 1;
814        } else {
815            c.stopped_primary += 1;
816        }
817    }
818
819    /// A hole was created in `cell` by an electron of the given generation.
820    fn create(&mut self, cell: Option<usize>, by_secondary: bool) {
821        let c = self.slot(cell, -1);
822        if by_secondary {
823            c.created_by_secondary += 1;
824        } else {
825            c.created_by_primary += 1;
826        }
827    }
828
829    fn slot(&mut self, cell: Option<usize>, delta: i64) -> &mut ChargeCounts {
830        self.touched.push((cell.unwrap_or(usize::MAX), delta));
831        match cell {
832            Some(i) => &mut self.counts[i],
833            None => &mut self.outside,
834        }
835    }
836
837    /// Fold the history's per-cell net charge into the squared sums.
838    fn end_history(&mut self) {
839        self.touched.sort_unstable_by_key(|t| t.0);
840        let mut i = 0;
841        while i < self.touched.len() {
842            let cell = self.touched[i].0;
843            let mut net = 0i64;
844            while i < self.touched.len() && self.touched[i].0 == cell {
845                net += self.touched[i].1;
846                i += 1;
847            }
848            let sq = (net * net) as u64;
849            if cell == usize::MAX {
850                self.outside_net_sum_sq += sq;
851            } else {
852                self.net_sum_sq[cell] += sq;
853            }
854        }
855        self.touched.clear();
856    }
857
858    fn merge(&mut self, o: &ChargeGrid) {
859        for (a, b) in self.counts.iter_mut().zip(&o.counts) {
860            a.merge(b);
861        }
862        for (a, b) in self.net_sum_sq.iter_mut().zip(&o.net_sum_sq) {
863            *a += b;
864        }
865        self.outside.merge(&o.outside);
866        self.outside_net_sum_sq += o.outside_net_sum_sq;
867    }
868
869    fn report(&self) -> ChargeCells {
870        ChargeCells {
871            counts: self.counts.clone(),
872            net_sum_sq: self.net_sum_sq.clone(),
873            outside: self.outside,
874            outside_net_sum_sq: self.outside_net_sum_sq,
875        }
876    }
877}
878
879/// Charge bookkeeping of a tally: per layer, per grid cell, and escapes.
880#[derive(Debug, Clone, PartialEq)]
881struct ChargeAcc {
882    layers: ChargeGrid,
883    cartesian: Option<ChargeGrid>,
884    cylindrical: Option<ChargeGrid>,
885    /// Escapes through each face: `[primary, secondary]`.
886    escaped_front: [u64; 2],
887    escaped_back: [u64; 2],
888    escaped_side: [u64; 2],
889}
890
891fn per(x: f64, n: u64) -> f64 {
892    if n == 0 {
893        0.0
894    } else {
895        x / n as f64
896    }
897}
898
899/// Deposition, emission and energy-balance tally for the electron transport
900/// loop. See the module docs.
901#[derive(Debug, Clone, PartialEq)]
902pub struct FullElectronTally {
903    config: ElectronTallyConfig,
904    cutoff_ev: f64,
905    /// Stopping threshold per layer, eV, from the transport.
906    thresholds: Vec<f64>,
907    /// Whether the transport runs a secondary model (then the `secondary`
908    /// hook, not `inelastic`, carries the deposit of an inelastic event).
909    secondaries: bool,
910    histories: u64,
911    fates: FateCounts,
912    event_caps: EventCapCounts,
913    budget: Budget,
914    layer_deposit_ev: Vec<f64>,
915    layer_ionisation_ev: Vec<f64>,
916    layer_lattice_ev: Vec<f64>,
917    cartesian: Option<GridAcc>,
918    cylindrical: Option<GridAcc>,
919    /// Radial profile in a depth slab with per-history errors.
920    psf: Option<RadialAccumulator>,
921    /// Dose, depth-dose and transmission accumulators.
922    dose: Option<DoseAcc>,
923    /// Per-electron deposit in the exposure volumes.
924    exposure: Option<ExposureAccumulator>,
925    /// Energy-weighted deposition position: `x`, `y`, `z`, `r`.
926    generation: [WeightedMoments; 4],
927    stop_depth: Moments,
928    stop_radial: Moments,
929    /// The subset of `stop_depth` / `stop_radial` that are primaries.
930    primary_stop_depth: Moments,
931    primary_stop_radial: Moments,
932    front: FaceAcc,
933    back: FaceAcc,
934    side: FaceAcc,
935    /// Energy deposited per voxel of the optional voxel grid.
936    voxel: Option<VoxelAcc>,
937    /// Where electrons stopped and holes were created.
938    charge: ChargeAcc,
939    /// Rate evaluations against each table's energy grid.
940    table_coverage: TableCoverageTally,
941    /// Per-history scratch, not part of the result and cleared at every
942    /// history boundary: the last state seen of the current electron, for
943    /// [`Fate::EventCap`] (`None` once it ended through a hook).
944    last: Option<ElectronState>,
945    /// The primary's last state, set aside when the first secondary begins.
946    primary_last: Option<ElectronState>,
947    /// Whether the history has reached its secondaries.
948    in_secondary: bool,
949    /// Whether a secondary of the current history was cut off by the event
950    /// cap (so the history is counted once in
951    /// [`EventCapCounts::affected_histories`]).
952    history_capped: bool,
953}
954
955impl FullElectronTally {
956    /// Empty tally for runs of `transport` (its per-layer stopping thresholds
957    /// classify `stopped` events, its secondary model decides which hook
958    /// carries an inelastic deposit, its stack sets the number of layers).
959    pub fn new<G: Geometry>(
960        transport: &Transport<G>,
961        config: ElectronTallyConfig,
962    ) -> Result<Self, ElectronTallyError> {
963        if !(config.se_bse_split_ev.is_finite() && config.se_bse_split_ev >= 0.0) {
964            return Err(ElectronTallyError::Split(config.se_bse_split_ev));
965        }
966        let p = config.escape_polar;
967        if p.lo < 0.0 || p.hi > std::f64::consts::PI {
968            return Err(ElectronTallyError::PolarRange { lo: p.lo, hi: p.hi });
969        }
970        let cartesian = match config.cartesian {
971            None => None,
972            Some(g) => Some(GridAcc::new(
973                g.voxels()
974                    .ok_or(ElectronTallyError::GridTooLarge("Cartesian"))?,
975            )),
976        };
977        let cylindrical = match config.cylindrical {
978            None => None,
979            Some(g) => {
980                if g.r.lo < 0.0 {
981                    return Err(ElectronTallyError::NegativeRadius(g.r.lo));
982                }
983                Some(GridAcc::new(
984                    g.cells()
985                        .ok_or(ElectronTallyError::GridTooLarge("cylindrical"))?,
986                ))
987            }
988        };
989        let psf = config.psf.map(RadialAccumulator::new).transpose()?;
990        let dose = match config.dose {
991            None => None,
992            Some(d) => {
993                let stack = transport.geometry().as_stack().ok_or_else(|| {
994                    DoseError("the dose tally needs a layered stack target".into())
995                })?;
996                Some(DoseAcc::new(stack, d, config.escape_energy)?)
997            }
998        };
999        let exposure = config
1000            .exposure
1001            .clone()
1002            .map(ExposureAccumulator::new)
1003            .transpose()?;
1004        let (front, back, side) = (
1005            FaceAcc::new(&config),
1006            FaceAcc::new(&config),
1007            FaceAcc::new(&config),
1008        );
1009        let charge = ChargeAcc {
1010            layers: ChargeGrid::new(transport.n_materials()),
1011            cartesian: cartesian
1012                .as_ref()
1013                .map(|a| ChargeGrid::new(a.energy_ev.len())),
1014            cylindrical: cylindrical
1015                .as_ref()
1016                .map(|a| ChargeGrid::new(a.energy_ev.len())),
1017            escaped_front: [0; 2],
1018            escaped_back: [0; 2],
1019            escaped_side: [0; 2],
1020        };
1021        Ok(Self {
1022            config,
1023            cutoff_ev: transport.config().cutoff_ev,
1024            thresholds: transport.stopping_thresholds_ev().to_vec(),
1025            secondaries: transport.config().secondaries != SecondaryModel::Off,
1026            histories: 0,
1027            fates: FateCounts::default(),
1028            event_caps: EventCapCounts::default(),
1029            budget: Budget::default(),
1030            layer_deposit_ev: vec![0.0; transport.n_materials()],
1031            layer_ionisation_ev: vec![0.0; transport.n_materials()],
1032            layer_lattice_ev: vec![0.0; transport.n_materials()],
1033            cartesian,
1034            cylindrical,
1035            psf,
1036            dose,
1037            exposure,
1038            generation: [WeightedMoments::default(); 4],
1039            stop_depth: Moments::new(),
1040            stop_radial: Moments::new(),
1041            primary_stop_depth: Moments::new(),
1042            primary_stop_radial: Moments::new(),
1043            front,
1044            back,
1045            side,
1046            voxel: None,
1047            charge,
1048            table_coverage: TableCoverageTally::new(transport),
1049            last: None,
1050            primary_last: None,
1051            in_secondary: false,
1052            history_capped: false,
1053        })
1054    }
1055
1056    /// The configuration.
1057    pub fn config(&self) -> &ElectronTallyConfig {
1058        &self.config
1059    }
1060
1061    /// Primary histories tallied so far.
1062    pub fn histories(&self) -> u64 {
1063        self.histories
1064    }
1065
1066    /// Also tally the deposited energy per voxel of `grid`, for
1067    /// [`FullElectronTally::voxel_report`]. The grid is independent of the
1068    /// target: use [`VoxelTallyGrid::from_voxel_grid`] to tally on the voxels
1069    /// of a [`crate::geometry::VoxelGrid`] target.
1070    pub fn with_voxel_grid(mut self, grid: VoxelTallyGrid) -> Self {
1071        self.voxel = Some(VoxelAcc::new(grid));
1072        self
1073    }
1074
1075    /// The per-voxel deposition as a [`VoxelReport`] (CSV and VTK export of
1076    /// [`crate::tally::voxel`]), or `None` without
1077    /// [`FullElectronTally::with_voxel_grid`].
1078    ///
1079    /// Each voxel's energy is split by how it was deposited: `electronic_ev`
1080    /// is the energy left at inelastic events (the
1081    /// [`ElectronEnergyBudget::inelastic_ev`] channel), `lattice_ev` the
1082    /// LO-phonon emission and polaron-trapping energy, and `rest_ev` the
1083    /// energy of electrons that fell below the stopping threshold
1084    /// ([`ElectronEnergyBudget::residual_ev`]); their sum over all voxels
1085    /// and `outside` is [`ElectronEnergyBudget::deposited_ev`].
1086    /// `implanted` and `displacements` are zero: the electron tally records
1087    /// neither, and the per-species defect fields (`species_z`, `defects`,
1088    /// `outside_defects`) are empty: the electron tally has no species axis.
1089    /// Deposits are point events at the collision (as for
1090    /// [`CartesianGrid`]), so a flight crossing voxel faces is not split.
1091    pub fn voxel_report(&self) -> Option<VoxelReport> {
1092        self.voxel.as_ref().map(|v| VoxelReport {
1093            grid: v.grid,
1094            histories: self.histories,
1095            voxels: v
1096                .cells
1097                .iter()
1098                .map(|(&flat, &cell)| VoxelRecord {
1099                    index: v.grid.unflatten(flat),
1100                    cell,
1101                    defects: Vec::new(),
1102                })
1103                .collect(),
1104            outside: v.outside,
1105            species_z: Vec::new(),
1106            outside_defects: Vec::new(),
1107        })
1108    }
1109
1110    /// Every energy deposit goes through here. `kind` picks the voxel field
1111    /// and, through [`DepositKind::channel`], the ionisation or lattice
1112    /// channel of the layer and grid maps.
1113    fn deposit(&mut self, at: &ElectronState, e: f64, kind: DepositKind) {
1114        if e <= 0.0 {
1115            return;
1116        }
1117        let pos = at.pos;
1118        if let Some(v) = &mut self.voxel {
1119            v.add(pos, e, kind);
1120        }
1121        self.layer_deposit_ev[at.layer] += e;
1122        let channel = kind.channel();
1123        match channel {
1124            Channel::Ionisation => self.layer_ionisation_ev[at.layer] += e,
1125            Channel::Lattice => self.layer_lattice_ev[at.layer] += e,
1126        }
1127        if let (Some(acc), Some(g)) = (&mut self.cartesian, &self.config.cartesian) {
1128            acc.add(g.locate(pos), e, channel);
1129        }
1130        if let (Some(acc), Some(g)) = (&mut self.cylindrical, &self.config.cylindrical) {
1131            acc.add(g.locate(pos), e, channel);
1132        }
1133        if let Some(p) = &mut self.psf {
1134            p.deposit(pos, e);
1135        }
1136        if let Some(d) = &mut self.dose {
1137            d.deposit(at.layer, pos[0], e);
1138        }
1139        if let Some(x) = &mut self.exposure {
1140            x.deposit(pos, e);
1141        }
1142        let r = pos[1].hypot(pos[2]);
1143        for (m, v) in self.generation.iter_mut().zip([pos[0], pos[1], pos[2], r]) {
1144            m.push(v, e);
1145        }
1146    }
1147
1148    /// An electron of the current generation terminated at `at`.
1149    fn charge_stop(&mut self, at: &ElectronState) {
1150        self.charge_stop_gen(at, self.in_secondary);
1151    }
1152
1153    fn charge_stop_gen(&mut self, at: &ElectronState, secondary: bool) {
1154        let c = &mut self.charge;
1155        c.layers.stop(Some(at.layer), secondary);
1156        if let (Some(a), Some(g)) = (&mut c.cartesian, &self.config.cartesian) {
1157            a.stop(g.locate(at.pos), secondary);
1158        }
1159        if let (Some(a), Some(g)) = (&mut c.cylindrical, &self.config.cylindrical) {
1160            a.stop(g.locate(at.pos), secondary);
1161        }
1162    }
1163
1164    /// A hole was left at `at` by a collision of the current electron.
1165    fn charge_create(&mut self, at: &ElectronState) {
1166        let by_secondary = self.in_secondary;
1167        let c = &mut self.charge;
1168        c.layers.create(Some(at.layer), by_secondary);
1169        if let (Some(a), Some(g)) = (&mut c.cartesian, &self.config.cartesian) {
1170            a.create(g.locate(at.pos), by_secondary);
1171        }
1172        if let (Some(a), Some(g)) = (&mut c.cylindrical, &self.config.cylindrical) {
1173            a.create(g.locate(at.pos), by_secondary);
1174        }
1175    }
1176
1177    fn charge_end_history(&mut self) {
1178        let c = &mut self.charge;
1179        c.layers.end_history();
1180        if let Some(a) = &mut c.cartesian {
1181            a.end_history();
1182        }
1183        if let Some(a) = &mut c.cylindrical {
1184            a.end_history();
1185        }
1186    }
1187
1188    fn charge_report(&self) -> ChargeReport {
1189        let c = &self.charge;
1190        let mut total = ChargeCounts::default();
1191        for l in &c.layers.counts {
1192            total.merge(l);
1193        }
1194        let esc = |e: [u64; 2]| EscapedCharge {
1195            primary: e[0],
1196            secondary: e[1],
1197        };
1198        let escaped = c
1199            .escaped_front
1200            .iter()
1201            .chain(&c.escaped_back)
1202            .chain(&c.escaped_side)
1203            .sum::<u64>();
1204        ChargeReport {
1205            incident: self.histories,
1206            total,
1207            per_layer: c.layers.report(),
1208            escaped_front: esc(c.escaped_front),
1209            escaped_back: esc(c.escaped_back),
1210            escaped_side: esc(c.escaped_side),
1211            imbalance: self.histories as i64 + total.created() as i64
1212                - total.stopped() as i64
1213                - escaped as i64,
1214            cartesian: self
1215                .config
1216                .cartesian
1217                .zip(c.cartesian.as_ref())
1218                .map(|(grid, a)| CartesianCharge {
1219                    grid,
1220                    cells: a.report(),
1221                }),
1222            cylindrical: self
1223                .config
1224                .cylindrical
1225                .zip(c.cylindrical.as_ref())
1226                .map(|(grid, a)| CylindricalCharge {
1227                    grid,
1228                    cells: a.report(),
1229                }),
1230        }
1231    }
1232
1233    /// The plain-data result.
1234    pub fn report(&self) -> ElectronReport {
1235        let h = self.histories;
1236        let front = self.front.report(h);
1237        let back = self.back.report(h);
1238        let yields = Yields {
1239            backscatter_eta: front.fast.per_primary,
1240            secondary_delta: front.slow.per_primary,
1241            total_sigma: per((front.fast.count + front.slow.count) as f64, h),
1242            transmitted_fast: back.fast.per_primary,
1243            transmitted_slow: back.slow.per_primary,
1244        };
1245        let [gx, gy, gz, gr] = &self.generation;
1246        let generation_volume = (gx.weight > 0.0).then(|| GenerationVolume {
1247            energy_ev: gx.weight,
1248            mean_m: [gx.mean, gy.mean, gz.mean],
1249            std_dev_m: [
1250                gx.variance().sqrt(),
1251                gy.variance().sqrt(),
1252                gz.variance().sqrt(),
1253            ],
1254            mean_radius_m: gr.mean,
1255            radius_std_dev_m: gr.variance().sqrt(),
1256            rms_radius_m: (gy.variance() + gy.mean * gy.mean + gz.variance() + gz.mean * gz.mean)
1257                .sqrt(),
1258        });
1259        let deposition =
1260            DepositionReport {
1261                per_layer_ev: self.layer_deposit_ev.clone(),
1262                per_layer_ionisation_ev: self.layer_ionisation_ev.clone(),
1263                per_layer_lattice_ev: self.layer_lattice_ev.clone(),
1264                cartesian: self
1265                    .config
1266                    .cartesian
1267                    .zip(self.cartesian.as_ref())
1268                    .map(|(grid, a)| CartesianDeposition {
1269                        grid,
1270                        energy_ev: a.energy_ev.clone(),
1271                        outside_ev: a.outside_ev,
1272                        ionisation_ev: a.ionisation_ev.clone(),
1273                        lattice_ev: a.lattice_ev.clone(),
1274                        outside_ionisation_ev: a.outside_ionisation_ev,
1275                        outside_lattice_ev: a.outside_lattice_ev,
1276                    }),
1277                cylindrical: self.config.cylindrical.zip(self.cylindrical.as_ref()).map(
1278                    |(grid, a)| CylindricalDeposition {
1279                        grid,
1280                        energy_ev: a.energy_ev.clone(),
1281                        outside_ev: a.outside_ev,
1282                        ionisation_ev: a.ionisation_ev.clone(),
1283                        lattice_ev: a.lattice_ev.clone(),
1284                        outside_ionisation_ev: a.outside_ionisation_ev,
1285                        outside_lattice_ev: a.outside_lattice_ev,
1286                    },
1287                ),
1288                psf: self.psf.as_ref().map(RadialAccumulator::profile),
1289                exposure: self.exposure.as_ref().map(ExposureAccumulator::report),
1290            };
1291        let dose = self.dose.as_ref().map(|d| d.report(self.budget.incident));
1292        ElectronReport {
1293            histories: h,
1294            metadata: ElectronTallyMetadata {
1295                se_bse_split_ev: self.config.se_bse_split_ev,
1296                se_bse_split_rule: SE_BSE_SPLIT_RULE.to_string(),
1297                se_bse_split_source: SE_BSE_SPLIT_SOURCE.to_string(),
1298                cutoff_ev: self.cutoff_ev,
1299                stopping_threshold_ev: self.thresholds.clone(),
1300                layers: self.layer_deposit_ev.len(),
1301                config: self.config.clone(),
1302            },
1303            fates: self.fates,
1304            event_caps: self.event_caps,
1305            budget: self.budget.report(),
1306            yields,
1307            front,
1308            back,
1309            side: (self.side.slow.count + self.side.fast.count > 0).then(|| self.side.report(h)),
1310            deposition,
1311            dose,
1312            generation_volume,
1313            charge: self.charge_report(),
1314            stopping_points: StoppingPoints {
1315                stopped: self.stop_depth.n,
1316                depth: self.stop_depth.summary(),
1317                radial: self.stop_radial.summary(),
1318                primaries: PrimaryStoppingPoints {
1319                    stopped: self.primary_stop_depth.n,
1320                    depth: self.primary_stop_depth.summary(),
1321                    radial: self.primary_stop_radial.summary(),
1322                },
1323            },
1324            table_coverage: self.table_coverage.layers().to_vec(),
1325        }
1326    }
1327}
1328
1329impl ElectronTally for FullElectronTally {
1330    fn begin_history(&mut self, index: u64, start: &ElectronState) {
1331        if let Some(x) = &mut self.exposure {
1332            x.begin_history(index, [start.pos[1], start.pos[2]]);
1333        }
1334        self.budget.incident += start.energy_ev;
1335        self.last = Some(*start);
1336        self.primary_last = None;
1337        self.in_secondary = false;
1338        self.history_capped = false;
1339    }
1340
1341    fn step(&mut self, _from: [f64; 3], end: &ElectronState, _length_m: f64) {
1342        self.last = Some(*end);
1343    }
1344
1345    fn table_lookup(&mut self, at: &ElectronState, channel: TableChannel, coverage: GridCoverage) {
1346        self.table_coverage.record(at.layer, channel, coverage);
1347    }
1348
1349    fn elastic(&mut self, after: &ElectronState, _theta: f64) {
1350        self.last = Some(*after);
1351    }
1352
1353    fn inelastic(&mut self, after: &ElectronState, w_ev: f64) {
1354        self.last = Some(*after);
1355        // With a secondary model the `secondary` hook that follows carries
1356        // the part of `w_ev` left in the solid.
1357        if !self.secondaries {
1358            self.budget.inelastic += w_ev;
1359            self.deposit(after, w_ev, DepositKind::Electronic);
1360        }
1361    }
1362
1363    fn secondary(
1364        &mut self,
1365        primary: &ElectronState,
1366        event: &SecondaryEvent,
1367        created: Option<&ElectronState>,
1368    ) {
1369        self.last = Some(*primary);
1370        if event.liberated {
1371            // A hole is left where the electron was lifted out. A liberated
1372            // electron that is not followed was below the stopping threshold
1373            // and is dropped here, so it stops where its hole is.
1374            self.charge_create(primary);
1375            if created.is_none() {
1376                self.charge_stop_gen(primary, true);
1377            }
1378        }
1379        // `W = secondary + binding + deposited` (module docs): the created
1380        // secondary is counted where it ends, a positive binding stays in
1381        // the solid, a negative one is energy the liberated electron brings.
1382        let local = event.deposited_ev + event.binding_ev.max(0.0);
1383        self.budget.inelastic += local;
1384        self.budget.fermi_sea += (-event.binding_ev).max(0.0);
1385        self.deposit(primary, local, DepositKind::Electronic);
1386    }
1387
1388    fn phonon(
1389        &mut self,
1390        after: &ElectronState,
1391        kind: PhononEvent,
1392        hbar_omega_ev: f64,
1393        _theta: f64,
1394    ) {
1395        self.last = Some(*after);
1396        match kind {
1397            PhononEvent::Emission => {
1398                self.budget.phonon_emitted += hbar_omega_ev;
1399                self.deposit(after, hbar_omega_ev, DepositKind::Lattice);
1400            }
1401            PhononEvent::Absorption => self.budget.phonon_absorbed += hbar_omega_ev,
1402        }
1403    }
1404
1405    fn polaron_trapped(&mut self, at: &ElectronState) {
1406        self.last = None;
1407        self.charge_stop(at);
1408        self.budget.polaron += at.energy_ev;
1409        self.deposit(at, at.energy_ev, DepositKind::Lattice);
1410    }
1411
1412    fn interface(&mut self, at: &ElectronState, from_layer: usize, to_layer: usize) {
1413        self.last = Some(*at);
1414        if let Some(d) = &mut self.dose {
1415            d.crossing(from_layer, to_layer, at.energy_ev, self.in_secondary);
1416        }
1417    }
1418
1419    fn barrier(&mut self, at: &ElectronState, _boundary: Boundary, delta_u_ev: f64) {
1420        self.last = Some(*at);
1421        self.budget.barrier -= delta_u_ev;
1422    }
1423
1424    fn reflected(&mut self, at: &ElectronState, _boundary: Boundary) {
1425        self.last = Some(*at);
1426    }
1427
1428    fn begin_secondary(&mut self, start: &ElectronState, _generation: u32) {
1429        if !self.in_secondary {
1430            // The primary has ended; keep its last state for `end_history`.
1431            self.primary_last = self.last.take();
1432            self.in_secondary = true;
1433        }
1434        self.last = Some(*start);
1435    }
1436
1437    fn end_secondary(&mut self, fate: Fate) {
1438        if fate == Fate::EventCap {
1439            if let Some(s) = self.last {
1440                self.budget.event_cap += s.energy_ev;
1441            }
1442            if let Some(s) = self.last {
1443                self.charge_stop_gen(&s, true);
1444            }
1445            self.event_caps.secondary_tracks += 1;
1446            self.history_capped = true;
1447        }
1448        self.last = None;
1449    }
1450
1451    fn stopped(&mut self, at: &ElectronState) {
1452        self.last = None;
1453        // Below the threshold or trapped without an interaction, the
1454        // electron ends inside the target either way.
1455        self.charge_stop(at);
1456        let e = at.energy_ev;
1457        if e < self.thresholds[at.layer] {
1458            self.budget.residual += e;
1459            self.deposit(at, e, DepositKind::Rest);
1460            let r = at.pos[1].hypot(at.pos[2]);
1461            self.stop_depth.push(at.pos[0]);
1462            self.stop_radial.push(r);
1463            if !self.in_secondary {
1464                // The primary is followed to its end before any secondary
1465                // begins, so this is the primary stopping.
1466                self.primary_stop_depth.push(at.pos[0]);
1467                self.primary_stop_radial.push(r);
1468            }
1469        } else {
1470            self.budget.no_interaction += e;
1471        }
1472    }
1473
1474    fn escaped(&mut self, at: &ElectronState, face: Face) {
1475        self.last = None;
1476        let e = at.energy_ev;
1477        let polar = at.dir[0].abs().min(1.0).acos();
1478        let g = usize::from(self.in_secondary);
1479        let acc = match face {
1480            Face::Front => {
1481                self.budget.escaped_front += e;
1482                self.charge.escaped_front[g] += 1;
1483                &mut self.front
1484            }
1485            Face::Back => {
1486                self.budget.escaped_back += e;
1487                self.charge.escaped_back[g] += 1;
1488                &mut self.back
1489            }
1490            Face::Side => {
1491                self.budget.escaped_side += e;
1492                self.charge.escaped_side[g] += 1;
1493                &mut self.side
1494            }
1495        };
1496        acc.energy_hist.fill(e);
1497        let class = if e < self.config.se_bse_split_ev {
1498            &mut acc.slow
1499        } else {
1500            &mut acc.fast
1501        };
1502        class.count += 1;
1503        class.energy_ev += e;
1504        class.polar_hist.fill(polar);
1505    }
1506
1507    fn absorbed(&mut self, at: &ElectronState) {
1508        self.last = None;
1509        self.charge_stop(at);
1510        self.budget.absorbed += at.energy_ev;
1511    }
1512
1513    fn end_history(&mut self, _index: u64, fate: Fate) {
1514        self.histories += 1;
1515        if let Some(p) = &mut self.psf {
1516            p.end_history();
1517        }
1518        if let Some(d) = &mut self.dose {
1519            d.end_history();
1520        }
1521        if let Some(x) = &mut self.exposure {
1522            x.end_history();
1523        }
1524        match fate {
1525            Fate::Stopped => self.fates.stopped += 1,
1526            Fate::Escaped(Face::Front) => self.fates.escaped_front += 1,
1527            Fate::Escaped(Face::Back) => self.fates.escaped_back += 1,
1528            Fate::Escaped(Face::Side) => self.fates.escaped_side += 1,
1529            Fate::Absorbed => self.fates.absorbed += 1,
1530            Fate::Trapped => self.fates.trapped += 1,
1531            Fate::PolaronTrapped => self.fates.polaron_trapped += 1,
1532            Fate::EventCap => {
1533                self.fates.event_capped += 1;
1534                let primary = if self.in_secondary {
1535                    self.primary_last
1536                } else {
1537                    self.last
1538                };
1539                if let Some(s) = primary {
1540                    self.budget.event_cap += s.energy_ev;
1541                    self.charge_stop_gen(&s, false);
1542                }
1543            }
1544        }
1545        self.charge_end_history();
1546        if self.history_capped || fate == Fate::EventCap {
1547            // Once per history, however many of its electrons were capped.
1548            self.event_caps.affected_histories += 1;
1549        }
1550        self.last = None;
1551        self.primary_last = None;
1552        self.in_secondary = false;
1553        self.history_capped = false;
1554    }
1555
1556    /// Field-wise merge.
1557    ///
1558    /// # Panics
1559    /// If `other` was built with a different configuration, cutoff,
1560    /// thresholds, secondary model or stack.
1561    fn merge(&mut self, o: Self) {
1562        assert!(
1563            self.config == o.config
1564                && self.cutoff_ev == o.cutoff_ev
1565                && self.thresholds == o.thresholds
1566                && self.secondaries == o.secondaries
1567                && self.layer_deposit_ev.len() == o.layer_deposit_ev.len(),
1568            "merging electron tallies built for different runs"
1569        );
1570        self.histories += o.histories;
1571        self.fates.merge(&o.fates);
1572        self.event_caps.merge(&o.event_caps);
1573        self.budget.merge(&o.budget);
1574        for (a, b) in self.layer_deposit_ev.iter_mut().zip(&o.layer_deposit_ev) {
1575            *a += b;
1576        }
1577        for (a, b) in self
1578            .layer_ionisation_ev
1579            .iter_mut()
1580            .zip(&o.layer_ionisation_ev)
1581        {
1582            *a += b;
1583        }
1584        for (a, b) in self.layer_lattice_ev.iter_mut().zip(&o.layer_lattice_ev) {
1585            *a += b;
1586        }
1587        if let (Some(a), Some(b)) = (&mut self.cartesian, &o.cartesian) {
1588            a.merge(b);
1589        }
1590        if let (Some(a), Some(b)) = (&mut self.cylindrical, &o.cylindrical) {
1591            a.merge(b);
1592        }
1593        if let (Some(a), Some(b)) = (&mut self.psf, &o.psf) {
1594            a.merge(b);
1595        }
1596        if let (Some(a), Some(b)) = (&mut self.dose, &o.dose) {
1597            a.merge(b);
1598        }
1599        if let (Some(a), Some(b)) = (&mut self.exposure, &o.exposure) {
1600            a.merge(b);
1601        }
1602        for (a, b) in self.generation.iter_mut().zip(&o.generation) {
1603            a.merge(b);
1604        }
1605        self.stop_depth.merge(&o.stop_depth);
1606        self.stop_radial.merge(&o.stop_radial);
1607        self.primary_stop_depth.merge(&o.primary_stop_depth);
1608        self.primary_stop_radial.merge(&o.primary_stop_radial);
1609        self.front.merge(&o.front);
1610        self.back.merge(&o.back);
1611        self.side.merge(&o.side);
1612        match (&mut self.voxel, &o.voxel) {
1613            (Some(a), Some(b)) => a.merge(b),
1614            (None, None) => {}
1615            _ => panic!("merging electron tallies built for different runs"),
1616        }
1617        self.charge.layers.merge(&o.charge.layers);
1618        if let (Some(a), Some(b)) = (&mut self.charge.cartesian, &o.charge.cartesian) {
1619            a.merge(b);
1620        }
1621        if let (Some(a), Some(b)) = (&mut self.charge.cylindrical, &o.charge.cylindrical) {
1622            a.merge(b);
1623        }
1624        for g in 0..2 {
1625            self.charge.escaped_front[g] += o.charge.escaped_front[g];
1626            self.charge.escaped_back[g] += o.charge.escaped_back[g];
1627            self.charge.escaped_side[g] += o.charge.escaped_side[g];
1628        }
1629        self.table_coverage.merge_from(&o.table_coverage);
1630    }
1631}
1632
1633/// Everything a [`FullElectronTally`] measured, as plain data.
1634#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1635pub struct ElectronReport {
1636    /// Primary histories.
1637    pub histories: u64,
1638    /// The tally settings and conventions.
1639    pub metadata: ElectronTallyMetadata,
1640    /// How the primary histories ended.
1641    pub fates: FateCounts,
1642    /// Electrons cut off by the collision cap: capped secondary tracks and
1643    /// histories with any capped electron. A numerical diagnostic; all zero
1644    /// in reports written before it existed.
1645    #[serde(default)]
1646    pub event_caps: EventCapCounts,
1647    /// Summed energy balance of all histories, eV.
1648    pub budget: ElectronEnergyBudget,
1649    /// Emission yields per primary.
1650    pub yields: Yields,
1651    /// Electrons leaving the front face (`x = 0`).
1652    pub front: FaceEmission,
1653    /// Electrons leaving the back face of a finite stack.
1654    pub back: FaceEmission,
1655    /// Electrons leaving through a lateral face of a voxel target; `None`
1656    /// when none did (always, for a stack).
1657    #[serde(default, skip_serializing_if = "Option::is_none")]
1658    pub side: Option<FaceEmission>,
1659    /// Where the energy was deposited.
1660    pub deposition: DepositionReport,
1661    /// Dose per layer, depth-dose and interface transmission, if configured
1662    /// ([`ElectronTallyConfig::dose`]).
1663    #[serde(default)]
1664    pub dose: Option<DoseReport>,
1665    /// Energy-weighted moments of the deposition position; `None` if nothing
1666    /// was deposited.
1667    pub generation_volume: Option<GenerationVolume>,
1668    /// Where electrons stopped and where holes were left, and the balance
1669    /// against the incident electrons. Empty in reports written before it
1670    /// existed.
1671    #[serde(default)]
1672    pub charge: ChargeReport,
1673    /// Where electrons fell below the stopping threshold.
1674    pub stopping_points: StoppingPoints,
1675    /// Per layer, the rate evaluations of the elastic and inelastic tables
1676    /// inside and beyond each table's energy grid, with the grid bounds
1677    /// (primaries and secondaries together). These count evaluations, not
1678    /// collisions; see [`super::table_coverage`]. Empty in reports written
1679    /// before it existed.
1680    #[serde(default)]
1681    pub table_coverage: Vec<LayerTableCoverage>,
1682}
1683
1684/// Where the electrons of a run came to rest and where holes were left, as
1685/// integer counts, and the balance against the incident electrons.
1686///
1687/// Charge is counted in units of the electron's, per electron number:
1688///
1689/// - every electron that ends inside the target counts `+1` where it ends:
1690///   every terminal fate except escape (stopped below the layer's threshold,
1691///   trapped without an interaction, absorbed at the back face, polaron
1692///   trapped, cut off by the collision cap);
1693/// - every liberated electron (a secondary-model event with
1694///   `liberated = true`) counts `-1` at the event, the hole it leaves, whether
1695///   or not the electron is followed. A liberated electron below the stopping
1696///   threshold is not followed, it is dropped and counted `+1` where it was
1697///   created, so its `-1` and `+1` cancel;
1698/// - an electron leaving through a face carries its charge out and is counted
1699///   in `escaped_front` / `escaped_back`.
1700///
1701/// The balance is exact in integers: `incident + created = stopped +
1702/// escaped`, so `imbalance` is zero on every run and the net electron number
1703/// deposited in the target, `stopped - created`, equals `incident - escaped`.
1704/// Generation: `primary` is the incident electron, `secondary` every electron
1705/// created by the secondary model. A secondary below the cutoff is not
1706/// followed and is therefore counted where it is dropped (see
1707/// [`ElectronTallyMetadata::cutoff_ev`]).
1708#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1709pub struct ChargeReport {
1710    /// Incident electrons (primary histories).
1711    pub incident: u64,
1712    /// Totals over the target.
1713    pub total: ChargeCounts,
1714    /// Per layer.
1715    pub per_layer: ChargeCells,
1716    /// Electrons that left through the front face.
1717    pub escaped_front: EscapedCharge,
1718    /// Electrons that left through the back face.
1719    pub escaped_back: EscapedCharge,
1720    /// Electrons that left through the lateral boundary of a non-slab
1721    /// geometry.
1722    pub escaped_side: EscapedCharge,
1723    /// `incident + created - stopped - escaped`; zero if every electron was
1724    /// counted exactly once.
1725    pub imbalance: i64,
1726    /// The Cartesian grid, if the run has one.
1727    pub cartesian: Option<CartesianCharge>,
1728    /// The cylindrical grid, if the run has one.
1729    pub cylindrical: Option<CylindricalCharge>,
1730}
1731
1732/// Electrons that left through one face, by generation.
1733#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1734pub struct EscapedCharge {
1735    /// Primaries.
1736    pub primary: u64,
1737    /// Secondaries.
1738    pub secondary: u64,
1739}
1740
1741/// Charge counts per cell (or per layer) with the data for the standard
1742/// error of the net charge per primary.
1743#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1744pub struct ChargeCells {
1745    /// Counts per cell.
1746    pub counts: Vec<ChargeCounts>,
1747    /// Per cell, the sum over histories of the square of the net charge
1748    /// (stopped minus created) the history left there.
1749    pub net_sum_sq: Vec<u64>,
1750    /// Counts outside the grid.
1751    pub outside: ChargeCounts,
1752    /// Squared net charge sum outside the grid.
1753    pub outside_net_sum_sq: u64,
1754}
1755
1756impl ChargeCells {
1757    /// Net charge per incident electron in cell `i` and its standard error
1758    /// (of the mean over `histories`).
1759    pub fn net_per_primary(&self, i: usize, histories: u64) -> (f64, f64) {
1760        net_mean_se(self.counts[i].net(), self.net_sum_sq[i], histories)
1761    }
1762}
1763
1764/// Mean and standard error of a per-history integer quantity from its sum
1765/// and sum of squares.
1766pub fn net_mean_se(sum: i64, sum_sq: u64, n: u64) -> (f64, f64) {
1767    if n == 0 {
1768        return (0.0, 0.0);
1769    }
1770    let nf = n as f64;
1771    let mean = sum as f64 / nf;
1772    if n == 1 {
1773        return (mean, 0.0);
1774    }
1775    let var = ((sum_sq as f64 - nf * mean * mean) / (nf - 1.0)).max(0.0);
1776    (mean, (var / nf).sqrt())
1777}
1778
1779/// Charge on the Cartesian grid, in [`CartesianGrid::index`] order.
1780#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1781pub struct CartesianCharge {
1782    /// The grid.
1783    pub grid: CartesianGrid,
1784    /// Per voxel.
1785    pub cells: ChargeCells,
1786}
1787
1788/// Charge on the cylindrical `(r, depth)` grid, in
1789/// [`CylindricalGrid::index`] order.
1790#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1791pub struct CylindricalCharge {
1792    /// The grid.
1793    pub grid: CylindricalGrid,
1794    /// Per cell.
1795    pub cells: ChargeCells,
1796}
1797
1798/// The settings and conventions a report was made with.
1799#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1800pub struct ElectronTallyMetadata {
1801    /// SE/BSE split, eV.
1802    pub se_bse_split_ev: f64,
1803    /// How an escaping electron is classed against the split.
1804    pub se_bse_split_rule: String,
1805    /// Where the split convention comes from.
1806    pub se_bse_split_source: String,
1807    /// Transport cutoff, eV, as configured (measured from the band bottom or
1808    /// the vacuum level, see the run's
1809    /// [`RunMetadata`](crate::electron::transport::RunMetadata)).
1810    pub cutoff_ev: f64,
1811    /// Stopping threshold per layer, eV: a `stopped` electron below its
1812    /// layer's threshold is a deposit, at or above it a trapped electron.
1813    pub stopping_threshold_ev: Vec<f64>,
1814    /// Layers in the stack.
1815    pub layers: usize,
1816    /// The full tally configuration (grids and spectrum binnings).
1817    pub config: ElectronTallyConfig,
1818}
1819
1820/// How the primary histories ended (one per history, from its
1821/// [`Fate`]).
1822#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1823pub struct FateCounts {
1824    /// Fell below the stopping threshold in the target.
1825    pub stopped: u64,
1826    /// Left through the front face.
1827    pub escaped_front: u64,
1828    /// Left through the back face.
1829    pub escaped_back: u64,
1830    /// Left through a lateral face of a voxel target.
1831    #[serde(default)]
1832    pub escaped_side: u64,
1833    /// Absorbed at the back face.
1834    pub absorbed: u64,
1835    /// No interaction available and no face to reach.
1836    pub trapped: u64,
1837    /// Cut off by the collision cap.
1838    pub event_capped: u64,
1839    /// Trapped as a polaron (an opt-in insulator channel).
1840    #[serde(default)]
1841    pub polaron_trapped: u64,
1842}
1843
1844impl FateCounts {
1845    fn merge(&mut self, o: &FateCounts) {
1846        self.stopped += o.stopped;
1847        self.escaped_front += o.escaped_front;
1848        self.escaped_back += o.escaped_back;
1849        self.escaped_side += o.escaped_side;
1850        self.absorbed += o.absorbed;
1851        self.trapped += o.trapped;
1852        self.event_capped += o.event_capped;
1853        self.polaron_trapped += o.polaron_trapped;
1854    }
1855}
1856
1857/// Electrons cut off by the collision cap ([`Fate::EventCap`]), a numerical
1858/// truncation diagnostic rather than a physical fate. The primaries' own caps
1859/// are [`FateCounts::event_capped`] (one fate per primary); these counts add
1860/// the secondaries. Both are plain counts (tracks, histories), not energies;
1861/// the energy the capped electrons still carried is
1862/// [`ElectronEnergyBudget::event_cap_ev`].
1863#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1864pub struct EventCapCounts {
1865    /// Secondary tracks cut off by the cap, one per capped secondary.
1866    #[serde(default)]
1867    pub secondary_tracks: u64,
1868    /// Primary histories in which the primary or at least one secondary was
1869    /// cut off by the cap, each counted once.
1870    #[serde(default)]
1871    pub affected_histories: u64,
1872}
1873
1874impl EventCapCounts {
1875    fn merge(&mut self, o: &EventCapCounts) {
1876        self.secondary_tracks += o.secondary_tracks;
1877        self.affected_histories += o.affected_histories;
1878    }
1879}
1880
1881/// The energy balance, eV, summed over all histories and every electron of
1882/// them (see [the module docs](self#the-energy-balance)):
1883/// `incident + fermi_sea + phonon_absorbed = deposited + escaped + trapped +
1884/// barrier`, with
1885///
1886/// - `deposited = inelastic + residual + phonon_emitted + polaron`: energy
1887///   left in the solid at inelastic events, plus the energy electrons had
1888///   left when they fell below the stopping threshold, plus the `ħω` given to
1889///   the lattice at LO-phonon emissions, plus the whole remaining energy of
1890///   electrons trapped as polarons;
1891/// - `phonon_absorbed`: the `ħω` electrons took from the lattice at LO-phonon
1892///   absorptions (a source; zero without the insulator channels);
1893/// - `escaped = escaped_front + escaped_back + escaped_side`;
1894/// - `trapped = no_interaction + absorbed + event_cap`: energy still carried
1895///   by electrons that ended in the target without falling below the
1896///   threshold (no interaction available, absorbed at the back face, or cut
1897///   off by the collision cap);
1898/// - `fermi_sea`: the kinetic energy liberated secondaries already had (a
1899///   source; zero without a secondary model);
1900/// - `barrier`: the kinetic energy taken by the potential steps at the faces,
1901///   the sum of `-ΔU` (signed; zero with transparent faces).
1902#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1903pub struct ElectronEnergyBudget {
1904    /// Energy of the primaries.
1905    pub incident_ev: f64,
1906    /// Deposited in the target, measured from the band bottom. With a
1907    /// secondary model in a layer with a Fermi energy this includes the
1908    /// Fermi-sea energy of liberated conduction electrons (`fermi_sea_ev`),
1909    /// so it can exceed the energy the beam imparted; the net energy imparted
1910    /// is `incident_ev - escaped_ev = deposited_ev + trapped_ev + barrier_ev -
1911    /// fermi_sea_ev - phonon_absorbed_ev` (see
1912    /// [the module docs](self#deposits-and-the-energy-imparted)).
1913    pub deposited_ev: f64,
1914    /// Carried out of the target.
1915    pub escaped_ev: f64,
1916    /// Carried by electrons that ended in the target at or above the
1917    /// stopping threshold.
1918    pub trapped_ev: f64,
1919    /// Energy left in the solid at inelastic events (part of
1920    /// `deposited_ev`): the loss `W` without a secondary model; with one,
1921    /// [`SecondaryEvent::deposited_ev`] plus any positive
1922    /// [`SecondaryEvent::binding_ev`].
1923    pub inelastic_ev: f64,
1924    /// Remaining energy of electrons that fell below the stopping threshold
1925    /// (part of `deposited_ev`).
1926    pub residual_ev: f64,
1927    /// Out of the front face (part of `escaped_ev`).
1928    pub escaped_front_ev: f64,
1929    /// Out of the back face (part of `escaped_ev`).
1930    pub escaped_back_ev: f64,
1931    /// Out of a lateral face of a voxel target (part of `escaped_ev`); zero
1932    /// for a stack.
1933    #[serde(default)]
1934    pub escaped_side_ev: f64,
1935    /// Electrons with no interaction and no face to reach (part of
1936    /// `trapped_ev`).
1937    pub no_interaction_ev: f64,
1938    /// Electrons absorbed at the back face (part of `trapped_ev`).
1939    pub absorbed_ev: f64,
1940    /// Electrons cut off by the collision cap (part of `trapped_ev`).
1941    pub event_cap_ev: f64,
1942    /// Energy the liberated secondaries brought with them, the sum of
1943    /// `-binding_ev` over events where it is negative (a source).
1944    pub fermi_sea_ev: f64,
1945    /// Kinetic energy taken by the potential steps, the sum of `-ΔU` over
1946    /// face transmissions (signed).
1947    pub barrier_ev: f64,
1948    /// `ħω` given to the lattice by LO-phonon emission (part of
1949    /// `deposited_ev`).
1950    #[serde(default)]
1951    pub phonon_emitted_ev: f64,
1952    /// `ħω` taken from the lattice by LO-phonon absorption (a source).
1953    #[serde(default)]
1954    pub phonon_absorbed_ev: f64,
1955    /// Remaining energy of electrons trapped as polarons (part of
1956    /// `deposited_ev`).
1957    #[serde(default)]
1958    pub polaron_ev: f64,
1959    /// Ionisation channel of `deposited_ev`: `inelastic_ev + residual_ev`
1960    /// (the residual is placed here by convention, see
1961    /// [the channel split](self#the-ionisation-and-lattice-channels)).
1962    #[serde(default)]
1963    pub ionisation_ev: f64,
1964    /// Lattice channel of `deposited_ev`: `phonon_emitted_ev + polaron_ev`.
1965    #[serde(default)]
1966    pub lattice_ev: f64,
1967    /// `|deposited + escaped + trapped + barrier - incident - fermi_sea| /
1968    /// (incident + fermi_sea)` (0 with no incident energy).
1969    pub relative_imbalance: f64,
1970}
1971
1972/// Emission yields per primary.
1973#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1974pub struct Yields {
1975    /// Backscatter yield `η`: fast electrons out of the front face per
1976    /// primary.
1977    pub backscatter_eta: f64,
1978    /// Secondary yield `δ`: slow electrons out of the front face per primary.
1979    pub secondary_delta: f64,
1980    /// Total yield `σ = η + δ`.
1981    pub total_sigma: f64,
1982    /// Fast electrons out of the back face per primary.
1983    pub transmitted_fast: f64,
1984    /// Slow electrons out of the back face per primary.
1985    pub transmitted_slow: f64,
1986}
1987
1988/// Electrons leaving through one face.
1989#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1990pub struct FaceEmission {
1991    /// Electrons.
1992    pub count: u64,
1993    /// Electrons per primary.
1994    pub per_primary: f64,
1995    /// Sum of their energies, eV.
1996    pub energy_ev: f64,
1997    /// Energy spectrum, eV, both classes.
1998    pub energy_histogram: Histogram,
1999    /// Slow electrons (`E < split`, the secondary class).
2000    pub slow: EmissionClass,
2001    /// Fast electrons (`E >= split`, the backscattered class).
2002    pub fast: EmissionClass,
2003}
2004
2005/// One energy class of the electrons leaving through a face.
2006#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2007pub struct EmissionClass {
2008    /// Electrons.
2009    pub count: u64,
2010    /// Electrons per primary.
2011    pub per_primary: f64,
2012    /// Sum of their energies, eV.
2013    pub energy_ev: f64,
2014    /// Mean energy, eV (0 if none).
2015    pub mean_energy_ev: f64,
2016    /// Polar angle from the outward surface normal, rad.
2017    pub polar_histogram: Histogram,
2018}
2019
2020/// Deposited energy by layer and on the configured grids, eV.
2021///
2022/// Deposits are measured from the band bottom. With a secondary model in a
2023/// layer with a Fermi energy they include the Fermi-sea energy of liberated
2024/// conduction electrons, so they can sum to more than the energy the beam
2025/// imparted; see [`ElectronEnergyBudget::deposited_ev`] and
2026/// [the module docs](self#deposits-and-the-energy-imparted).
2027#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2028pub struct DepositionReport {
2029    /// By the layer the deposit was made in.
2030    pub per_layer_ev: Vec<f64>,
2031    /// The ionisation part of `per_layer_ev`, eV (electronic excitation,
2032    /// including the residual of sub-cutoff electrons; see
2033    /// [the channel split](self#the-ionisation-and-lattice-channels)). Empty
2034    /// in reports written before it existed.
2035    #[serde(default)]
2036    pub per_layer_ionisation_ev: Vec<f64>,
2037    /// The lattice part of `per_layer_ev`, eV (LO-phonon emission and polaron
2038    /// traps); `ionisation + lattice = per_layer_ev` per layer.
2039    #[serde(default)]
2040    pub per_layer_lattice_ev: Vec<f64>,
2041    /// On the Cartesian grid, if configured.
2042    pub cartesian: Option<CartesianDeposition>,
2043    /// On the cylindrical grid, if configured.
2044    pub cylindrical: Option<CylindricalDeposition>,
2045    /// Radial profile in the PSF depth slab, with per-history errors, if
2046    /// configured ([`ElectronTallyConfig::psf`]); fit it with
2047    /// [`super::psf::fit_psf`].
2048    #[serde(default)]
2049    pub psf: Option<RadialProfile>,
2050    /// Exposure statistics, if configured ([`ElectronTallyConfig::exposure`]).
2051    #[serde(default)]
2052    pub exposure: Option<ExposureReport>,
2053}
2054
2055/// Deposited energy on a [`CartesianGrid`].
2056///
2057/// Measured from the band bottom: with a secondary model in a layer with a
2058/// Fermi energy the cells include the Fermi-sea energy of liberated
2059/// conduction electrons, so they can sum to more than the energy the beam
2060/// imparted (see [`ElectronEnergyBudget::deposited_ev`]).
2061#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2062pub struct CartesianDeposition {
2063    /// The grid.
2064    pub grid: CartesianGrid,
2065    /// Energy per voxel, eV, at [`CartesianGrid::index`].
2066    pub energy_ev: Vec<f64>,
2067    /// Energy deposited outside the grid, eV.
2068    pub outside_ev: f64,
2069    /// Ionisation part of `energy_ev` per voxel, eV; `ionisation + lattice =
2070    /// energy` per voxel. Empty in reports written before it existed.
2071    #[serde(default)]
2072    pub ionisation_ev: Vec<f64>,
2073    /// Lattice part of `energy_ev` per voxel, eV.
2074    #[serde(default)]
2075    pub lattice_ev: Vec<f64>,
2076    /// Ionisation part of `outside_ev`, eV.
2077    #[serde(default)]
2078    pub outside_ionisation_ev: f64,
2079    /// Lattice part of `outside_ev`, eV.
2080    #[serde(default)]
2081    pub outside_lattice_ev: f64,
2082}
2083
2084impl CartesianDeposition {
2085    /// Energy in voxel `(ix, iy, iz)`, eV.
2086    pub fn at(&self, ix: usize, iy: usize, iz: usize) -> f64 {
2087        self.energy_ev[self.grid.index(ix, iy, iz)]
2088    }
2089
2090    /// Energy per volume in each voxel, eV/m³.
2091    pub fn density_ev_per_m3(&self) -> Vec<f64> {
2092        let v = self.grid.voxel_volume_m3();
2093        self.energy_ev.iter().map(|e| e / v).collect()
2094    }
2095}
2096
2097/// Deposited energy on a [`CylindricalGrid`].
2098///
2099/// Measured from the band bottom: with a secondary model in a layer with a
2100/// Fermi energy the cells include the Fermi-sea energy of liberated
2101/// conduction electrons, so they can sum to more than the energy the beam
2102/// imparted (see [`ElectronEnergyBudget::deposited_ev`]).
2103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2104pub struct CylindricalDeposition {
2105    /// The grid.
2106    pub grid: CylindricalGrid,
2107    /// Energy per cell, eV, at [`CylindricalGrid::index`].
2108    pub energy_ev: Vec<f64>,
2109    /// Energy deposited outside the grid, eV.
2110    pub outside_ev: f64,
2111    /// Ionisation part of `energy_ev` per cell, eV; `ionisation + lattice =
2112    /// energy` per cell. Empty in reports written before it existed.
2113    #[serde(default)]
2114    pub ionisation_ev: Vec<f64>,
2115    /// Lattice part of `energy_ev` per cell, eV.
2116    #[serde(default)]
2117    pub lattice_ev: Vec<f64>,
2118    /// Ionisation part of `outside_ev`, eV.
2119    #[serde(default)]
2120    pub outside_ionisation_ev: f64,
2121    /// Lattice part of `outside_ev`, eV.
2122    #[serde(default)]
2123    pub outside_lattice_ev: f64,
2124}
2125
2126impl CylindricalDeposition {
2127    /// Energy in cell `(ir, ix)`, eV.
2128    pub fn at(&self, ir: usize, ix: usize) -> f64 {
2129        self.energy_ev[self.grid.index(ir, ix)]
2130    }
2131
2132    /// Energy per volume in each cell, eV/m³.
2133    pub fn density_ev_per_m3(&self) -> Vec<f64> {
2134        self.energy_ev
2135            .iter()
2136            .enumerate()
2137            .map(|(i, e)| e / self.grid.cell_volume_m3(i / self.grid.depth.bins))
2138            .collect()
2139    }
2140}
2141
2142/// Energy-weighted moments of the deposition position (weights: the energy
2143/// of each deposit). Lengths in metres.
2144#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2145pub struct GenerationVolume {
2146    /// Total weight: the deposited energy, eV.
2147    pub energy_ev: f64,
2148    /// Weighted mean of `[x, y, z]`.
2149    pub mean_m: [f64; 3],
2150    /// Weighted standard deviation of `[x, y, z]`.
2151    pub std_dev_m: [f64; 3],
2152    /// Weighted mean distance from the beam axis.
2153    pub mean_radius_m: f64,
2154    /// Weighted standard deviation of the distance from the beam axis.
2155    pub radius_std_dev_m: f64,
2156    /// Weighted RMS distance from the beam axis, `<y² + z²>^(1/2)`.
2157    pub rms_radius_m: f64,
2158}
2159
2160/// Where electrons fell below the stopping threshold (unweighted, one sample
2161/// each).
2162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2163pub struct StoppingPoints {
2164    /// Electrons that fell below the stopping threshold.
2165    pub stopped: u64,
2166    /// Depth moments, m; `None` with fewer than two.
2167    pub depth: Option<MomentSummary>,
2168    /// Moments of the distance from the beam axis, m.
2169    pub radial: Option<MomentSummary>,
2170    /// The primaries among `stopped`: those that fell below the threshold
2171    /// before any secondary of their history began (the transport follows a
2172    /// history's primary to its end first), i.e. histories whose fate is
2173    /// [`Fate::Stopped`]. Absent from reports written before it existed.
2174    #[serde(default)]
2175    pub primaries: PrimaryStoppingPoints,
2176}
2177
2178/// Where the primaries fell below the stopping threshold (unweighted, one
2179/// sample per stopped primary): the penetration depth of stopped primaries,
2180/// as compared with other codes in `docs/validation.md`.
2181#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
2182pub struct PrimaryStoppingPoints {
2183    /// Primaries that fell below the stopping threshold.
2184    pub stopped: u64,
2185    /// Depth moments, m; `None` with fewer than two.
2186    pub depth: Option<MomentSummary>,
2187    /// Moments of the distance from the beam axis, m.
2188    pub radial: Option<MomentSummary>,
2189}
2190
2191#[cfg(test)]
2192mod tests {
2193    use super::*;
2194
2195    #[test]
2196    fn weighted_moments_merge_matches_direct() {
2197        let xs = [(1.0, 2.0), (3.0, 1.0), (-2.0, 0.5), (7.0, 4.0), (0.5, 3.0)];
2198        let mut all = WeightedMoments::default();
2199        for &(x, w) in &xs {
2200            all.push(x, w);
2201        }
2202        let mut a = WeightedMoments::default();
2203        let mut b = WeightedMoments::default();
2204        for &(x, w) in &xs[..2] {
2205            a.push(x, w);
2206        }
2207        for &(x, w) in &xs[2..] {
2208            b.push(x, w);
2209        }
2210        a.merge(&b);
2211        let wsum: f64 = xs.iter().map(|p| p.1).sum();
2212        let mean = xs.iter().map(|p| p.0 * p.1).sum::<f64>() / wsum;
2213        let var = xs.iter().map(|p| p.1 * (p.0 - mean).powi(2)).sum::<f64>() / wsum;
2214        for m in [all, a] {
2215            assert!((m.weight - wsum).abs() < 1e-12);
2216            assert!((m.mean - mean).abs() < 1e-12);
2217            assert!((m.variance() - var).abs() < 1e-12);
2218        }
2219        // Zero weights are ignored.
2220        let before = all;
2221        all.push(100.0, 0.0);
2222        assert_eq!(all, before);
2223    }
2224
2225    #[test]
2226    fn grid_indices() {
2227        let g = CartesianGrid {
2228            x: Binning::new(0.0, 1.0, 2).unwrap(),
2229            y: Binning::new(-1.0, 1.0, 3).unwrap(),
2230            z: Binning::new(-1.0, 1.0, 4).unwrap(),
2231        };
2232        assert_eq!(g.voxels(), Some(24));
2233        assert_eq!(g.locate([0.75, 0.9, -0.9]), Some(g.index(1, 2, 0)));
2234        assert_eq!(g.index(1, 2, 0), 20);
2235        assert_eq!(g.locate([1.5, 0.0, 0.0]), None);
2236        let c = CylindricalGrid {
2237            r: Binning::new(0.0, 2.0, 2).unwrap(),
2238            depth: Binning::new(0.0, 1.0, 5).unwrap(),
2239        };
2240        assert_eq!(c.locate([0.3, 0.6, 0.8]), Some(c.index(1, 1)));
2241        let total: f64 = (0..2).map(|i| c.cell_volume_m3(i)).sum::<f64>() * 5.0;
2242        assert!((total - std::f64::consts::PI * 4.0).abs() < 1e-12);
2243    }
2244}