Skip to main content

lindhard/ion/stopping/
bethe.rs

1//! Bethe-Bloch electronic stopping (relativistic) with Bloch and optional
2//! user-supplied shell/density corrections, and a named effective-charge model.
3//!
4//! # Formula
5//!
6//! Per target atom (Bethe 1930/1932; Fano, Ann. Rev. Nucl. Sci. 13, 1 (1963);
7//! PDG review "Passage of particles through matter", the same equation):
8//!
9//! `S = 4π (e²)² z² Z2 / (m c² β²) ·
10//!      [ ½ ln(2 m c² β² γ² W_max / I²) − β² − δ/2 − C/Z2 + L1 + L2 ]`
11//!
12//! with `e²` meaning `e²/(4π ε0)`, `z` the projectile effective charge,
13//! `W_max = 2 m c² β²γ² / (1 + 2γ m/M + (m/M)²)`, `I` the mean excitation
14//! energy, `δ` the density-effect correction and `C` the shell correction.
15//!
16//! * `L2` (Bloch 1933, Ann. Phys. 408, 285): the exact series
17//!   `L2 = −y² Σ_{n≥1} 1 / (n (n² + y²))`, `y = z α / β`.
18//! * `I` defaults to the Bloch rule `I = 10 eV · Z2` (Bloch 1933). This is a
19//!   rough estimate, good to about 20 % in `I` (about 2 % in `S`); pass a
20//!   measured `I` (cited) with [`BetheBloch::with_mean_excitation_ev`].
21//! * `δ` and `C` default to 0 and can be set by the caller as constants if
22//!   they come from a cited source. `δ` can instead be computed with the
23//!   opt-in single-oscillator model, see [`density_effect_single_oscillator`]
24//!   and [`BetheBloch::with_density_effect`].
25//!
26//! # Omitted terms
27//!
28//! The Barkas term `L1` (Ashley-Ritchie-Brandt 1972) needs a tabulated
29//! function, and the usual shell-correction parameter sets and the
30//! multi-oscillator density-effect parameter sets are published as tables
31//! (ICRU 49/37 and Sternheimer-Berger-Seltzer), which this project may not
32//! ingest. They are not implemented. See `docs/stopping-models.md` for the
33//! literature findings; expect errors of a few percent in the range
34//! 1 to 10 MeV/u until they are added.
35//!
36//! # Effective charge
37//!
38//! [`EffectiveCharge::BarkasEmpirical`]: `z_eff = Z1 [1 − exp(−125 β / Z1^(2/3))]`
39//! (W. H. Barkas, *Nuclear Research Emulsions* I, Academic Press (1963);
40//! quoted in Northcliffe, Ann. Rev. Nucl. Sci. 13, 67 (1963)).
41
42use super::{check_energy, target, ElectronicStopping, Ion, StoppingError, ValidityRange};
43use crate::constants::{COULOMB_E2, ELECTRON_REST_ENERGY, FINE_STRUCTURE};
44use crate::units::J_PER_EV;
45use std::f64::consts::PI;
46
47/// Projectile effective-charge model.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum EffectiveCharge {
50    /// `z = Z1` (fully stripped); right for protons and alphas at high energy.
51    #[default]
52    Bare,
53    /// Barkas empirical form, see the module docs.
54    BarkasEmpirical,
55}
56
57impl EffectiveCharge {
58    /// Effective charge number at speed `beta`.
59    pub fn charge(self, z1: u8, beta: f64) -> f64 {
60        let z = f64::from(z1);
61        match self {
62            Self::Bare => z,
63            Self::BarkasEmpirical => z * (1.0 - (-125.0 * beta / z.powf(2.0 / 3.0)).exp()),
64        }
65    }
66}
67
68/// Plasma energy `ħ ω_p = ħ sqrt(n e² / (ε0 m))` in eV for an electron
69/// density `n` in m^-3 (the free-electron plasma frequency).
70pub fn plasma_energy_ev(electron_density_m3: f64) -> f64 {
71    let wp2 = 4.0 * PI * electron_density_m3 * COULOMB_E2 / crate::constants::ELECTRON_MASS;
72    crate::constants::HBAR * wp2.sqrt() / J_PER_EV
73}
74
75/// Density-effect correction `δ` of a single-oscillator dielectric model.
76///
77/// Takes the squared product `(βγ)²`, the target plasma energy `ħω_p` and the
78/// mean excitation energy `I` (both eV). Returns 0 below the threshold
79/// `(βγ)² = (I/ħω_p)²` and otherwise
80///
81/// `δ = ln((βγ)² (ħω_p/I)²) − 1 + (I/ħω_p)² / (βγ)²`.
82///
83/// # Source and status
84///
85/// This is the Fermi (Phys. Rev. 57, 485 (1940)) / Sternheimer (Phys. Rev. 88,
86/// 851 (1952)) dielectric formulation, which in the form given by Fano (Ann.
87/// Rev. Nucl. Sci. 13, 1 (1963)) reads
88/// `δ = Σ f_i ln(1 + L²/ω_i²) − L² (1−β²)/(β² ω_p²)`, with `L` fixed by
89/// `Σ f_i ω_p² / (ω_i² + L²) = 1/β² − 1`. We specialise it to ONE oscillator
90/// (`f = 1`, `ω_0 = I/ħ`), where the constraint solves in closed form
91/// (`L² = ω_p² (βγ)² − ω_0²`), giving the expression above. The reduction was
92/// done by us and is not quoted from a paper; equation numbers of the sources
93/// are not cited because they were not checked. It is verified in the tests by
94/// its limits: `δ → 0` continuously (value and slope) at threshold, and
95/// `δ → 2 ln(βγ ħω_p/I) − 1` at large `βγ`, the standard high-energy form.
96///
97/// # Validity
98///
99/// Exact in the high-energy limit when `I` is the true mean excitation
100/// energy. Near and above threshold a single oscillator is a crude stand-in
101/// for the real oscillator spectrum, and it contains no conductor
102/// (free-electron) terms, so the transition region is only approximate. It
103/// is zero below `βγ = I/ħω_p`, which is about 5 to 6 for Si (a proton of
104/// about 5 GeV). Real materials switch the effect on much earlier (around
105/// `βγ ≈ 1.5` for Si in the published parameterisations), because their
106/// oscillator spectrum is spread; this model therefore UNDER-estimates `δ`
107/// through the transition and, in particular, is identically 0 over the whole
108/// range `BetheBloch::validity` advertises (up to 1 GeV/u). Its value is the
109/// correct asymptote for ultra-relativistic ions only.
110pub fn density_effect_single_oscillator(
111    beta_gamma_sq: f64,
112    plasma_energy_ev: f64,
113    mean_excitation_ev: f64,
114) -> f64 {
115    let a = (mean_excitation_ev / plasma_energy_ev).powi(2);
116    if beta_gamma_sq <= a {
117        return 0.0;
118    }
119    (beta_gamma_sq / a).ln() - 1.0 + a / beta_gamma_sq
120}
121
122/// Bloch correction `L2(y)`, `y = z α / β` (see module docs).
123pub fn bloch_term(y: f64) -> f64 {
124    let y2 = y * y;
125    const N: u32 = 20_000;
126    let mut sum = 0.0;
127    // Sum small terms first for accuracy.
128    for n in (1..=N).rev() {
129        let n = f64::from(n);
130        sum += 1.0 / (n * (n * n + y2));
131    }
132    // Tail: sum_{n>N} 1/(n^3) ~ 1/(2 N^2) (y << N).
133    let nn = f64::from(N);
134    sum += 1.0 / (2.0 * nn * nn);
135    -y2 * sum
136}
137
138/// Bethe-Bloch model.
139#[derive(Debug, Clone, Copy, PartialEq, Default)]
140pub struct BetheBloch {
141    /// Effective-charge model.
142    pub effective_charge: EffectiveCharge,
143    /// Mean excitation energy override, eV.
144    pub mean_excitation_ev: Option<f64>,
145    /// Shell correction `C / Z2` (dimensionless, added as `−C/Z2`); default 0.
146    pub shell_over_z: f64,
147    /// Density-effect `δ` (dimensionless); default 0.
148    pub density_delta: f64,
149    /// Target electron density (m^-3) for the opt-in single-oscillator
150    /// density effect; `None` (default) leaves it off. Added to
151    /// `density_delta`.
152    pub density_effect_electron_density_m3: Option<f64>,
153    /// Include the Bloch term `L2` (default: true via [`BetheBloch::new`]).
154    pub no_bloch: bool,
155}
156
157impl BetheBloch {
158    /// Bare-charge Bethe-Bloch with Bloch term, Bloch-rule `I`.
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    /// Set the effective-charge model.
164    pub fn with_effective_charge(mut self, m: EffectiveCharge) -> Self {
165        self.effective_charge = m;
166        self
167    }
168
169    /// Set the mean excitation energy, eV (cite its source at the call site).
170    pub fn with_mean_excitation_ev(mut self, i_ev: f64) -> Self {
171        self.mean_excitation_ev = Some(i_ev);
172        self
173    }
174
175    /// Enable the single-oscillator density effect
176    /// ([`density_effect_single_oscillator`]) for a target of electron density
177    /// `n_e` (m^-3). The density effect needs a meaningful `I`: set a cited
178    /// one with [`BetheBloch::with_mean_excitation_ev`]. Opt-in; relevant only
179    /// at relativistic energies.
180    pub fn with_density_effect(mut self, electron_density_m3: f64) -> Self {
181        self.density_effect_electron_density_m3 = Some(electron_density_m3);
182        self
183    }
184
185    /// Drop the Bloch term (pure Bethe).
186    pub fn without_bloch(mut self) -> Self {
187        self.no_bloch = true;
188        self
189    }
190
191    /// `(gamma, beta^2, M c^2)` for kinetic energy `energy_ev`.
192    ///
193    /// With `tau = T / (M c^2)`, `beta^2 = tau (tau + 2) / (1 + tau)^2`, which
194    /// stays accurate when `tau` is below the rounding error of `1 + tau`
195    /// (the form `1 - 1/gamma^2` cancels to exactly 0 there).
196    pub(super) fn kinematics(ion: &Ion, energy_ev: f64) -> (f64, f64, f64) {
197        let mc2 = ion.mass_kg() * crate::constants::SPEED_OF_LIGHT.powi(2);
198        let tau = energy_ev * J_PER_EV / mc2;
199        let gamma = 1.0 + tau;
200        let beta2 = tau * (tau + 2.0) / (gamma * gamma);
201        (gamma, beta2, mc2)
202    }
203
204    /// Reject non-physical parameters. Checked on every evaluation so that
205    /// direct assignment to the public fields is covered too.
206    fn validate(&self) -> Result<(), StoppingError> {
207        if let Some(i) = self.mean_excitation_ev {
208            if !(i.is_finite() && i > 0.0) {
209                return Err(StoppingError::InvalidParameter {
210                    name: "mean_excitation_ev",
211                    value: i,
212                });
213            }
214        }
215        if let Some(n) = self.density_effect_electron_density_m3 {
216            if !(n.is_finite() && n > 0.0) {
217                return Err(StoppingError::InvalidParameter {
218                    name: "density_effect_electron_density_m3",
219                    value: n,
220                });
221            }
222        }
223        for (name, value) in [
224            ("shell_over_z", self.shell_over_z),
225            ("density_delta", self.density_delta),
226        ] {
227            if !value.is_finite() {
228                return Err(StoppingError::InvalidParameter { name, value });
229            }
230        }
231        Ok(())
232    }
233
234    /// Bracket `[...]` of the formula (dimensionless).
235    pub fn bracket(&self, ion: &Ion, target_z: u8, energy_ev: f64) -> Result<f64, StoppingError> {
236        check_energy(energy_ev)?;
237        target(target_z)?;
238        self.validate()?;
239        let (gamma, beta2, mc2_ion) = Self::kinematics(ion, energy_ev);
240        let beta = beta2.sqrt();
241        let i_j = self
242            .mean_excitation_ev
243            .unwrap_or(10.0 * f64::from(target_z))
244            * J_PER_EV;
245        let m = ELECTRON_REST_ENERGY;
246        let ratio = m / mc2_ion;
247        let wmax = 2.0 * m * beta2 * gamma * gamma / (1.0 + 2.0 * gamma * ratio + ratio * ratio);
248        let z = self.effective_charge.charge(ion.z(), beta);
249        let delta = self.density_delta
250            + self.density_effect_electron_density_m3.map_or(0.0, |n| {
251                density_effect_single_oscillator(
252                    beta2 * gamma * gamma,
253                    plasma_energy_ev(n),
254                    i_j / J_PER_EV,
255                )
256            });
257        let mut b = 0.5 * (2.0 * m * beta2 * gamma * gamma * wmax / (i_j * i_j)).ln()
258            - beta2
259            - 0.5 * delta
260            - self.shell_over_z;
261        if !self.no_bloch {
262            b += bloch_term(z * FINE_STRUCTURE / beta);
263        }
264        if b.is_nan() {
265            return Err(StoppingError::NotApplicable {
266                model: "bethe-bloch",
267                energy_ev,
268            });
269        }
270        Ok(b)
271    }
272}
273
274impl ElectronicStopping for BetheBloch {
275    fn name(&self) -> &'static str {
276        "bethe-bloch"
277    }
278
279    fn stopping(&self, ion: &Ion, target_z: u8, energy_ev: f64) -> Result<f64, StoppingError> {
280        let b = self.bracket(ion, target_z, energy_ev)?;
281        let (_, beta2, _) = Self::kinematics(ion, energy_ev);
282        let z = self.effective_charge.charge(ion.z(), beta2.sqrt());
283        let s = 4.0 * PI * COULOMB_E2 * COULOMB_E2 * z * z * f64::from(target_z)
284            / (ELECTRON_REST_ENERGY * beta2)
285            * b;
286        if !(b > 0.0 && s.is_finite()) {
287            return Err(StoppingError::NotApplicable {
288                model: "bethe-bloch",
289                energy_ev,
290            });
291        }
292        Ok(s)
293    }
294
295    fn validity(&self, ion: &Ion) -> ValidityRange {
296        // v >= 3 v0 Z1^(2/3) on the low side; no density effect on the high
297        // side, so stop at 1 GeV/u.
298        ValidityRange {
299            min_energy_ev: ion.mass_amu() * super::velocity_scale_energy_per_amu_ev(ion.z(), 3.0),
300            max_energy_ev: ion.mass_amu() * 1.0e9,
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::constants::{AVOGADRO, BOHR_RADIUS, PROTON_MASS, SPEED_OF_LIGHT};
309
310    /// Hand calculation for protons in Si (Z2 = 14, I = 10 eV * 14 = 140 eV by
311    /// the Bloch rule), no Bloch/shell/density terms:
312    ///
313    ///   gamma = 1 + T / (m_p c^2); beta^2 = 1 - 1/gamma^2
314    ///   S = 4 pi (e^2)^2 Z2 / (m c^2 beta^2) [ ln(2 m c^2 beta^2 gamma^2 / I) - beta^2 ]
315    ///
316    /// with W_max replaced by its heavy-particle limit. The expected values in
317    /// eV 1e-15 cm^2 were computed independently from this arithmetic with the
318    /// CODATA constants (T = 1, 10, 100 MeV).
319    #[test]
320    fn proton_in_si_hand_calculation() {
321        let m = BetheBloch::new()
322            .with_mean_excitation_ev(140.0)
323            .without_bloch();
324        let ion = Ion::proton();
325        let expected = [
326            (1.0e6, 9.201_572_04),
327            (1.0e7, 1.712_108_53),
328            (1.0e8, 0.281_049_35),
329        ];
330        for (e, want) in expected {
331            let got = super::super::to_ev_1e15_cm2(m.stopping(&ion, 14, e).unwrap());
332            // W_max exact vs heavy limit differs by < 1e-4 in S.
333            assert!((got / want - 1.0).abs() < 2.0e-4, "{e}: {got} vs {want}");
334        }
335    }
336
337    #[test]
338    fn independent_formula_check() {
339        // Same hand formula, written in MeV cm^2 / g via K = 4 pi N_A r_e^2 m c^2.
340        let re = FINE_STRUCTURE * FINE_STRUCTURE * BOHR_RADIUS;
341        let mc2_mev = ELECTRON_REST_ENERGY / J_PER_EV / 1e6;
342        let k = 4.0 * PI * AVOGADRO * (re * 100.0).powi(2) * mc2_mev; // MeV cm^2/mol
343        let t = 10.0;
344        let g = 1.0 + t / (PROTON_MASS * SPEED_OF_LIGHT.powi(2) / J_PER_EV / 1e6);
345        let b2 = 1.0 - 1.0 / (g * g);
346        let l = (2.0 * mc2_mev * 1e6 * b2 * g * g / 140.0).ln() - b2;
347        let per_g = k * 14.0 / 28.0855 / b2 * l; // MeV cm^2 / g
348        let m = BetheBloch::new()
349            .with_mean_excitation_ev(140.0)
350            .without_bloch();
351        let s = super::super::to_ev_1e15_cm2(m.stopping(&Ion::proton(), 14, t * 1e6).unwrap());
352        let per_g_model = s * 1e-15 * 1e-6 * AVOGADRO / 28.0855;
353        assert!((per_g_model / per_g - 1.0).abs() < 2e-4);
354    }
355
356    #[test]
357    fn bloch_term_small_y_matches_zeta3() {
358        let y = 0.01;
359        assert!((bloch_term(y) / (-1.202_056_903_16 * y * y) - 1.0).abs() < 1e-3);
360    }
361
362    #[test]
363    fn bloch_reduces_stopping() {
364        let ion = Ion::new(6).unwrap();
365        let a = BetheBloch::new().stopping(&ion, 14, 1.2e8).unwrap();
366        let b = BetheBloch::new()
367            .without_bloch()
368            .stopping(&ion, 14, 1.2e8)
369            .unwrap();
370        assert!(a < b);
371    }
372
373    #[test]
374    fn plasma_energy_of_aluminium() {
375        // n_e = 13 rho N_A / A, rho = 2.699 g/cm^3, A = 26.98 (elementary
376        // facts); the free-electron plasma energy is about 32.9 eV.
377        let n = 13.0 * 2.699e3 * AVOGADRO / 26.98e-3;
378        assert!((plasma_energy_ev(n) / 32.86 - 1.0).abs() < 3e-3);
379    }
380
381    #[test]
382    fn density_effect_limits() {
383        let (ehp, i): (f64, f64) = (30.0, 150.0);
384        let a = (i / ehp).powi(2);
385        // zero at and below threshold, continuous in value and slope
386        assert_eq!(density_effect_single_oscillator(0.5 * a, ehp, i), 0.0);
387        let eps = 1e-4 * a;
388        let above = density_effect_single_oscillator(a + eps, ehp, i);
389        assert!((0.0..1e-6).contains(&above));
390        // high-energy form 2 ln(bg ehp / I) - 1
391        for bg2 in [1e6, 1e8] {
392            let want = (bg2 * (ehp / i).powi(2)).ln() - 1.0;
393            let got = density_effect_single_oscillator(bg2, ehp, i);
394            assert!((got - want).abs() < 2.0 * a / bg2 + 1e-12, "{got} {want}");
395        }
396        // monotonic
397        let mut prev = 0.0;
398        for k in 0..60 {
399            let d = density_effect_single_oscillator(a * 10f64.powf(f64::from(k) * 0.1), ehp, i);
400            assert!(d >= prev);
401            prev = d;
402        }
403    }
404
405    #[test]
406    fn density_effect_is_opt_in_and_reduces_stopping() {
407        let n = 14.0 * 2.33e3 * AVOGADRO / 28.0855e-3; // Si
408        let base = BetheBloch::new().with_mean_excitation_ev(173.0);
409        let on = base.with_density_effect(n);
410        let p = Ion::proton();
411        // 10 MeV and 1 GeV: below this model's threshold (beta gamma about
412        // 5.6 for Si), identical
413        for e in [1.0e7, 1.0e9] {
414            assert_eq!(
415                base.stopping(&p, 14, e).unwrap(),
416                on.stopping(&p, 14, e).unwrap()
417            );
418        }
419        // 100 GeV: reduced
420        let (a, b) = (
421            base.stopping(&p, 14, 1.0e11).unwrap(),
422            on.stopping(&p, 14, 1.0e11).unwrap(),
423        );
424        assert!(b < a);
425        let mut bad = base;
426        bad.density_effect_electron_density_m3 = Some(-1.0);
427        assert!(matches!(
428            bad.stopping(&p, 14, 1.0e9),
429            Err(StoppingError::InvalidParameter { .. })
430        ));
431    }
432
433    #[test]
434    fn effective_charge_limits() {
435        assert!((EffectiveCharge::BarkasEmpirical.charge(15, 0.9) / 15.0 - 1.0).abs() < 1e-6);
436        assert!(EffectiveCharge::BarkasEmpirical.charge(15, 0.01) < 5.0);
437    }
438
439    #[test]
440    fn tiny_energy_is_a_defined_error_not_nan() {
441        let u = Ion::with_mass(92, 238.028_91).unwrap();
442        for e in [1.0e-6, 1.0e-30, 1.0e-300] {
443            for m in [BetheBloch::new(), BetheBloch::new().without_bloch()] {
444                let r = m.stopping(&u, 14, e);
445                assert!(
446                    matches!(r, Err(StoppingError::NotApplicable { .. })),
447                    "{e}: {r:?}"
448                );
449                assert!(!m.bracket(&u, 14, e).is_ok_and(f64::is_nan));
450            }
451        }
452    }
453
454    #[test]
455    fn beta2_is_stable_at_tiny_tau() {
456        let u = Ion::with_mass(92, 238.028_91).unwrap();
457        let (_, beta2, _) = BetheBloch::kinematics(&u, 1.0e-6);
458        assert!(beta2 > 0.0 && beta2.is_finite());
459        // non-relativistic limit beta^2 = 2 T / (M c^2)
460        let mc2_ev = u.mass_kg() * crate::constants::SPEED_OF_LIGHT.powi(2) / J_PER_EV;
461        assert!((beta2 / (2.0e-6 / mc2_ev) - 1.0).abs() < 1e-12);
462    }
463
464    #[test]
465    fn invalid_parameters_are_rejected() {
466        let ion = Ion::proton();
467        for i in [0.0, -140.0, f64::NAN, f64::INFINITY] {
468            let mut m = BetheBloch::new().without_bloch();
469            m.mean_excitation_ev = Some(i);
470            assert!(matches!(
471                m.stopping(&ion, 14, 1.0e7),
472                Err(StoppingError::InvalidParameter {
473                    name: "mean_excitation_ev",
474                    ..
475                })
476            ));
477            assert!(matches!(
478                BetheBloch::new()
479                    .with_mean_excitation_ev(i)
480                    .bracket(&ion, 14, 1.0e7),
481                Err(StoppingError::InvalidParameter { .. })
482            ));
483        }
484        for bad in [f64::NAN, f64::INFINITY] {
485            let mut m = BetheBloch::new();
486            m.shell_over_z = bad;
487            assert!(matches!(
488                m.stopping(&ion, 14, 1.0e7),
489                Err(StoppingError::InvalidParameter {
490                    name: "shell_over_z",
491                    ..
492                })
493            ));
494            let mut m = BetheBloch::new();
495            m.density_delta = bad;
496            assert!(matches!(
497                m.stopping(&ion, 14, 1.0e7),
498                Err(StoppingError::InvalidParameter {
499                    name: "density_delta",
500                    ..
501                })
502            ));
503        }
504    }
505
506    #[test]
507    fn not_applicable_at_low_energy() {
508        let r = BetheBloch::new().stopping(&Ion::proton(), 14, 10.0);
509        assert!(matches!(r, Err(StoppingError::NotApplicable { .. })));
510    }
511}