lindhard/tally/table_coverage.rs
1//! How often the electron transport read each cross-section table inside its
2//! energy grid, and how often it used the constant continuation beyond an end.
3//!
4//! The transport interpolates the rate (and the inverse CDF) of each table
5//! linearly in energy between the rows of the table's own grid, and holds the
6//! first or last row constant outside it (`crate::electron::transport`, module
7//! docs, "Free flight"). That continuation is a numerical choice, not physics:
8//! a validation run whose electrons spend many flights beyond a grid end
9//! measures the continuation as much as the model. The input resolver can
10//! warn when a configured grid does not cover the expected energies; this
11//! tally counts what actually happened, primaries and secondaries together.
12//!
13//! # What is counted
14//!
15//! One count per call of
16//! [`ElectronTally::table_lookup`],
17//! per layer and per channel (elastic, inelastic), split by where the energy
18//! lay against that table's grid `[E_min, E_max]`:
19//!
20//! - `below`: `E < E_min`, the first row was used;
21//! - `within`: `E_min <= E <= E_max`, endpoints included (an energy equal to
22//! an endpoint reads that row exactly, with no continuation);
23//! - `above`: `E > E_max`, the last row was used.
24//!
25//! The transport evaluates both rates of the electron's layer once per pass
26//! of its loop, before drawing a free path. So the counts are **rate
27//! evaluations**, not collisions: a flight cut short at a layer face, a pass
28//! after a face reflection and a flight with zero total rate are each one
29//! evaluation of both tables and no collision, and a channel with zero rate
30//! is evaluated (and counted) like any other. They are not fractions of
31//! deposited energy or of path length either. The elastic and inelastic
32//! counts of a layer are always equal in total; they differ in their split
33//! when the two grids differ.
34//!
35//! # Determinism
36//!
37//! All counts are integers, merged by addition in chunk order, so they are
38//! identical at any thread count for a fixed seed and chunk size. Counting
39//! draws no random number and changes no state of the transport.
40
41use serde::{Deserialize, Serialize};
42
43use crate::electron::data::CrossSectionTable;
44use crate::electron::transport::{
45 ElectronState, ElectronTally, GridCoverage, TableChannel, Transport,
46};
47
48/// Rate evaluations of one table, by where the energy lay against its grid,
49/// with the grid's bounds.
50#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
51pub struct TableCoverageCounts {
52 /// First energy of the table's grid, eV.
53 pub energy_min_ev: f64,
54 /// Last energy of the table's grid, eV.
55 pub energy_max_ev: f64,
56 /// Evaluations at `E < energy_min_ev` (first row continued).
57 pub below: u64,
58 /// Evaluations at `energy_min_ev <= E <= energy_max_ev`.
59 pub within: u64,
60 /// Evaluations at `E > energy_max_ev` (last row continued).
61 pub above: u64,
62}
63
64impl TableCoverageCounts {
65 /// No evaluation yet, with the bounds of `table`'s grid.
66 pub fn new(table: &CrossSectionTable) -> Self {
67 let g = table.energy_ev();
68 Self {
69 energy_min_ev: g[0],
70 energy_max_ev: g[g.len() - 1],
71 below: 0,
72 within: 0,
73 above: 0,
74 }
75 }
76
77 /// All evaluations.
78 pub fn total(&self) -> u64 {
79 self.below + self.within + self.above
80 }
81
82 fn add(&mut self, c: GridCoverage) {
83 match c {
84 GridCoverage::Below => self.below += 1,
85 GridCoverage::Within => self.within += 1,
86 GridCoverage::Above => self.above += 1,
87 }
88 }
89
90 fn merge(&mut self, o: &Self) {
91 self.below += o.below;
92 self.within += o.within;
93 self.above += o.above;
94 }
95}
96
97/// Rate evaluations of the two tables of one layer.
98#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
99pub struct LayerTableCoverage {
100 /// Layer index (0 = front).
101 pub layer: usize,
102 /// The elastic table.
103 pub elastic: TableCoverageCounts,
104 /// The inelastic table.
105 pub inelastic: TableCoverageCounts,
106}
107
108/// Counts the rate evaluations of every table of a run against its grid (see
109/// [the module docs](self)). Usable on its own as an [`ElectronTally`];
110/// [`crate::tally::FullElectronTally`] carries one and puts its
111/// [`TableCoverageTally::layers`] in
112/// [`ElectronReport::table_coverage`](crate::tally::ElectronReport::table_coverage).
113#[derive(Debug, Clone, PartialEq)]
114pub struct TableCoverageTally {
115 layers: Vec<LayerTableCoverage>,
116}
117
118impl TableCoverageTally {
119 /// Empty counts for the tables of `transport`, one entry per layer.
120 pub fn new<G: crate::geometry::Geometry>(transport: &Transport<G>) -> Self {
121 Self {
122 layers: transport
123 .layer_tables()
124 .iter()
125 .enumerate()
126 .map(|(layer, t)| LayerTableCoverage {
127 layer,
128 elastic: TableCoverageCounts::new(&t.elastic),
129 inelastic: TableCoverageCounts::new(&t.inelastic),
130 })
131 .collect(),
132 }
133 }
134
135 /// The counts, by layer.
136 pub fn layers(&self) -> &[LayerTableCoverage] {
137 &self.layers
138 }
139
140 /// Count one evaluation.
141 pub fn record(&mut self, layer: usize, channel: TableChannel, coverage: GridCoverage) {
142 let l = &mut self.layers[layer];
143 match channel {
144 TableChannel::Elastic => l.elastic.add(coverage),
145 TableChannel::Inelastic => l.inelastic.add(coverage),
146 }
147 }
148
149 /// Add `o`'s counts (a later chunk) to these.
150 ///
151 /// # Panics
152 /// If `o` was built for tables with a different number of layers or
153 /// different grid bounds.
154 pub fn merge_from(&mut self, o: &Self) {
155 assert!(
156 self.layers.len() == o.layers.len()
157 && self.layers.iter().zip(&o.layers).all(|(a, b)| {
158 a.elastic.energy_min_ev == b.elastic.energy_min_ev
159 && a.elastic.energy_max_ev == b.elastic.energy_max_ev
160 && a.inelastic.energy_min_ev == b.inelastic.energy_min_ev
161 && a.inelastic.energy_max_ev == b.inelastic.energy_max_ev
162 }),
163 "merging table-coverage tallies built for different tables"
164 );
165 for (a, b) in self.layers.iter_mut().zip(&o.layers) {
166 a.elastic.merge(&b.elastic);
167 a.inelastic.merge(&b.inelastic);
168 }
169 }
170}
171
172impl ElectronTally for TableCoverageTally {
173 fn table_lookup(&mut self, at: &ElectronState, channel: TableChannel, coverage: GridCoverage) {
174 self.record(at.layer, channel, coverage);
175 }
176
177 fn merge(&mut self, other: Self) {
178 self.merge_from(&other);
179 }
180}