Skip to main content

lindhard/ion/
potential.rs

1//! Screened-Coulomb interatomic potentials.
2//!
3//! The interaction of two atoms with atomic numbers `Z1`, `Z2` at separation
4//! `r` is written
5//!
6//! ```text
7//! V(r) = Z1 Z2 e^2 / (4 pi eps0 r) * phi(r / a)
8//! ```
9//!
10//! where `phi` is a universal screening function (`phi(0) = 1`, decreasing to 0)
11//! and `a` a screening length that depends on `Z1` and `Z2`. Everything in the
12//! scattering code is expressed in the reduced variables `x = r / a` and
13//! `eps = a E_cm / (Z1 Z2 e^2 / (4 pi eps0))`, in which `phi` is the only
14//! input; the screening length only converts to and from SI.
15//!
16//! Coefficients follow the cited papers. The status of each set is recorded
17//! in its `docs/data-provenance.md` row. The Firsov and Lindhard screening
18//! lengths have been checked against the primary papers (page references
19//! below). The ZBL, Kr-C and Moliere screening sets have been cross-checked
20//! against an independent MIT-licensed implementation. The Lenz-Jensen set
21//! agrees with a textbook tabulation, but not with a primary source. The
22//! universal-length prefactor has been seen only in secondary sources.
23
24use crate::constants::{BOHR_RADIUS, COULOMB_E2};
25
26/// Screening function `phi(x)`, `x = r / a`.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum Screening {
29    /// ZBL "universal" screening: four exponentials fitted to Hartree-Fock-Slater
30    /// solid-state pair potentials. J. F. Ziegler, J. P. Biersack, U. Littmark,
31    /// *The Stopping and Range of Ions in Solids* (Pergamon, 1985), Ch. 2.
32    ZblUniversal,
33    /// Kr-C potential: three exponentials fitted to Hartree-Fock Kr-Kr pair
34    /// interaction. W. D. Wilson, L. G. Haggmark, J. P. Biersack,
35    /// Phys. Rev. B 15, 2458 (1977).
36    KrC,
37    /// Moliere's approximation to the Thomas-Fermi function: three exponentials.
38    /// G. Moliere, Z. Naturforsch. A 2, 133 (1947).
39    Moliere,
40    /// Lenz-Jensen screening, a polynomial-times-exponential approximation of
41    /// the Thomas-Fermi-Jensen statistical model. W. Lenz, Z. Phys. 77, 713
42    /// (1932), doi:10.1007/BF01342150; H. Jensen, Z. Phys. 77, 722 (1932),
43    /// doi:10.1007/BF01342151. Polynomial form and argument scale as tabulated
44    /// in W. Moller, *Fundamentals of Ion-Solid Interaction*, HZDR-073
45    /// (Helmholtz-Zentrum Dresden-Rossendorf, 2017), p. 11, eq. (28); see
46    /// `LJ_P` (private; see its comment in the source) for the verification status.
47    LenzJensen,
48}
49
50/// Prefactors and exponents `(c_i, b_i)` of the sum-of-exponentials screening
51/// functions, `phi(x) = Sum_i c_i exp(-b_i x)`.
52///
53/// ZBL: verified against J. F. Ziegler, J. P. Biersack, M. D. Ziegler, *SRIM: The Stopping and
54/// Range of Ions in Matter* (SRIM Co., 2015), Ch. 2,
55/// Fig. 2-17, p. 2-32 (these 4-digit values exactly) and Fig. 2-18, p. 2-36
56/// (the 5-digit set 0.18175, 0.50986, 0.28022, 0.028171 / 3.1998, 0.94229,
57/// 0.4029, 0.20162). The book's Eq. 2-74, p. 2-31, misprints the fourth
58/// prefactor as `.2817` and the third exponent as `.4028`; the figures and
59/// the normalisation `Sum c_i = 1` settle both.
60///
61/// Moliere: verified against the same book, Eq. 2-62, p. 2-20, which
62/// misprints the middle prefactor as `5.5` (normalisation gives 0.55).
63///
64/// Kr-C and Moliere: cross-checked digit for digit against
65/// `include/screened_coulomb.h` of the MIT-licensed `ir2-lab/screened_coulomb`
66/// (commit f84c3c8), a component of OpenTRIM. This is a secondary source, not
67/// the cited papers.
68const ZBL_C: [(f64, f64); 4] = [
69    (0.181_8, 3.2),
70    (0.509_9, 0.942_3),
71    (0.280_2, 0.402_9),
72    (0.028_17, 0.201_6),
73];
74const KRC_C: [(f64, f64); 3] = [
75    (0.190_945, 0.278_544),
76    (0.473_674, 0.637_174),
77    (0.335_381, 1.919_249),
78];
79const MOLIERE_C: [(f64, f64); 3] = [(0.35, 0.3), (0.55, 1.2), (0.10, 6.0)];
80
81/// Lenz-Jensen: `phi = P(q) exp(-q)`, `q = sqrt(LJ_Q2 x)`,
82/// `P(q) = 1 + q + 0.3344 q^2 + 0.0485 q^3 + 0.002647 q^4`.
83///
84/// All five polynomial coefficients and the scale `9.67` are printed exactly
85/// so in W. Moller, *Fundamentals of Ion-Solid Interaction*, HZDR-073 (2017),
86/// p. 11, eq. (28), `phi_LJ(xi) = (1 + xi + 0.3344 xi^2 + 0.0485 xi^3 +
87/// 0.002647 xi^4) exp(-xi)`, `xi = sqrt(9.67 r/a)`
88/// (<https://www.hzdr.de/publications/PublDoc-10091.pdf>). There `a` is the
89/// single-atom Thomas-Fermi length `0.8853 a0 Z^(-1/3)` (eq. (26)). The
90/// two-atom form replaces it with the Lindhard length (eqs. (29)-(30)), which
91/// is the pairing in [`Screening::default_length`]. That is a secondary
92/// (textbook) source. The primary papers of Lenz and Jensen were not
93/// accessible, so the set is **unverified against the primary literature**. No
94/// source was found for the alternative scale `3.11126^2 = 9.680`. See
95/// `docs/data-provenance.md`.
96const LJ_P: [f64; 5] = [1.0, 1.0, 0.3344, 0.0485, 0.002_647];
97const LJ_Q2: f64 = 9.67;
98
99fn sum_exp(c: &[(f64, f64)], x: f64) -> f64 {
100    c.iter().map(|&(a, b)| a * (-b * x).exp()).sum()
101}
102fn sum_exp_d(c: &[(f64, f64)], x: f64) -> f64 {
103    c.iter().map(|&(a, b)| -a * b * (-b * x).exp()).sum()
104}
105
106impl Screening {
107    /// All implemented screening functions.
108    pub const ALL: [Screening; 4] = [
109        Screening::ZblUniversal,
110        Screening::KrC,
111        Screening::Moliere,
112        Screening::LenzJensen,
113    ];
114
115    /// Screening function value `phi(x)`.
116    pub fn phi(self, x: f64) -> f64 {
117        match self {
118            Screening::ZblUniversal => sum_exp(&ZBL_C, x),
119            Screening::KrC => sum_exp(&KRC_C, x),
120            Screening::Moliere => sum_exp(&MOLIERE_C, x),
121            Screening::LenzJensen => {
122                let q = (LJ_Q2 * x).sqrt();
123                horner(&LJ_P, q) * (-q).exp()
124            }
125        }
126    }
127
128    /// Derivative `dphi/dx`.
129    pub fn dphi(self, x: f64) -> f64 {
130        match self {
131            Screening::ZblUniversal => sum_exp_d(&ZBL_C, x),
132            Screening::KrC => sum_exp_d(&KRC_C, x),
133            Screening::Moliere => sum_exp_d(&MOLIERE_C, x),
134            Screening::LenzJensen => {
135                if x <= 0.0 {
136                    return f64::NEG_INFINITY;
137                }
138                let q = (LJ_Q2 * x).sqrt();
139                // d/dq [P e^-q] = (P' - P) e^-q ;  dq/dx = q / (2x).
140                let p = horner(&LJ_P, q);
141                let dp = LJ_P
142                    .iter()
143                    .enumerate()
144                    .skip(1)
145                    .rev()
146                    .fold(0.0, |acc, (i, &c)| acc * q + c * i as f64);
147                (dp - p) * (-q).exp() * q / (2.0 * x)
148            }
149        }
150    }
151
152    /// The `(c_i, b_i)` terms of a sum-of-exponentials screening function
153    /// `phi(x) = Sum_i c_i exp(-b_i x)`, or `None` for Lenz-Jensen. Used by the
154    /// analytic stopping asymptote in the scattering tests and the validation
155    /// harness (`lindhard/tests/validation`).
156    pub fn exponential_terms(self) -> Option<&'static [(f64, f64)]> {
157        match self {
158            Screening::ZblUniversal => Some(&ZBL_C),
159            Screening::KrC => Some(&KRC_C),
160            Screening::Moliere => Some(&MOLIERE_C),
161            Screening::LenzJensen => None,
162        }
163    }
164
165    /// The screening length conventionally paired with this function (the
166    /// pairing used where the function was introduced or fitted):
167    /// ZBL universal with the universal length, Kr-C and Moliere with the
168    /// Firsov length, Lenz-Jensen with the Lindhard (Thomas-Fermi) length.
169    pub fn default_length(self) -> ScreeningLength {
170        match self {
171            Screening::ZblUniversal => ScreeningLength::Universal,
172            Screening::KrC | Screening::Moliere => ScreeningLength::Firsov,
173            Screening::LenzJensen => ScreeningLength::Lindhard,
174        }
175    }
176}
177
178fn horner(c: &[f64], q: f64) -> f64 {
179    c.iter().rev().fold(0.0, |acc, &k| acc * q + k)
180}
181
182/// Screening length `a(Z1, Z2)`.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
184pub enum ScreeningLength {
185    /// Universal length, `a_U = 0.8853 a0 / (Z1^0.23 + Z2^0.23)`.
186    /// Ziegler, Biersack, Littmark (1985), Ch. 2; the page numbers are those of
187    /// J. F. Ziegler, J. P. Biersack and M. D. Ziegler, *SRIM: The Stopping
188    /// and Range of Ions in Matter* (SRIM Co., 2015), Eq. 2-73, p. 2-30, which
189    /// prints the prefactor as `0.8853`; its Figs. 2-16 and 2-17 (p. 2-32) label it
190    /// `.8854`. We use the Thomas-Fermi constant (`0.88534`, the same value
191    /// as the Firsov and Lindhard lengths), which agrees with Eq. 2-73 to the
192    /// printed digits; see `docs/data-provenance.md`.
193    Universal,
194    /// Firsov length, `a_F = 0.8853 a0 / (sqrt(Z1) + sqrt(Z2))^(2/3)`.
195    /// O. B. Firsov, Sov. Phys. JETP 6, 534 (1958): the scaled distance
196    /// `x' = (Z1^(1/2) + Z2^(1/2))^(2/3) R / a` (p. 536), with
197    /// `a = (9 pi^2/128)^(1/3) hbar^2/(m e^2) = 4.68e-9 cm` (p. 535, below
198    /// eq. (4)). Verified against the scan at
199    /// <http://www.jetp.ras.ru/cgi-bin/dn/e_006_03_0534.pdf>.
200    Firsov,
201    /// Lindhard (Thomas-Fermi) length,
202    /// `a_L = 0.8853 a0 / (Z1^(2/3) + Z2^(2/3))^(1/2)`.
203    /// J. Lindhard, M. Scharff, H. E. Schiott, Mat. Fys. Medd. Dan. Vid. Selsk.
204    /// 33(14) (1963), p. 8, printed as `a = a0 . 0.8853 (Z1^(2/3) +
205    /// Z2^(2/3))^(-1/2)`. Verified against the scan at
206    /// <http://gymarkiv.sdu.dk/MFM/kdvs/mfm%2030-39/mfm-33-14.pdf>.
207    Lindhard,
208}
209
210/// The Thomas-Fermi constant `(9 pi^2 / 128)^(1/3) = 0.8853`, which multiplies
211/// the Bohr radius in the Firsov and Lindhard lengths. The closed form is
212/// Firsov's (Sov. Phys. JETP 6, 534 (1958), p. 535), and the rounded value
213/// `0.8853` is the one printed by Lindhard, Scharff and Schiott (1963), p. 8.
214pub fn thomas_fermi_constant() -> f64 {
215    (9.0 * std::f64::consts::PI * std::f64::consts::PI / 128.0).cbrt()
216}
217
218impl ScreeningLength {
219    /// Screening length in metres for atomic numbers `z1`, `z2`.
220    pub fn metres(self, z1: f64, z2: f64) -> f64 {
221        let a0 = BOHR_RADIUS;
222        match self {
223            ScreeningLength::Universal => {
224                thomas_fermi_constant() * a0 / (z1.powf(0.23) + z2.powf(0.23))
225            }
226            ScreeningLength::Firsov => {
227                thomas_fermi_constant() * a0 / (z1.sqrt() + z2.sqrt()).powf(2.0 / 3.0)
228            }
229            ScreeningLength::Lindhard => {
230                thomas_fermi_constant() * a0 / (z1.powf(2.0 / 3.0) + z2.powf(2.0 / 3.0)).sqrt()
231            }
232        }
233    }
234}
235
236/// A screened-Coulomb pair potential for a specific `(Z1, Z2)`.
237#[derive(Debug, Clone, Copy, PartialEq)]
238pub struct Potential {
239    /// Screening function.
240    pub screening: Screening,
241    /// Screening length convention.
242    pub length: ScreeningLength,
243    /// Projectile atomic number.
244    pub z1: f64,
245    /// Target atomic number.
246    pub z2: f64,
247}
248
249impl Potential {
250    /// Potential with the conventional length for the chosen screening function.
251    pub fn new(screening: Screening, z1: f64, z2: f64) -> Self {
252        Self {
253            screening,
254            length: screening.default_length(),
255            z1,
256            z2,
257        }
258    }
259
260    /// Override the screening length.
261    pub fn with_length(mut self, length: ScreeningLength) -> Self {
262        self.length = length;
263        self
264    }
265
266    /// Screening length `a`, metres.
267    pub fn screening_length(&self) -> f64 {
268        self.length.metres(self.z1, self.z2)
269    }
270
271    /// Potential energy at separation `r` (metres), joules.
272    pub fn energy(&self, r: f64) -> f64 {
273        self.z1 * self.z2 * COULOMB_E2 / r * self.screening.phi(r / self.screening_length())
274    }
275
276    /// Reduced centre-of-mass energy `eps = a E_cm / (Z1 Z2 e^2/(4 pi eps0))`,
277    /// from `E_cm` in joules. (Lindhard-Scharff-Schiott reduced energy.)
278    pub fn reduced_energy(&self, e_cm_j: f64) -> f64 {
279        self.screening_length() * e_cm_j / (self.z1 * self.z2 * COULOMB_E2)
280    }
281
282    /// Centre-of-mass energy in joules from lab energy `e_lab_j` for projectile
283    /// mass `m1` and target mass `m2` (any common unit): `E_cm = E M2/(M1+M2)`.
284    pub fn cm_energy(e_lab_j: f64, m1: f64, m2: f64) -> f64 {
285        e_lab_j * m2 / (m1 + m2)
286    }
287
288    /// Impact parameter in metres from reduced `beta = b / a`.
289    pub fn impact_parameter(&self, beta: f64) -> f64 {
290        beta * self.screening_length()
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn phi_is_one_at_origin_and_decreasing() {
300        for s in Screening::ALL {
301            let p0 = s.phi(1e-12);
302            assert!((p0 - 1.0).abs() < 5e-3, "{s:?} phi(0) = {p0}");
303            let mut prev = p0;
304            for k in 1..200 {
305                let v = s.phi(k as f64 * 0.1);
306                assert!(v < prev && v > 0.0, "{s:?} not decreasing at {k}");
307                prev = v;
308            }
309        }
310    }
311
312    #[test]
313    fn derivative_matches_finite_difference() {
314        for s in Screening::ALL {
315            for &x in &[0.05, 0.3, 1.0, 3.0, 10.0] {
316                let h = 1e-6 * x;
317                let fd = (s.phi(x + h) - s.phi(x - h)) / (2.0 * h);
318                let an = s.dphi(x);
319                assert!((fd - an).abs() < 1e-6 * an.abs().max(1e-3), "{s:?} x={x}");
320            }
321        }
322    }
323
324    #[test]
325    fn functions_agree_with_thomas_fermi_scale() {
326        // All four approximate the Thomas-Fermi function, phi_TF(1) ~ 0.40.
327        for s in Screening::ALL {
328            let v = s.phi(1.0);
329            assert!((0.3..0.5).contains(&v), "{s:?} phi(1) = {v}");
330        }
331    }
332
333    #[test]
334    fn screening_lengths_match_closed_forms() {
335        // Explicit closed forms for Z1 = Z2 = 14.
336        let a0 = BOHR_RADIUS;
337        let tf = thomas_fermi_constant();
338        assert!((tf - 0.8853).abs() < 1e-4);
339        let a = ScreeningLength::Lindhard.metres(14.0, 14.0);
340        let expect = tf * a0 / (2.0 * 14f64.powf(2.0 / 3.0)).sqrt();
341        assert!((a / expect - 1.0).abs() < 1e-12);
342        let a = ScreeningLength::Firsov.metres(14.0, 14.0);
343        let expect = tf * a0 / (2.0 * 14f64.sqrt()).powf(2.0 / 3.0);
344        assert!((a / expect - 1.0).abs() < 1e-12);
345        // Universal: ZBL (1985) Eq. 2-73 prints 0.8853; same constant as above.
346        let a = ScreeningLength::Universal.metres(5.0, 14.0);
347        let expect = tf * a0 / (5f64.powf(0.23) + 14f64.powf(0.23));
348        assert!((a / expect - 1.0).abs() < 1e-12);
349    }
350
351    /// The Thomas-Fermi prefactor against the numbers printed in the primary
352    /// sources: Firsov (1958), p. 535, `a = (9 pi^2/128)^(1/3) hbar^2/(m e^2) =
353    /// 4.68e-9 cm`, and Lindhard, Scharff, Schiott (1963), p. 8, `0.8853`.
354    #[test]
355    fn screening_length_prefactors_match_primary_sources() {
356        let tf = thomas_fermi_constant();
357        // LSS (1963) prints four digits.
358        assert_eq!((tf * 1e4).round(), 8853.0);
359        // Firsov (1958) prints a = 4.68e-9 cm. The value is 4.6850e-9 cm, so
360        // the printed figure is truncated, not rounded: require agreement to
361        // one unit in its last digit.
362        let a_cm = tf * BOHR_RADIUS * 100.0;
363        assert!((a_cm - 4.68e-9).abs() < 0.01e-9, "a = {a_cm} cm");
364        // Firsov p. 536: R = a x' / (sqrt(Z1) + sqrt(Z2))^(2/3).
365        let (z1, z2) = (5.0f64, 14.0f64);
366        let expect = tf * BOHR_RADIUS / (z1.sqrt() + z2.sqrt()).powf(2.0 / 3.0);
367        assert!((ScreeningLength::Firsov.metres(z1, z2) / expect - 1.0).abs() < 1e-12);
368    }
369
370    #[test]
371    fn exponential_sets_are_normalised() {
372        // phi(0) = 1 requires the prefactors to sum to 1.
373        let sum = |c: &[(f64, f64)]| c.iter().map(|&(a, _)| a).sum::<f64>();
374        assert!((sum(&ZBL_C) - 1.0).abs() < 1e-4);
375        assert!((sum(&KRC_C) - 1.0).abs() < 1e-5);
376        assert!((sum(&MOLIERE_C) - 1.0).abs() < 1e-12);
377        // The ZBL exponents are 3.2, 0.9423, 0.4029, 0.2016.
378        assert_eq!(ZBL_C[2].1, 0.4029);
379    }
380
381    #[test]
382    fn reduced_energy_round_trip() {
383        let p = Potential::new(Screening::ZblUniversal, 5.0, 14.0);
384        let e = 1.0e3 * crate::units::J_PER_EV;
385        let eps = p.reduced_energy(e);
386        assert!(eps > 0.0 && eps < 1.0);
387        // V(r) at r = a: Z1 Z2 e^2/a * phi(1), in units of E_cm = eps^-1 ...
388        let a = p.screening_length();
389        let v = p.energy(a);
390        let expect = p.z1 * p.z2 * COULOMB_E2 / a * p.screening.phi(1.0);
391        assert!((v / expect - 1.0).abs() < 1e-12);
392    }
393}