Skip to main content

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}