lindhard/ion/stopping/mod.rs
1//! Electronic stopping of ions in matter, from published formulas only.
2//!
3//! # Conventions
4//!
5//! * The stopping quantity returned by every model is the **stopping cross
6//! section per target atom**, `S_e = (1/N) (-dE/dx)`, in **J m² per atom**
7//! (SI). [`to_ev_1e15_cm2`] converts to the customary eV·10⁻¹⁵ cm²/atom.
8//! * Energies are the ion's kinetic energy in the laboratory frame, in eV
9//! (`_ev` suffix at the API boundary, as in [`crate::units`]).
10//! * Reduced quantities (`ε`, `ρ`) are the Lindhard-Scharff ones, defined in
11//! [`lindhard_scharff`].
12//! * Every model is a pure function of `(ion, target Z, energy)`: no RNG, no
13//! state, so results are bitwise reproducible across threads.
14//!
15//! Validity ranges per model are in `docs/stopping-models.md` and are exposed
16//! at run time by [`ElectronicStopping::validity`].
17//!
18//! No tabulated SRIM or ICRU data enters this module. Terms whose published
19//! coefficients are only available from such tables are omitted, and the
20//! omission is listed in `docs/stopping-models.md`.
21
22pub mod bethe;
23pub mod bragg;
24pub mod correction;
25pub mod dataset;
26pub mod lindhard_scharff;
27pub mod mix;
28pub mod none;
29pub mod oen_robinson;
30pub mod straggling;
31pub mod table;
32
33use crate::constants::{ATOMIC_MASS_UNIT, FINE_STRUCTURE, SPEED_OF_LIGHT};
34use crate::elements::element;
35use crate::units::J_PER_EV;
36
37/// Errors from stopping models, tables and Bragg sums.
38#[derive(Debug, Clone, thiserror::Error, PartialEq)]
39pub enum StoppingError {
40 /// Energy was not finite and positive.
41 #[error("energy must be finite and positive, got {0} eV")]
42 InvalidEnergy(f64),
43 /// Atomic number outside the element table (1..=92).
44 #[error("unknown element Z = {0}")]
45 UnknownElement(u8),
46 /// The model's formula is not meaningful at this energy (for example the
47 /// Bethe logarithm bracket is not positive far below its validity range).
48 #[error("{model} is not applicable at {energy_ev} eV for this ion")]
49 NotApplicable {
50 /// Model name.
51 model: &'static str,
52 /// Energy, eV.
53 energy_ev: f64,
54 },
55 /// A model parameter (for example a mean excitation energy) was not finite,
56 /// or not positive where it must be.
57 #[error("invalid model parameter {name} = {value}")]
58 InvalidParameter {
59 /// Parameter name.
60 name: &'static str,
61 /// Offending value.
62 value: f64,
63 },
64 /// A user table has no (or an empty) provenance field.
65 #[error(
66 "stopping table has no provenance; a citation or description of its origin is required"
67 )]
68 MissingProvenance,
69 /// A user table is malformed.
70 #[error("invalid stopping table: {0}")]
71 InvalidTable(String),
72 /// A line of a stopping dataset ([`dataset`]) is malformed or fails
73 /// validation.
74 #[error("stopping dataset, line {line}: {reason}")]
75 InvalidDataset {
76 /// 1-based line number in the source text.
77 line: usize,
78 /// What is wrong with the line.
79 reason: String,
80 },
81 /// A correction fit ([`correction`]) could not be made, with the reason.
82 #[error("invalid correction fit: {0}")]
83 InvalidFit(String),
84 /// A user table was queried outside its energy range.
85 #[error("energy {energy_ev} eV outside table range [{min_ev}, {max_ev}] eV")]
86 OutOfTableRange {
87 /// Queried energy, eV.
88 energy_ev: f64,
89 /// Table minimum, eV.
90 min_ev: f64,
91 /// Table maximum, eV.
92 max_ev: f64,
93 },
94 /// A user table was queried for a different ion or target than it holds.
95 #[error("table is for Z1={table_ion}, Z2={table_target}; asked for Z1={ion}, Z2={target}")]
96 TableMismatch {
97 /// Table ion Z.
98 table_ion: u8,
99 /// Table target Z.
100 table_target: u8,
101 /// Requested ion Z.
102 ion: u8,
103 /// Requested target Z.
104 target: u8,
105 },
106 /// Projectile mass was not finite and positive.
107 #[error("projectile mass must be finite and positive, got {0} u")]
108 InvalidMass(f64),
109 /// A user table was queried for a projectile mass different from the one
110 /// it was declared for (a different isotope).
111 #[error("table is for an ion of mass {table_mass_amu} u; asked for {mass_amu} u")]
112 TableMassMismatch {
113 /// Mass the table was declared for, u.
114 table_mass_amu: f64,
115 /// Requested projectile mass, u.
116 mass_amu: f64,
117 },
118 /// A material error while summing a compound.
119 #[error(transparent)]
120 Material(#[from] crate::material::MaterialError),
121}
122
123/// A projectile: atomic number and mass.
124#[derive(Debug, Clone, Copy, PartialEq)]
125///
126/// Fields are private so that every `Ion` satisfies its invariants: a known
127/// element and a finite, positive mass.
128pub struct Ion {
129 z: u8,
130 mass_amu: f64,
131}
132
133impl Ion {
134 /// An ion of element `z` with the standard atomic weight as its mass.
135 pub fn new(z: u8) -> Result<Self, StoppingError> {
136 let e = element(z).ok_or(StoppingError::UnknownElement(z))?;
137 Ok(Self {
138 z,
139 mass_amu: e.atomic_weight,
140 })
141 }
142
143 /// An ion with an explicit (isotopic) mass in amu.
144 ///
145 /// The mass must be finite and positive.
146 pub fn with_mass(z: u8, mass_amu: f64) -> Result<Self, StoppingError> {
147 element(z).ok_or(StoppingError::UnknownElement(z))?;
148 check_mass(mass_amu)?;
149 Ok(Self { z, mass_amu })
150 }
151
152 /// Atomic number `Z1`.
153 pub fn z(&self) -> u8 {
154 self.z
155 }
156
157 /// Mass in unified atomic mass units.
158 pub fn mass_amu(&self) -> f64 {
159 self.mass_amu
160 }
161
162 /// A proton (CODATA proton mass).
163 pub fn proton() -> Self {
164 Self {
165 z: 1,
166 mass_amu: crate::constants::PROTON_MASS / ATOMIC_MASS_UNIT,
167 }
168 }
169
170 /// Mass in kg.
171 pub fn mass_kg(&self) -> f64 {
172 self.mass_amu * ATOMIC_MASS_UNIT
173 }
174}
175
176/// Advisory energy range over which a model is meant to be used, eV.
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub struct ValidityRange {
179 /// Lower bound, eV (0 if none).
180 pub min_energy_ev: f64,
181 /// Upper bound, eV (`INFINITY` if none).
182 pub max_energy_ev: f64,
183}
184
185impl ValidityRange {
186 /// True if `energy_ev` is inside the range.
187 pub fn contains(&self, energy_ev: f64) -> bool {
188 energy_ev >= self.min_energy_ev && energy_ev <= self.max_energy_ev
189 }
190}
191
192/// An electronic stopping model.
193pub trait ElectronicStopping {
194 /// Short stable model name (used in output metadata).
195 fn name(&self) -> &'static str;
196
197 /// Stopping cross section per atom of element `target_z`, J m².
198 fn stopping(&self, ion: &Ion, target_z: u8, energy_ev: f64) -> Result<f64, StoppingError>;
199
200 /// Advisory validity range for this ion.
201 fn validity(&self, ion: &Ion) -> ValidityRange;
202
203 /// If this model is exactly `S(E) = c sqrt(E)` for this ion and target
204 /// (the velocity-proportional Lindhard-Scharff form), return `c` in
205 /// J m² eV^(-1/2); `None` (the default) otherwise.
206 ///
207 /// A model that returns `Some(c)` promises that
208 /// [`stopping`](Self::stopping) succeeds for every finite positive energy
209 /// and equals `c sqrt(E)` to rounding. A caller that evaluates the model
210 /// many times for one ion and target (the collision loop) can then
211 /// compute `c`, which involves several fractional powers, once. Return
212 /// `None` whenever [`stopping`](Self::stopping) could fail, so the error
213 /// is still reported at evaluation.
214 fn sqrt_energy_coefficient(&self, _ion: &Ion, _target_z: u8) -> Option<f64> {
215 None
216 }
217}
218
219/// Convert J m² per atom to eV·10⁻¹⁵ cm² per atom (1e-15 cm² = 1e-19 m²).
220pub fn to_ev_1e15_cm2(s_j_m2: f64) -> f64 {
221 s_j_m2 / J_PER_EV / 1.0e-19
222}
223
224/// Convert eV·10⁻¹⁵ cm² per atom to J m² per atom.
225pub fn from_ev_1e15_cm2(s: f64) -> f64 {
226 s * 1.0e-19 * J_PER_EV
227}
228
229/// Bohr velocity `v0 = α c`, m/s.
230pub fn bohr_velocity() -> f64 {
231 FINE_STRUCTURE * SPEED_OF_LIGHT
232}
233
234/// Ion speed from kinetic energy, non-relativistic `sqrt(2E/M)`, m/s.
235pub fn speed_nonrel(ion: &Ion, energy_ev: f64) -> f64 {
236 (2.0 * energy_ev * J_PER_EV / ion.mass_kg()).sqrt()
237}
238
239pub(crate) fn check_mass(mass_amu: f64) -> Result<(), StoppingError> {
240 if mass_amu.is_finite() && mass_amu > 0.0 {
241 Ok(())
242 } else {
243 Err(StoppingError::InvalidMass(mass_amu))
244 }
245}
246
247pub(crate) fn check_energy(energy_ev: f64) -> Result<(), StoppingError> {
248 if energy_ev.is_finite() && energy_ev > 0.0 {
249 Ok(())
250 } else {
251 Err(StoppingError::InvalidEnergy(energy_ev))
252 }
253}
254
255pub(crate) fn target(z: u8) -> Result<&'static crate::elements::Element, StoppingError> {
256 element(z).ok_or(StoppingError::UnknownElement(z))
257}
258
259/// Energy per amu (eV/u) at which the ion speed equals `n_v0 * v0 * Z1^(2/3)`:
260/// the Lindhard-Scharff velocity scale that separates the low-energy and
261/// Bethe regimes.
262pub(crate) fn velocity_scale_energy_per_amu_ev(z1: u8, n_v0: f64) -> f64 {
263 let v = n_v0 * bohr_velocity() * f64::from(z1).powf(2.0 / 3.0);
264 0.5 * ATOMIC_MASS_UNIT * v * v / J_PER_EV
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn with_mass_rejects_invalid_mass() {
273 for m in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
274 assert!(
275 matches!(Ion::with_mass(1, m), Err(StoppingError::InvalidMass(_))),
276 "mass {m}"
277 );
278 }
279 assert!(Ion::with_mass(1, 2.014).is_ok());
280 }
281
282 #[test]
283 fn unit_round_trip() {
284 let s = 12.5;
285 assert!((to_ev_1e15_cm2(from_ev_1e15_cm2(s)) - s).abs() < 1e-12);
286 }
287
288 #[test]
289 fn models_are_bitwise_deterministic_across_threads() {
290 let ion = Ion::new(15).unwrap();
291 let m = bethe::BetheBloch::new();
292 let r0 = m.stopping(&ion, 14, 1.2e8).unwrap();
293 let hs: Vec<_> = (0..4)
294 .map(|_| {
295 std::thread::spawn(move || {
296 let ion = Ion::new(15).unwrap();
297 bethe::BetheBloch::new().stopping(&ion, 14, 1.2e8).unwrap()
298 })
299 })
300 .collect();
301 for h in hs {
302 assert_eq!(h.join().unwrap().to_bits(), r0.to_bits());
303 }
304 assert_eq!(m.stopping(&ion, 14, 1.2e8).unwrap().to_bits(), r0.to_bits());
305 }
306}