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}