Skip to main content

lindhard/tally/
dose.rs

1//! Dose to named layers, depth-dose through a stack, and interface
2//! transmission, for the electron tally.
3//!
4//! Everything here is bookkeeping on the deposits and interface crossings the
5//! transport already reports; nothing changes the transport. The accumulators
6//! are opt-in through [`DoseConfig`] on
7//! [`ElectronTallyConfig`](super::ElectronTallyConfig), so a run without it is
8//! unchanged.
9//!
10//! # Conventions
11//!
12//! - **Per-history errors.** A history's contribution to a bin is the sum over
13//!   the primary and all its secondaries. The reported mean is the sum over
14//!   histories divided by the number of histories, and the standard error is
15//!   `sqrt((Σx² − (Σx)²/N) / (N (N − 1)))`. It is `NaN` for fewer than two
16//!   histories.
17//! - **Dose per fluence.** The target is laterally infinite, so lateral escape
18//!   cancels and the energy deposited in a layer per primary, divided by the
19//!   layer's areal mass density `ρ t`, is the absorbed dose per unit incident
20//!   fluence, in Gy m² (`J kg⁻¹ m²`). Multiply by a fluence in m⁻² for a dose.
21//!   A semi-infinite last layer has no finite areal mass, so it is reported as
22//!   energy only (its [`LayerDose::dose_gy_m2`] is `None`).
23//! - **Depth-dose grid.** Each finite layer gets `bins_per_layer` uniform bins
24//!   over its middle and, with [`InterfaceRefine`], `bins` finer uniform bins
25//!   in the band of width `width_m` next to each of its faces (at most half
26//!   the layer each side). A semi-infinite last layer is binned over
27//!   [`DepthDoseConfig::substrate_depth_m`]; energy deeper than that is kept
28//!   in [`DepthDose::outside_ev`]. A deposit is binned by the layer it was
29//!   made in (the transport's `layer`), clamped to that layer's extent, so the
30//!   bins of a layer sum to its layer total to summation error.
31//! - **Deposition point.** The same as the rest of the electron tally: an
32//!   inelastic loss at the collision point, the sub-threshold remainder at the
33//!   stopping point.
34//! - **Transmission.** One count per crossing from the transport's
35//!   `interface` hook: an electron that crosses an interface several times is
36//!   counted each time. Primaries and secondaries are separate entries. The
37//!   energy is the kinetic energy on the new side of the face (after
38//!   refraction with a step barrier). Forward is towards larger depth.
39//! - **Deposits are measured from the band bottom**, like
40//!   [`DepositionReport`](super::DepositionReport); with a secondary model in
41//!   a layer with a Fermi energy they include the Fermi-sea energy of liberated
42//!   conduction electrons.
43
44use serde::{Deserialize, Serialize};
45
46use super::hist::{Binning, Histogram};
47use crate::geometry::Stack;
48use crate::units::J_PER_EV;
49
50/// Refined depth bins next to each interface.
51#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
52pub struct InterfaceRefine {
53    /// Width of the refined band on each side of an interface, m. It is
54    /// limited to half the layer, so the two bands of a thin layer do not
55    /// overlap.
56    pub width_m: f64,
57    /// Uniform bins in each refined band.
58    pub bins: usize,
59}
60
61/// The 1D depth-dose grid.
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63pub struct DepthDoseConfig {
64    /// Uniform bins over the middle of each layer (the whole layer without
65    /// refinement), at least 1.
66    pub bins_per_layer: usize,
67    /// Optional refinement next to each interface and the front face.
68    #[serde(default)]
69    pub refine: Option<InterfaceRefine>,
70    /// Depth binned in a semi-infinite last layer, m; ignored without one.
71    #[serde(default)]
72    pub substrate_depth_m: f64,
73}
74
75/// Settings of the dose, depth-dose and transmission tallies.
76#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
77pub struct DoseConfig {
78    /// Depth-dose profile, if wanted. The per-layer totals are always on.
79    #[serde(default)]
80    pub depth: Option<DepthDoseConfig>,
81    /// Count electrons crossing each interface, with spectra on the tally's
82    /// escape energy binning.
83    #[serde(default)]
84    pub transmission: bool,
85}
86
87/// An invalid [`DoseConfig`].
88#[derive(Debug, Clone, PartialEq, thiserror::Error)]
89#[error("invalid dose configuration: {0}")]
90pub struct DoseError(pub String);
91
92/// A mean with its standard error.
93#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
94pub struct Estimate {
95    /// Mean per primary.
96    pub mean: f64,
97    /// Standard error of the mean (`NaN` for fewer than two histories).
98    pub std_err: f64,
99}
100
101impl Estimate {
102    fn scaled(self, k: f64) -> Self {
103        Self {
104            mean: self.mean * k,
105            std_err: self.std_err * k.abs(),
106        }
107    }
108}
109
110/// Per-history sums for a fixed set of bins. The current history's totals are
111/// folded into `Σx` and `Σx²` by [`HistorySums::end_history`].
112#[derive(Debug, Clone, PartialEq)]
113struct HistorySums {
114    histories: u64,
115    sum: Vec<f64>,
116    sum_sq: Vec<f64>,
117    scratch: Vec<f64>,
118    touched: Vec<usize>,
119}
120
121impl HistorySums {
122    fn new(n: usize) -> Self {
123        Self {
124            histories: 0,
125            sum: vec![0.0; n],
126            sum_sq: vec![0.0; n],
127            scratch: vec![0.0; n],
128            touched: Vec::new(),
129        }
130    }
131
132    fn add(&mut self, i: usize, x: f64) {
133        if x <= 0.0 {
134            return;
135        }
136        if self.scratch[i] == 0.0 {
137            self.touched.push(i);
138        }
139        self.scratch[i] += x;
140    }
141
142    fn end_history(&mut self) {
143        self.histories += 1;
144        for &i in &self.touched {
145            let s = self.scratch[i];
146            self.sum[i] += s;
147            self.sum_sq[i] += s * s;
148            self.scratch[i] = 0.0;
149        }
150        self.touched.clear();
151    }
152
153    fn merge(&mut self, o: &HistorySums) {
154        self.histories += o.histories;
155        for (a, b) in self.sum.iter_mut().zip(&o.sum) {
156            *a += b;
157        }
158        for (a, b) in self.sum_sq.iter_mut().zip(&o.sum_sq) {
159            *a += b;
160        }
161    }
162
163    fn estimate(&self, i: usize) -> Estimate {
164        let n = self.histories as f64;
165        if self.histories == 0 {
166            return Estimate {
167                mean: 0.0,
168                std_err: f64::NAN,
169            };
170        }
171        let mean = self.sum[i] / n;
172        let std_err = if self.histories < 2 {
173            f64::NAN
174        } else {
175            let var = (self.sum_sq[i] - self.sum[i] * self.sum[i] / n).max(0.0);
176            (var / (n * (n - 1.0))).sqrt()
177        };
178        Estimate { mean, std_err }
179    }
180}
181
182/// Static description of one layer, taken from the stack.
183#[derive(Debug, Clone, PartialEq)]
184struct LayerInfo {
185    name: Option<String>,
186    front_m: f64,
187    back_m: f64,
188    density_kg_m3: f64,
189    /// Bin edges of this layer in the depth profile (`bins + 1` entries), and
190    /// the index of its first bin; empty edges without a depth profile.
191    edges: Vec<f64>,
192    first_bin: usize,
193}
194
195impl LayerInfo {
196    fn areal_mass(&self) -> Option<f64> {
197        let t = self.back_m - self.front_m;
198        t.is_finite().then_some(self.density_kg_m3 * t)
199    }
200}
201
202fn layer_edges(front: f64, back: f64, finite: bool, cfg: &DepthDoseConfig) -> Vec<f64> {
203    let t = back - front;
204    let mut edges = Vec::new();
205    let band = cfg
206        .refine
207        .map(|r| r.width_m.min(0.5 * t))
208        .unwrap_or(0.0)
209        .max(0.0);
210    let fine = cfg.refine.map(|r| r.bins).unwrap_or(0);
211    let refine_front = band > 0.0 && fine > 0;
212    // A semi-infinite layer is refined at its front face only.
213    let refine_back = refine_front && finite;
214    let mid_lo = if refine_front { front + band } else { front };
215    let mid_hi = if refine_back { back - band } else { back };
216    if refine_front {
217        for i in 0..fine {
218            edges.push(front + band * i as f64 / fine as f64);
219        }
220    }
221    for i in 0..cfg.bins_per_layer {
222        edges.push(mid_lo + (mid_hi - mid_lo) * i as f64 / cfg.bins_per_layer as f64);
223    }
224    if refine_back {
225        for i in 0..fine {
226            edges.push(mid_hi + band * i as f64 / fine as f64);
227        }
228    }
229    edges.push(back);
230    // Drop coincident edges that rounding may produce.
231    edges.dedup();
232    edges
233}
234
235/// The accumulators of the dose tallies of one [`FullElectronTally`](super::FullElectronTally).
236#[derive(Debug, Clone, PartialEq)]
237pub(crate) struct DoseAcc {
238    config: DoseConfig,
239    layers: Vec<LayerInfo>,
240    /// Per layer.
241    layer_sums: HistorySums,
242    /// Per depth bin, then one `outside` entry.
243    depth_sums: Option<HistorySums>,
244    n_bins: usize,
245    /// Per `(interface, direction, class)`: count at `2 k`, energy at `2 k + 1`.
246    transmission_sums: Option<HistorySums>,
247    spectra: Vec<Histogram>,
248}
249
250impl DoseAcc {
251    pub(crate) fn new(
252        stack: &Stack,
253        config: DoseConfig,
254        escape_energy: Binning,
255    ) -> Result<Self, DoseError> {
256        if let Some(d) = &config.depth {
257            if d.bins_per_layer == 0 {
258                return Err(DoseError("bins_per_layer must be at least 1".into()));
259            }
260            if let Some(r) = &d.refine {
261                if !(r.width_m.is_finite() && r.width_m >= 0.0) {
262                    return Err(DoseError(format!(
263                        "refine.width_m must be finite and non-negative, got {}",
264                        r.width_m
265                    )));
266                }
267            }
268            let last_infinite = stack
269                .layers()
270                .last()
271                .is_some_and(|l| !l.back_m().is_finite());
272            if last_infinite && !(d.substrate_depth_m.is_finite() && d.substrate_depth_m > 0.0) {
273                return Err(DoseError(
274                    "substrate_depth_m must be finite and positive for a semi-infinite last layer"
275                        .into(),
276                ));
277            }
278        }
279        let mut n_bins = 0usize;
280        let mut layers = Vec::new();
281        for l in stack.layers() {
282            let (edges, first_bin) = match &config.depth {
283                Some(d) => {
284                    let back = if l.back_m().is_finite() {
285                        l.back_m()
286                    } else {
287                        l.front_m() + d.substrate_depth_m
288                    };
289                    let e = layer_edges(l.front_m(), back, l.back_m().is_finite(), d);
290                    let first = n_bins;
291                    n_bins += e.len() - 1;
292                    (e, first)
293                }
294                None => (Vec::new(), 0),
295            };
296            layers.push(LayerInfo {
297                name: l.material().name().map(str::to_string),
298                front_m: l.front_m(),
299                back_m: l.back_m(),
300                density_kg_m3: l.material().mass_density(),
301                edges,
302                first_bin,
303            });
304        }
305        let n_if = layers.len().saturating_sub(1);
306        Ok(Self {
307            config,
308            layer_sums: HistorySums::new(layers.len()),
309            depth_sums: config.depth.map(|_| HistorySums::new(n_bins + 1)),
310            n_bins,
311            transmission_sums: config.transmission.then(|| HistorySums::new(n_if * 4 * 2)),
312            spectra: if config.transmission {
313                (0..n_if * 4)
314                    .map(|_| Histogram::new(escape_energy))
315                    .collect()
316            } else {
317                Vec::new()
318            },
319            layers,
320        })
321    }
322
323    pub(crate) fn deposit(&mut self, layer: usize, x: f64, e: f64) {
324        self.layer_sums.add(layer, e);
325        if let Some(d) = &mut self.depth_sums {
326            let info = &self.layers[layer];
327            let last = *info.edges.last().expect("depth edges built");
328            if x >= last && !info.back_m.is_finite() {
329                d.add(self.n_bins, e);
330                return;
331            }
332            let xc = x.clamp(info.edges[0], last);
333            let nb = info.edges.len() - 1;
334            let i = info.edges[1..nb].partition_point(|&edge| edge <= xc);
335            d.add(info.first_bin + i, e);
336        }
337    }
338
339    /// A crossing of interface `from -> to` (adjacent layers).
340    pub(crate) fn crossing(&mut self, from: usize, to: usize, energy_ev: f64, secondary: bool) {
341        let Some(t) = &mut self.transmission_sums else {
342            return;
343        };
344        let (interface, backward) = if to > from { (from, 0) } else { (to, 1) };
345        let k = (interface * 2 + backward) * 2 + usize::from(secondary);
346        t.add(2 * k, 1.0);
347        t.add(2 * k + 1, energy_ev);
348        self.spectra[k].fill(energy_ev);
349    }
350
351    pub(crate) fn end_history(&mut self) {
352        self.layer_sums.end_history();
353        if let Some(d) = &mut self.depth_sums {
354            d.end_history();
355        }
356        if let Some(t) = &mut self.transmission_sums {
357            t.end_history();
358        }
359    }
360
361    pub(crate) fn merge(&mut self, o: &DoseAcc) {
362        self.layer_sums.merge(&o.layer_sums);
363        if let (Some(a), Some(b)) = (&mut self.depth_sums, &o.depth_sums) {
364            a.merge(b);
365        }
366        if let (Some(a), Some(b)) = (&mut self.transmission_sums, &o.transmission_sums) {
367            a.merge(b);
368        }
369        for (a, b) in self.spectra.iter_mut().zip(&o.spectra) {
370            for (x, y) in a.counts.iter_mut().zip(&b.counts) {
371                *x += y;
372            }
373            a.underflow += b.underflow;
374            a.overflow += b.overflow;
375        }
376    }
377
378    pub(crate) fn report(&self, incident_ev: f64) -> DoseReport {
379        let layers = self
380            .layers
381            .iter()
382            .enumerate()
383            .map(|(i, l)| {
384                let e = self.layer_sums.estimate(i);
385                LayerDose {
386                    index: i,
387                    name: l.name.clone(),
388                    thickness_m: (l.back_m - l.front_m)
389                        .is_finite()
390                        .then_some(l.back_m - l.front_m),
391                    areal_mass_kg_m2: l.areal_mass(),
392                    energy_ev: e,
393                    dose_gy_m2: l.areal_mass().map(|m| e.scaled(J_PER_EV / m)),
394                }
395            })
396            .collect();
397        let depth = self.depth_sums.as_ref().map(|d| {
398            let mut edges_m = Vec::new();
399            let mut layer_of_bin = Vec::new();
400            let mut energy_ev = Vec::new();
401            let mut ev_per_nm = Vec::new();
402            let mut dose = Vec::new();
403            for (li, l) in self.layers.iter().enumerate() {
404                for b in 0..l.edges.len() - 1 {
405                    let (lo, hi) = (l.edges[b], l.edges[b + 1]);
406                    let e = d.estimate(l.first_bin + b);
407                    let w = hi - lo;
408                    edges_m.push(lo);
409                    layer_of_bin.push(li);
410                    energy_ev.push(e);
411                    ev_per_nm.push(e.scaled(1e-9 / w));
412                    dose.push(
413                        l.back_m
414                            .is_finite()
415                            .then(|| e.scaled(J_PER_EV / (l.density_kg_m3 * w))),
416                    );
417                }
418            }
419            edges_m.push(
420                self.layers
421                    .last()
422                    .and_then(|l| l.edges.last().copied())
423                    .unwrap_or(0.0),
424            );
425            DepthDose {
426                edges_m,
427                layer_of_bin,
428                energy_ev,
429                ev_per_nm,
430                dose_gy_m2: dose,
431                outside_ev: d.estimate(self.n_bins),
432            }
433        });
434        let transmission = self
435            .transmission_sums
436            .as_ref()
437            .map(|t| {
438                let n_if = self.layers.len().saturating_sub(1);
439                let mut out = Vec::new();
440                for interface in 0..n_if {
441                    for backward in 0..2 {
442                        for secondary in 0..2 {
443                            let k = (interface * 2 + backward) * 2 + secondary;
444                            let energy = t.estimate(2 * k + 1);
445                            let total_energy = t.sum[2 * k + 1];
446                            out.push(InterfaceTransmission {
447                                interface,
448                                from_layer: if backward == 0 {
449                                    interface
450                                } else {
451                                    interface + 1
452                                },
453                                to_layer: if backward == 0 {
454                                    interface + 1
455                                } else {
456                                    interface
457                                },
458                                secondary: secondary == 1,
459                                count: t.estimate(2 * k),
460                                energy_ev: energy,
461                                energy_fraction: if incident_ev > 0.0 {
462                                    total_energy / incident_ev
463                                } else {
464                                    0.0
465                                },
466                                spectrum: self.spectra[k].clone(),
467                            });
468                        }
469                    }
470                }
471                out
472            })
473            .unwrap_or_default();
474        DoseReport {
475            layers,
476            depth,
477            transmission,
478        }
479    }
480}
481
482/// Energy and dose in one layer, per primary.
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484pub struct LayerDose {
485    /// Layer index (0 = front).
486    pub index: usize,
487    /// The material's name, if it has one.
488    pub name: Option<String>,
489    /// Thickness, m; `None` for a semi-infinite layer.
490    pub thickness_m: Option<f64>,
491    /// Areal mass density `ρ t`, kg/m²; `None` for a semi-infinite layer.
492    pub areal_mass_kg_m2: Option<f64>,
493    /// Energy deposited in the layer per primary, eV.
494    pub energy_ev: Estimate,
495    /// Absorbed dose per unit incident fluence, Gy m²; `None` for a
496    /// semi-infinite layer.
497    pub dose_gy_m2: Option<Estimate>,
498}
499
500/// The depth-dose profile through the stack.
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502pub struct DepthDose {
503    /// Bin edges, m: bin `i` is `[edges_m[i], edges_m[i + 1])`, so there is one
504    /// more edge than bins. Layer boundaries are shared edges.
505    pub edges_m: Vec<f64>,
506    /// The layer of each bin.
507    pub layer_of_bin: Vec<usize>,
508    /// Energy deposited per primary in each bin, eV.
509    pub energy_ev: Vec<Estimate>,
510    /// The same, per nm of depth, eV per primary per nm.
511    pub ev_per_nm: Vec<Estimate>,
512    /// Dose per unit incident fluence in each bin, Gy m²; `None` in a
513    /// semi-infinite layer.
514    pub dose_gy_m2: Vec<Option<Estimate>>,
515    /// Energy deposited per primary deeper than the binned depth of a
516    /// semi-infinite layer, eV.
517    pub outside_ev: Estimate,
518}
519
520/// Electrons crossing one interface in one direction.
521#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
522pub struct InterfaceTransmission {
523    /// Interface index: between layers `interface` and `interface + 1`.
524    pub interface: usize,
525    /// Layer left.
526    pub from_layer: usize,
527    /// Layer entered. Forward is `to_layer > from_layer`.
528    pub to_layer: usize,
529    /// Secondaries (`true`) or primaries (`false`).
530    pub secondary: bool,
531    /// Crossings per primary.
532    pub count: Estimate,
533    /// Kinetic energy carried across per primary, eV.
534    pub energy_ev: Estimate,
535    /// That energy as a fraction of the incident energy of all primaries.
536    pub energy_fraction: f64,
537    /// Kinetic energy spectrum of the crossings, on the tally's escape energy
538    /// binning (raw counts).
539    pub spectrum: Histogram,
540}
541
542/// Result of the dose tallies.
543#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
544pub struct DoseReport {
545    /// Per layer.
546    pub layers: Vec<LayerDose>,
547    /// Depth-dose profile, if configured.
548    pub depth: Option<DepthDose>,
549    /// Interface crossings, if configured: four entries per interface
550    /// (forward and back, primary then secondary).
551    pub transmission: Vec<InterfaceTransmission>,
552}