Skip to main content

lindhard/ion/bca/
tally.rs

1//! What the BCA engine reports, and a small built-in summary tally.
2//!
3//! The engine calls the [`BcaTally`] hooks as events happen. Every hook has a
4//! no-op default, so a tally implements only what it needs; the depth,
5//! lateral and damage tallies of a later milestone plug in here without any
6//! change to the engine. Tallies are merged in chunk order by
7//! [`crate::rng::run_particles`], so a tally whose `merge` is a plain sum is
8//! bit-identical at any thread count.
9
10use super::Particle;
11
12pub use crate::geometry::Face;
13
14/// Where an amount of nuclear energy given to the lattice came from.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LatticeDeposit {
17    /// A transfer `T <= E_d` (or `T <= E_b`): no atom is displaced and all of
18    /// `T` stays at the collision site.
19    Subthreshold,
20    /// The lattice binding energy `E_b` subtracted from a displaced recoil.
21    Binding,
22    /// The transfer of a weak collision ([`super::BcaConfig::weak_collisions`]).
23    /// Weak collisions make no recoils, whatever `T` is (Moller and Eckstein,
24    /// IPP 9/64 (1988), p. 26), so all of `T` stays at the collision site.
25    Weak,
26}
27
28/// Electronic energy-loss channel.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ElectronicChannel {
31    /// Continuous (nonlocal) loss along a free-flight segment.
32    NonLocal,
33    /// Local loss at a collision (Oen-Robinson).
34    Local,
35}
36
37/// Energy bookkeeping for one primary history, eV.
38///
39/// Every eV of the incident energy ends in exactly one field, so
40/// [`EnergyBudget::residual`] is zero up to floating-point rounding. A
41/// surface barrier `E_s` paid by an escaping particle is counted in
42/// `surface_barrier`, not in the escaped energy (which is the energy outside
43/// the target).
44#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
45pub struct EnergyBudget {
46    /// Incident kinetic energy of the primary.
47    pub incident: f64,
48    /// Nonlocal electronic loss, all particles.
49    pub electronic_nonlocal: f64,
50    /// Local electronic loss at collisions, all particles.
51    pub electronic_local: f64,
52    /// Nuclear energy left in the lattice: subthreshold transfers, weak-
53    /// collision transfers, and `E_b` of displaced recoils.
54    pub lattice: f64,
55    /// Work done against the planar surface barrier by escaping particles.
56    pub surface_barrier: f64,
57    /// Energy carried out of the front face by the primary.
58    pub backscattered: f64,
59    /// Energy carried out of the front face by recoils (sputtered atoms).
60    pub sputtered: f64,
61    /// Energy carried out of the back face (primary and recoils).
62    pub transmitted: f64,
63    /// Energy carried out of a lateral face of a voxel grid ([`Face::Side`];
64    /// primary and recoils). Always zero for a stack, and omitted from the
65    /// serialised form when zero.
66    #[serde(default, skip_serializing_if = "is_zero")]
67    pub lateral: f64,
68    /// Kinetic energy of particles when they stopped (below their cutoff, or
69    /// recoils not followed).
70    pub rest: f64,
71}
72
73fn is_zero(v: &f64) -> bool {
74    *v == 0.0
75}
76
77impl EnergyBudget {
78    /// `incident` minus the sum of all other fields.
79    pub fn residual(&self) -> f64 {
80        self.incident
81            - (self.electronic_nonlocal
82                + self.electronic_local
83                + self.lattice
84                + self.surface_barrier
85                + self.backscattered
86                + self.sputtered
87                + self.transmitted
88                + self.lateral
89                + self.rest)
90    }
91
92    /// Field-wise sum (for merging).
93    pub fn add(&mut self, o: &EnergyBudget) {
94        self.incident += o.incident;
95        self.electronic_nonlocal += o.electronic_nonlocal;
96        self.electronic_local += o.electronic_local;
97        self.lattice += o.lattice;
98        self.surface_barrier += o.surface_barrier;
99        self.backscattered += o.backscattered;
100        self.sputtered += o.sputtered;
101        self.transmitted += o.transmitted;
102        self.lateral += o.lateral;
103        self.rest += o.rest;
104    }
105}
106
107/// Event hooks called by the engine. All default to no-ops except `merge`.
108///
109/// Positions are `[x, y, z]` in metres, `x` the depth. The `Particle` passed
110/// is the state **after** the event (for example after the energy loss).
111pub trait BcaTally: Send {
112    /// A primary history starts.
113    fn begin_history(&mut self, _index: u64) {}
114
115    /// Electronic loss of `energy_ev`. For [`ElectronicChannel::NonLocal`]
116    /// it was spread along the straight segment from `from` to `p.pos`; for
117    /// [`ElectronicChannel::Local`] it was deposited at `p.pos`.
118    fn electronic(
119        &mut self,
120        _p: &Particle,
121        _from: [f64; 3],
122        _channel: ElectronicChannel,
123        _energy_ev: f64,
124    ) {
125    }
126
127    /// A collision partner of `p`, reported **before** the collision changes
128    /// `p` (so `p` is the incoming state, unlike in the other hooks):
129    /// `impact_parameter_m` is the distance from the partner to the incoming
130    /// straight path, and `partners` the number of partners of this collision
131    /// step, each of which is reported by its own call. The amorphous model
132    /// reports its hard collision and each weak collision as a step of one
133    /// partner; the crystal flight model reports every simultaneous partner
134    /// of a step with the same `partners`.
135    fn partner(&mut self, _p: &Particle, _impact_parameter_m: f64, _partners: usize) {}
136
137    /// Nuclear energy `energy_ev` left in the lattice at `at` in region
138    /// `layer` (the layer index of a stack, the flat voxel index of a voxel
139    /// grid).
140    fn lattice(&mut self, _at: [f64; 3], _layer: usize, _kind: LatticeDeposit, _energy_ev: f64) {}
141
142    /// A target atom was displaced (transfer above `E_d`); `recoil` is its
143    /// state at creation, before it is followed (or stopped, if below the
144    /// recoil cutoff or if cascades are off).
145    fn recoil(&mut self, _recoil: &Particle) {}
146
147    /// A particle came to rest at `p.pos` with kinetic energy `p.energy_ev`.
148    fn stopped(&mut self, _p: &Particle) {}
149
150    /// A particle left the target through `face`; `p` is its state outside
151    /// (after refraction by the surface barrier).
152    fn escaped(&mut self, _p: &Particle, _face: Face) {}
153
154    /// A primary history ended; `budget` is its energy bookkeeping.
155    fn end_history(&mut self, _index: u64, _budget: &EnergyBudget) {}
156
157    /// Fold `other` (a later chunk) into `self`.
158    fn merge(&mut self, other: Self)
159    where
160        Self: Sized;
161}
162
163/// A minimal summary: counts, energy sums and a depth histogram of where
164/// primaries stop. Enough to test the engine; the full tallies come later.
165#[derive(Debug, Clone, PartialEq)]
166pub struct SummaryTally {
167    /// Primary histories run.
168    pub histories: u64,
169    /// Sum of the per-history energy budgets.
170    pub budget: EnergyBudget,
171    /// Largest `|residual| / incident` over all histories.
172    pub max_relative_residual: f64,
173    /// Primaries that came to rest in the target.
174    pub primaries_stopped: u64,
175    /// Primaries that left through the front face.
176    pub backscattered: u64,
177    /// Primaries that left through the back face.
178    pub transmitted: u64,
179    /// Recoils that left through the front face.
180    pub sputtered: u64,
181    /// Recoils that left through the back face.
182    pub recoils_transmitted: u64,
183    /// Primaries that left through a lateral face of a voxel grid.
184    pub lateral: u64,
185    /// Recoils that left through a lateral face of a voxel grid.
186    pub recoils_lateral: u64,
187    /// Displaced atoms (recoils created).
188    pub recoils: u64,
189    /// Sum of the rest depth of stopped primaries, m.
190    pub depth_sum: f64,
191    /// Sum of squared rest depths of stopped primaries, m².
192    pub depth_sq_sum: f64,
193    /// Width of a depth bin, m.
194    pub bin_width_m: f64,
195    /// Stopped-primary counts per depth bin; the last bin collects overflow.
196    pub depth_hist: Vec<u64>,
197}
198
199impl SummaryTally {
200    /// Empty tally with `n_bins` depth bins of width `bin_width_m`.
201    pub fn new(bin_width_m: f64, n_bins: usize) -> Self {
202        Self {
203            histories: 0,
204            budget: EnergyBudget::default(),
205            max_relative_residual: 0.0,
206            primaries_stopped: 0,
207            backscattered: 0,
208            transmitted: 0,
209            sputtered: 0,
210            recoils_transmitted: 0,
211            lateral: 0,
212            recoils_lateral: 0,
213            recoils: 0,
214            depth_sum: 0.0,
215            depth_sq_sum: 0.0,
216            bin_width_m,
217            depth_hist: vec![0; n_bins.max(1)],
218        }
219    }
220
221    /// Mean rest depth of stopped primaries (the projected range), m.
222    pub fn mean_depth(&self) -> f64 {
223        self.depth_sum / self.primaries_stopped as f64
224    }
225
226    /// Standard deviation of the rest depth of stopped primaries, m.
227    pub fn depth_std(&self) -> f64 {
228        let n = self.primaries_stopped as f64;
229        let m = self.depth_sum / n;
230        (self.depth_sq_sum / n - m * m).max(0.0).sqrt()
231    }
232}
233
234impl BcaTally for SummaryTally {
235    fn recoil(&mut self, _recoil: &Particle) {
236        self.recoils += 1;
237    }
238
239    fn stopped(&mut self, p: &Particle) {
240        if p.is_primary() {
241            self.primaries_stopped += 1;
242            let x = p.pos[0];
243            self.depth_sum += x;
244            self.depth_sq_sum += x * x;
245            let last = self.depth_hist.len() - 1;
246            let bin = ((x / self.bin_width_m).max(0.0) as usize).min(last);
247            self.depth_hist[bin] += 1;
248        }
249    }
250
251    fn escaped(&mut self, p: &Particle, face: Face) {
252        match (p.is_primary(), face) {
253            (true, Face::Front) => self.backscattered += 1,
254            (true, Face::Back) => self.transmitted += 1,
255            (false, Face::Front) => self.sputtered += 1,
256            (false, Face::Back) => self.recoils_transmitted += 1,
257            (true, Face::Side) => self.lateral += 1,
258            (false, Face::Side) => self.recoils_lateral += 1,
259        }
260    }
261
262    fn end_history(&mut self, _index: u64, budget: &EnergyBudget) {
263        self.histories += 1;
264        self.budget.add(budget);
265        let r = (budget.residual() / budget.incident).abs();
266        self.max_relative_residual = self.max_relative_residual.max(r);
267    }
268
269    fn merge(&mut self, o: Self) {
270        self.histories += o.histories;
271        self.budget.add(&o.budget);
272        self.max_relative_residual = self.max_relative_residual.max(o.max_relative_residual);
273        self.primaries_stopped += o.primaries_stopped;
274        self.backscattered += o.backscattered;
275        self.transmitted += o.transmitted;
276        self.sputtered += o.sputtered;
277        self.recoils_transmitted += o.recoils_transmitted;
278        self.lateral += o.lateral;
279        self.recoils_lateral += o.recoils_lateral;
280        self.recoils += o.recoils;
281        self.depth_sum += o.depth_sum;
282        self.depth_sq_sum += o.depth_sq_sum;
283        for (a, b) in self.depth_hist.iter_mut().zip(o.depth_hist) {
284            *a += b;
285        }
286    }
287}