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}