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}