lindhard/ion/scattering.rs
1//! Classical elastic scattering in a screened-Coulomb potential.
2//!
3//! Everything is in the reduced variables of [`crate::ion::potential`]:
4//! `x = r/a`, `beta = b/a`, `eps = a E_cm / (Z1 Z2 e^2/(4 pi eps0))`. The
5//! radial motion in the centre-of-mass frame obeys
6//! `G(x) = 1 - phi(x)/(eps x) - beta^2/x^2`, with turning point `G(x0) = 0`.
7//! The centre-of-mass scattering angle is (Goldstein; ZBL 1985, Ch. 2)
8//!
9//! ```text
10//! theta = pi - 2 beta * Int_{x0}^{inf} dx / (x^2 sqrt(G(x)))
11//! ```
12//!
13//! Three evaluators are provided: direct Gauss-Mehler quadrature
14//! ([`theta_quadrature`], accurate everywhere), the Biersack-Haggmark "magic
15//! formula" ([`theta_magic`], fast and approximate), and a precomputed
16//! [`ScatteringTable`] for the hot path.
17
18use super::potential::{Potential, Screening};
19use std::f64::consts::{FRAC_PI_2, PI};
20
21/// Default number of Gauss-Mehler nodes for [`theta_quadrature`].
22pub const DEFAULT_NODES: usize = 64;
23
24/// Reduced distance of closest approach `x0` for reduced energy `eps` and
25/// reduced impact parameter `beta > 0`.
26///
27/// Solves `G(x) = 0`. `G` is strictly increasing (both `phi/x` and `1/x^2`
28/// decrease), so the root is unique and bracketed by
29/// `[beta, (1/eps + sqrt(1/eps^2 + 4 beta^2))/2]`: at `x = beta` the
30/// potential term makes `G < 0`, and at the upper end `G >= 0` because
31/// `phi <= 1`. Newton's method runs inside that bracket; any step that leaves
32/// it is replaced by bisection.
33pub fn closest_approach(s: Screening, eps: f64, beta: f64) -> f64 {
34 let inv = 1.0 / eps;
35 let mut hi = 0.5 * (inv + (inv * inv + 4.0 * beta * beta).sqrt());
36 let mut lo = beta;
37 let g = |x: f64| 1.0 - s.phi(x) * inv / x - beta * beta / (x * x);
38 let dg =
39 |x: f64| -s.dphi(x) * inv / x + s.phi(x) * inv / (x * x) + 2.0 * beta * beta / (x * x * x);
40 let mut x = hi;
41 for _ in 0..200 {
42 let gx = g(x);
43 if gx > 0.0 {
44 hi = x;
45 } else {
46 lo = x;
47 }
48 let d = dg(x);
49 let mut xn = x - gx / d;
50 if !(xn > lo && xn < hi) || !xn.is_finite() {
51 xn = 0.5 * (lo + hi);
52 }
53 if (xn - x).abs() <= 1e-15 * xn || hi - lo <= 1e-15 * hi {
54 return xn;
55 }
56 x = xn;
57 }
58 x
59}
60
61/// Centre-of-mass scattering angle (radians, in `[0, pi]`) by Gauss-Mehler
62/// quadrature with `n` nodes (Mendenhall and Weller, Nucl. Instrum. Methods B 58
63/// (1991) 11; Gauss-Chebyshev rule of the first kind).
64///
65/// With `u = x0/x` the integral becomes `Int_0^1 du / sqrt(F(u))` where
66/// `F(u) = (1 - u^2) H(u)` vanishes at the turning point. Writing
67/// `u = cos t` removes the inverse-square-root endpoint singularity, leaving
68/// `Int_0^{pi/2} dt / sqrt(H(cos t))`, which the midpoint rule in `t` (the
69/// Gauss-Mehler nodes) integrates with exponential convergence. `H` is
70/// evaluated from the cancellation-free form
71/// `H(u) = beta^2/x0^2 + [phi(x0) - u phi(x0/u)] / (eps x0 (1 - u^2))`.
72pub fn theta_quadrature_n(s: Screening, eps: f64, beta: f64, n: usize) -> f64 {
73 if beta <= 0.0 {
74 return PI;
75 }
76 let x0 = closest_approach(s, eps, beta);
77 let phi0 = s.phi(x0);
78 let b2 = beta * beta / (x0 * x0);
79 let sb2 = b2.sqrt();
80 let c = 1.0 / (eps * x0);
81 let h = FRAC_PI_2 / n as f64;
82 // theta = pi - 2 I and, with no potential, I = pi/2 exactly, so
83 // theta = 2 (beta/x0) h Sum_k (1/sqrt(b2) - 1/sqrt(H_k)) (the k-sum of the
84 // unperturbed integrand reproduces (pi/2) x0/beta). Each term is written as
85 // delta / (sqrt(H) sqrt(b2) (sqrt(H) + sqrt(b2))) with delta = H - b2 >= 0,
86 // so small angles keep full relative precision (no pi - 2 I cancellation).
87 let mut sum = 0.0;
88 for k in 0..n {
89 let t = (k as f64 + 0.5) * h;
90 let (st, u) = t.sin_cos();
91 let delta = c * (phi0 - u * s.phi(x0 / u)) / (st * st);
92 let sh = (b2 + delta).sqrt();
93 sum += delta / (sh * sb2 * (sh + sb2));
94 }
95 (2.0 * beta / x0 * sum * h).min(PI)
96}
97
98/// [`theta_quadrature_n`] with [`DEFAULT_NODES`] nodes.
99pub fn theta_quadrature(s: Screening, eps: f64, beta: f64) -> f64 {
100 theta_quadrature_n(s, eps, beta, DEFAULT_NODES)
101}
102
103/// Constants of the Biersack-Haggmark magic formula for one screening function.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct MagicConstants {
106 /// `C1` .. `C5` of the fit, in the published order (`gamma = (C4 + eps) /
107 /// (C5 + eps)`).
108 pub c: [f64; 5],
109}
110
111impl MagicConstants {
112 /// Fit to the universal (ZBL) potential, Ziegler, Biersack, Littmark (1985),
113 /// Ch. 2. Verified against J. F. Ziegler, J. P. Biersack, M. D. Ziegler,
114 /// *SRIM: The Stopping and Range of Ions in Matter* (SRIM Co., 2015),
115 /// Fig. 2-21, p. 2-42, which labels them `0.9923 0.01162 0.007122 9.307
116 /// 14.81`: these values to that rounding, with the last two listed in the
117 /// other order. Our order is the one that fits the formula (Eq. 2-105):
118 /// the swap fails `magic_formula_check_rejects_wrong_constants`. Also
119 /// cross-checked against the MIT-licensed `ir2-lab/screened_coulomb`
120 /// (commit f84c3c8), which carries the same five values in our order.
121 pub const ZBL: Self = Self {
122 c: [0.99229, 0.011615, 0.0071222, 14.813, 9.3066],
123 };
124 /// Fit to the Moliere potential, Biersack and Haggmark, Nucl. Instrum.
125 /// Methods 174 (1980) 257, doi:10.1016/0029-554X(80)90440-1. Verified
126 /// digit for digit against J. F. Ziegler, J. P. Biersack, M. D. Ziegler,
127 /// *SRIM: The Stopping and Range of Ions in Matter* (SRIM Co., 2015),
128 /// Table 4-1, p. 7-6 (the 1980 paper itself is not open access). Also
129 /// tested against the quadrature (`magic_formula_tracks_quadrature_for_moliere`),
130 /// which rejects the ZBL set and a `C4`/`C5` swap. See
131 /// `docs/data-provenance.md`.
132 pub const MOLIERE: Self = Self {
133 c: [0.6743, 0.009611, 0.005175, 10.0, 6.314],
134 };
135
136 /// The constant set published for a screening function, if one exists.
137 pub fn for_screening(s: Screening) -> Option<Self> {
138 match s {
139 Screening::ZblUniversal => Some(Self::ZBL),
140 Screening::Moliere => Some(Self::MOLIERE),
141 _ => None,
142 }
143 }
144}
145
146/// Biersack-Haggmark "magic formula" (Nucl. Instrum. Methods 174 (1980) 257) for
147/// the centre-of-mass angle:
148///
149/// ```text
150/// cos(theta/2) = (beta + rho + Delta) / (x0 + rho)
151/// Delta = A (x0 - beta) / (1 + G)
152/// A = 2 alpha eps beta^b, alpha = 1 + C1 eps^(-1/2),
153/// b = (C2 + eps^(1/2)) / (C3 + eps^(1/2)),
154/// G = gamma / (sqrt(1 + A^2) - A), gamma = (C4 + eps) / (C5 + eps)
155/// ```
156///
157/// with `rho = 2 (1 - V(x0)/E) / (-V'(x0)/E)` the radius of curvature of the
158/// trajectory at the distance of closest approach. Only the closest-approach
159/// root find is needed, no integral.
160///
161/// This form (no `Delta` in the denominator, `gamma` divided by the bracket)
162/// agrees with the MIT-licensed `ir2-lab/screened_coulomb` (commit f84c3c8).
163/// It is also the only arrangement that reproduces the quadrature to the
164/// accuracy expected of the fit. The tests check this to 1e-2 in
165/// `cos(theta/2)` for both constant sets.
166pub fn theta_magic(s: Screening, k: &MagicConstants, eps: f64, beta: f64) -> f64 {
167 if beta <= 0.0 {
168 return PI;
169 }
170 let x0 = closest_approach(s, eps, beta);
171 let phi = s.phi(x0);
172 let dphi = s.dphi(x0);
173 // -dV/dx / E at x0, with V/E = phi/(eps x).
174 let force = (phi / x0 - dphi) / (eps * x0);
175 let rho = 2.0 * (1.0 - phi / (eps * x0)) / force;
176 let [c1, c2, c3, c4, c5] = k.c;
177 let se = eps.sqrt();
178 let alpha = 1.0 + c1 / se;
179 let bexp = (c2 + se) / (c3 + se);
180 let a = 2.0 * alpha * eps * beta.powf(bexp);
181 let gamma = (c4 + eps) / (c5 + eps);
182 // gamma / (sqrt(1 + A^2) - A) == gamma (sqrt(1 + A^2) + A), without the
183 // cancellation at large A.
184 let g = gamma * ((1.0 + a * a).sqrt() + a);
185 let delta = a * (x0 - beta) / (1.0 + g);
186 let cos_half = ((beta + rho + delta) / (x0 + rho)).clamp(-1.0, 1.0);
187 2.0 * cos_half.acos()
188}
189
190/// Reduced nuclear stopping cross section
191/// `s_n(eps) = 2 eps Int_0^inf sin^2(theta/2) beta d beta`
192/// from the quadrature angle (reduced units of ZBL 1985, Ch. 2; the energy
193/// transfer is `T = gamma E sin^2(theta/2)`). See [`nuclear_stopping_with`].
194pub fn nuclear_stopping_reduced(
195 s: Screening,
196 eps: f64,
197 beta_min: f64,
198 beta_max: f64,
199 per_decade: usize,
200) -> f64 {
201 nuclear_stopping_with(
202 |b| theta_quadrature(s, eps, b),
203 eps,
204 beta_min,
205 beta_max,
206 per_decade,
207 )
208}
209
210/// Reduced nuclear stopping cross section for an arbitrary angle function
211/// `theta(beta)` at fixed `eps` (quadrature, magic formula, table, ...).
212/// Integrated over `ln beta` with Simpson's rule, `per_decade` intervals per
213/// decade from `beta_min` to `beta_max`; the contributions below `beta_min`
214/// (`~ beta_min^2`) and above `beta_max` (screened, exponentially small) are
215/// neglected.
216pub fn nuclear_stopping_with<F: Fn(f64) -> f64>(
217 theta: F,
218 eps: f64,
219 beta_min: f64,
220 beta_max: f64,
221 per_decade: usize,
222) -> f64 {
223 let decades = (beta_max / beta_min).log10();
224 let mut n = (decades * per_decade as f64).ceil() as usize;
225 if n % 2 == 1 {
226 n += 1;
227 }
228 let du = (beta_max / beta_min).ln() / n as f64;
229 let f = |u: f64| {
230 let b = beta_min * u.exp();
231 let sn = (0.5 * theta(b)).sin();
232 sn * sn * b * b
233 };
234 let mut acc = f(0.0) + f(n as f64 * du);
235 for i in 1..n {
236 acc += f(i as f64 * du) * if i % 2 == 1 { 4.0 } else { 2.0 };
237 }
238 2.0 * eps * acc * du / 3.0
239}
240
241/// Specification of a [`ScatteringTable`] grid.
242#[derive(Debug, Clone, Copy, PartialEq)]
243pub struct TableSpec {
244 /// Smallest reduced energy.
245 pub eps_min: f64,
246 /// Largest reduced energy.
247 pub eps_max: f64,
248 /// Smallest reduced impact parameter (below it the angle is the `beta -> 0`
249 /// limit, `theta ~ pi`).
250 pub beta_min: f64,
251 /// Largest reduced impact parameter (above it the angle is taken to be 0).
252 pub beta_max: f64,
253 /// Grid points per decade in both axes.
254 pub per_decade: usize,
255}
256
257impl Default for TableSpec {
258 fn default() -> Self {
259 Self {
260 eps_min: 1e-5,
261 eps_max: 1e4,
262 beta_min: 1e-5,
263 beta_max: 1e3,
264 per_decade: 32,
265 }
266 }
267}
268
269/// Precomputed centre-of-mass scattering angle `theta(eps, beta)`.
270///
271/// The table stores `y = ln tan(theta/2)` on a grid uniform in `ln eps` and
272/// `ln beta`, and interpolates bilinearly in those coordinates. `y` is close to
273/// linear in `ln beta` at both ends (`tan(theta/2) ~ 1/beta` as `beta -> 0`, and
274/// `theta ~ beta^-k` for large `beta`), which is what makes bilinear
275/// interpolation accurate. Since `d theta = sin(theta) dy`, an error `dy` in the
276/// table value is at most `dy` radians in `theta`.
277///
278/// The angle depends on `(eps, beta)` and on the screening *function* only; `Z1`,
279/// `Z2` and the screening length enter through the reduced variables. The
280/// table keeps its [`Potential`] so one table serves one `(Z1, Z2, potential)`
281/// pair and converts to and from SI. It is built once and shared read-only
282/// (`&ScatteringTable` is `Sync`).
283///
284/// # Error bound
285///
286/// At build time the interpolation error against direct quadrature is measured
287/// at cell centres (where bilinear interpolation error peaks) on a
288/// deterministic subset of cells. [`ScatteringTable::max_abs_error`] is the
289/// largest absolute error in `theta` seen (radians) and
290/// [`ScatteringTable::max_rel_error`] the largest relative error where
291/// `theta > 1e-6`. These are empirical bounds from sampling, not proofs.
292///
293/// Outside the grid: `beta >= beta_max` returns `0.0`; `beta < beta_min` is
294/// clamped to `beta_min`; `eps` outside `[eps_min, eps_max]` returns `None`.
295#[derive(Debug, Clone)]
296pub struct ScatteringTable {
297 potential: Potential,
298 spec: TableSpec,
299 n_eps: usize,
300 n_beta: usize,
301 d: f64,
302 y: Vec<f64>,
303 max_abs_error: f64,
304 max_rel_error: f64,
305}
306
307/// Floor for `theta/2` in the stored values; below it `theta` is indistinguishable
308/// from rounding noise of `pi - 2 I`.
309const HALF_THETA_FLOOR: f64 = 5e-16;
310
311fn half_theta_to_y(half: f64) -> f64 {
312 half.max(HALF_THETA_FLOOR).tan().ln()
313}
314
315impl ScatteringTable {
316 /// Build a table by direct quadrature. Serial and deterministic.
317 pub fn build(potential: &Potential, spec: &TableSpec) -> Self {
318 let pd = spec.per_decade as f64;
319 let d = std::f64::consts::LN_10 / pd;
320 let n_eps = ((spec.eps_max / spec.eps_min).log10() * pd).ceil() as usize + 1;
321 let n_beta = ((spec.beta_max / spec.beta_min).log10() * pd).ceil() as usize + 1;
322 let s = potential.screening;
323 let mut y = Vec::with_capacity(n_eps * n_beta);
324 for i in 0..n_eps {
325 let eps = spec.eps_min * (i as f64 * d).exp();
326 for j in 0..n_beta {
327 let beta = spec.beta_min * (j as f64 * d).exp();
328 y.push(half_theta_to_y(0.5 * theta_quadrature(s, eps, beta)));
329 }
330 }
331 let mut t = Self {
332 potential: *potential,
333 spec: *spec,
334 n_eps,
335 n_beta,
336 d,
337 y,
338 max_abs_error: 0.0,
339 max_rel_error: 0.0,
340 };
341 t.measure_error();
342 t
343 }
344
345 fn measure_error(&mut self) {
346 let s = self.potential.screening;
347 let (mut abs_e, mut rel_e) = (0.0f64, 0.0f64);
348 // Cell centres on a deterministic stride-3 subset (offset by 1 so the
349 // sampled cells differ from a lattice-aligned pattern).
350 for i in (0..self.n_eps - 1).step_by(3) {
351 let eps = self.spec.eps_min * ((i as f64 + 0.5) * self.d).exp();
352 for j in (1..self.n_beta - 1).step_by(3) {
353 let beta = self.spec.beta_min * ((j as f64 + 0.5) * self.d).exp();
354 let direct = theta_quadrature(s, eps, beta);
355 if let Some(v) = self.theta(eps, beta) {
356 let e = (v - direct).abs();
357 abs_e = abs_e.max(e);
358 if direct > 1e-6 {
359 rel_e = rel_e.max(e / direct);
360 }
361 }
362 }
363 }
364 self.max_abs_error = abs_e;
365 self.max_rel_error = rel_e;
366 }
367
368 /// The potential this table was built for.
369 pub fn potential(&self) -> &Potential {
370 &self.potential
371 }
372
373 /// The grid specification.
374 pub fn spec(&self) -> &TableSpec {
375 &self.spec
376 }
377
378 /// Largest absolute `theta` error (radians) found against quadrature at
379 /// sampled cell centres.
380 pub fn max_abs_error(&self) -> f64 {
381 self.max_abs_error
382 }
383
384 /// Largest relative `theta` error found at sampled cell centres where
385 /// `theta > 1e-6`.
386 pub fn max_rel_error(&self) -> f64 {
387 self.max_rel_error
388 }
389
390 /// Interpolated centre-of-mass angle (radians), or `None` if `eps` is out
391 /// of the tabulated range.
392 pub fn theta(&self, eps: f64, beta: f64) -> Option<f64> {
393 self.half_angle_tan(eps, beta).map(|t| 2.0 * t.atan())
394 }
395
396 /// Interpolated `tan(theta / 2)` (the stored quantity is its logarithm),
397 /// or `None` if `eps` is out of the tabulated range; `0.0` for
398 /// `beta >= beta_max`. [`ScatteringTable::theta`] is `2 atan` of this.
399 ///
400 /// The collision code needs `sin` and `cos` of `theta / 2` (energy
401 /// transfer, lab angles), which follow from the tangent by algebra, so it
402 /// avoids the `atan` here and the trigonometric calls that would undo it.
403 pub fn half_angle_tan(&self, eps: f64, beta: f64) -> Option<f64> {
404 if !(eps >= self.spec.eps_min && eps <= self.spec.eps_max) {
405 return None;
406 }
407 if beta >= self.spec.beta_max {
408 return Some(0.0);
409 }
410 let beta = beta.max(self.spec.beta_min);
411 let fi = (eps / self.spec.eps_min).ln() / self.d;
412 let fj = (beta / self.spec.beta_min).ln() / self.d;
413 let i = (fi.floor() as usize).min(self.n_eps - 2);
414 let j = (fj.floor() as usize).min(self.n_beta - 2);
415 let (ti, tj) = (fi - i as f64, fj - j as f64);
416 let at = |a: usize, b: usize| self.y[a * self.n_beta + b];
417 let y = (1.0 - ti) * ((1.0 - tj) * at(i, j) + tj * at(i, j + 1))
418 + ti * ((1.0 - tj) * at(i + 1, j) + tj * at(i + 1, j + 1));
419 Some(y.exp())
420 }
421
422 /// Number of grid points in `(eps, beta)`.
423 pub fn shape(&self) -> (usize, usize) {
424 (self.n_eps, self.n_beta)
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 /// ZBL universal reduced nuclear stopping fit, Ziegler, Biersack, Littmark
433 /// (1985), Ch. 2: `ln(1 + 1.1383 eps) / (2 (eps + 0.01321 eps^0.21226 +
434 /// 0.19593 eps^0.5))` for `eps <= 30`, `ln(eps) / (2 eps)` above. Verified
435 /// digit for digit against J. F. Ziegler, J. P. Biersack, M. D. Ziegler,
436 /// *SRIM: The Stopping and Range of Ions in Matter* (SRIM Co., 2015),
437 /// Eqs. 2-89 and 2-90, p. 2-37; also cross-checked against the
438 /// MIT-licensed `ir2-lab/screened_coulomb` (commit f84c3c8).
439 fn zbl_fit(eps: f64) -> f64 {
440 if eps <= 30.0 {
441 (1.0 + 1.1383 * eps).ln()
442 / (2.0 * (eps + 0.01321 * eps.powf(0.21226) + 0.19593 * eps.sqrt()))
443 } else {
444 eps.ln() / (2.0 * eps)
445 }
446 }
447
448 #[test]
449 fn quadrature_converges_with_nodes() {
450 let s = Screening::ZblUniversal;
451 for &(eps, beta) in &[(1e-3, 0.5), (0.1, 1.0), (10.0, 0.1), (100.0, 3.0)] {
452 let a = theta_quadrature_n(s, eps, beta, DEFAULT_NODES);
453 let b = theta_quadrature_n(s, eps, beta, 1024);
454 assert!(
455 (a - b).abs() < 1e-6 * (1.0 + a),
456 "eps={eps} beta={beta}: {a} {b}"
457 );
458 }
459 }
460
461 #[test]
462 fn coulomb_limit_matches_rutherford() {
463 // Very high energy, tiny beta region is not Coulomb; but at small beta
464 // and huge eps phi ~ 1 and tan(theta/2) = 1/(2 eps beta).
465 let eps = 1e6;
466 let beta = 1e-3;
467 let th = theta_quadrature(Screening::ZblUniversal, eps, beta);
468 let expect = 2.0 * (1.0 / (2.0 * eps * beta)).atan();
469 assert!((th - expect).abs() < 2e-3 * expect, "{th} {expect}");
470 }
471
472 #[test]
473 fn limits_in_impact_parameter() {
474 for s in Screening::ALL {
475 for &eps in &[1e-3, 1.0, 1e3] {
476 assert!(theta_quadrature(s, eps, 1e3) < 1e-6, "{s:?} {eps}");
477 // Head-on is Coulomb-like: pi - theta ~ 4 eps beta.
478 let head_on = theta_quadrature(s, eps, 1e-9);
479 assert!((PI - head_on) < 1e-5 + 5e-9 * eps, "{s:?} {eps} {head_on}");
480 assert_eq!(theta_quadrature(s, eps, 0.0), PI);
481 }
482 }
483 }
484
485 #[test]
486 fn theta_decreases_with_beta() {
487 for s in Screening::ALL {
488 let mut prev = PI;
489 for k in 0..60 {
490 let beta = 1e-3 * 10f64.powf(k as f64 / 10.0);
491 let th = theta_quadrature(s, 0.5, beta);
492 assert!(th <= prev + 1e-12, "{s:?} beta={beta}");
493 prev = th;
494 }
495 }
496 }
497
498 #[test]
499 fn closest_approach_is_a_root_above_beta() {
500 for s in Screening::ALL {
501 for &eps in &[1e-4, 1e-2, 1.0, 1e3] {
502 for &beta in &[1e-6, 0.01, 1.0, 20.0] {
503 let x0 = closest_approach(s, eps, beta);
504 let g = 1.0 - s.phi(x0) / (eps * x0) - beta * beta / (x0 * x0);
505 assert!(x0 >= beta && g.abs() < 1e-10, "{s:?} {eps} {beta} g={g}");
506 }
507 }
508 }
509 }
510
511 /// High-energy limit of the reduced nuclear stopping for a sum-of-exponentials
512 /// screening function `phi = Sum_i c_i exp(-b_i x)`, `S = Sum_i c_i`:
513 ///
514 /// ```text
515 /// s_n(eps) -> (S^2 ln eps + C) / (2 eps),
516 /// C = S^2 (ln 2 - ln S - gamma_E) - Sum_ij c_i c_j L(b_i, b_j),
517 /// L(a, b) = (a^2 ln(a/2) - b^2 ln(b/2)) / (a^2 - b^2), L(a, a) = ln(a/2) + 1/2.
518 /// ```
519 ///
520 /// It comes from the first-order momentum (impulse) approximation
521 /// `theta = eps^-1 Sum_i c_i b_i K1(b_i beta)`, which holds for
522 /// `eps beta >> 1`. This is eq. (3.4) of J. Lindhard, V. Nielsen,
523 /// M. Scharff, Mat. Fys. Medd. Dan. Vid. Selsk. 36(10) (1968), p. 12,
524 /// `eps theta = (a/p) g(p/a)`, with `g` from eq. (3.3). For a sum of
525 /// exponentials `g(beta) = beta Sum_i c_i b_i K1(b_i beta)` (checked out of
526 /// tree with scipy to 1e-9; the formula itself is reproduced by the
527 /// quadrature in `small_angles_match_lns_perturbation_formula`). Copy seen:
528 /// <http://gymarkiv.sdu.dk/MFM/kdvs/mfm%2030-39/mfm-36-10.pdf>.
529 /// The approximation is matched to exact Rutherford scattering
530 /// (`phi -> S`) at small `beta`. (Higher-order momentum approximations are
531 /// treated by C. Lehmann and G. Leibfried, "Higher order momentum
532 /// approximations in classical collision theory", Z. Phys. 172, 465 (1963),
533 /// doi:10.1007/BF01378911; bibliographic data confirmed via Crossref, text
534 /// not seen, and not used here.) The
535 /// `Int beta K1(a beta) K1(b beta) d beta` integrals are Lommel integrals
536 /// (G. N. Watson, *A Treatise on the Theory of Bessel Functions*, 2nd ed.,
537 /// CUP 1944, Sec. 5.11), evaluated with the small-argument forms of `K0` and `K1`
538 /// (Abramowitz and Stegun, Ch. 9). For ZBL `C = 0.2744`. The
539 /// expression needs no fitted stopping data and no trajectory integral, so it
540 /// is an independent reference for the quadrature at high `eps`. It was also
541 /// checked against direct numerical integration of the `K1` sum (scipy, out
542 /// of tree; agreement 1e-12).
543 fn high_energy_asymptote(s: Screening, eps: f64) -> f64 {
544 let terms = s
545 .exponential_terms()
546 .expect("sum-of-exponentials screening");
547 let sum: f64 = terms.iter().map(|&(c, _)| c).sum();
548 let l = |a: f64, b: f64| {
549 if a == b {
550 (0.5 * a).ln() + 0.5
551 } else {
552 (a * a * (0.5 * a).ln() - b * b * (0.5 * b).ln()) / (a * a - b * b)
553 }
554 };
555 const EULER_GAMMA: f64 = 0.577_215_664_901_532_9;
556 let mut c = sum * sum * (std::f64::consts::LN_2 - sum.ln() - EULER_GAMMA);
557 for &(ci, bi) in terms {
558 for &(cj, bj) in terms {
559 c -= ci * cj * l(bi, bj);
560 }
561 }
562 (sum * sum * eps.ln() + c) / (2.0 * eps)
563 }
564
565 /// Small-angle deflection from the first-order perturbation (momentum)
566 /// formula of Lindhard, Nielsen and Scharff, Mat. Fys. Medd. 36(10) (1968),
567 /// p. 12, eqs. (3.3)-(3.4): `eps theta = g(beta) / beta` with
568 /// `g(z) = Int_0^{pi/2} cos t [u(z/cos t) - (z/cos t) u'(z/cos t)] dt`,
569 /// `u = phi`. Midpoint rule in `t`; the integrand is smooth and vanishes
570 /// at `t = pi/2`.
571 fn lns_perturbation_angle(s: Screening, eps: f64, beta: f64) -> f64 {
572 let n = 4000;
573 let h = FRAC_PI_2 / n as f64;
574 let mut g = 0.0;
575 for k in 0..n {
576 let c = ((k as f64 + 0.5) * h).cos();
577 let x = beta / c;
578 g += c * (s.phi(x) - x * s.dphi(x));
579 }
580 g * h / (beta * eps)
581 }
582
583 /// The quadrature reproduces the cited small-angle formula (LNS 1968,
584 /// eq. (3.4)) at high `eps` for every screening function, Lenz-Jensen
585 /// included. Measured: worst relative deviation 5.0e-6 (the first-order
586 /// formula's own error is `O(theta)`, and every angle here is small).
587 #[test]
588 fn small_angles_match_lns_perturbation_formula() {
589 let eps = 1e5;
590 let mut worst = 0.0f64;
591 for s in Screening::ALL {
592 for &beta in &[0.1, 0.3, 1.0, 3.0, 10.0] {
593 let q = theta_quadrature(s, eps, beta);
594 let p = lns_perturbation_angle(s, eps, beta);
595 let rel = (q / p - 1.0).abs();
596 worst = worst.max(rel);
597 assert!(rel < 2e-5, "{s:?} beta={beta}: quadrature {q:e}, LNS {p:e}");
598 }
599 }
600 eprintln!("LNS (3.4) vs quadrature at eps = 1e5: worst rel dev {worst:.2e}");
601 }
602
603 /// `beta_min` small enough that the neglected head-on part (`~ beta_min^2`,
604 /// Rutherford-like up to `beta ~ 1/eps`) is negligible at every `eps`.
605 fn stopping_beta_min(eps: f64) -> f64 {
606 1e-4f64.min(1e-3 / eps)
607 }
608
609 /// Low-energy part of the acceptance test of issue #3: s_n from the
610 /// quadrature against the ZBL universal stopping fit, `< 0.5 %` for
611 /// `1e-4 <= eps <= 0.3`. Measured: 0.39 % worst (at `eps = 0.3`).
612 #[test]
613 fn stopping_vs_zbl_universal_fit_low_energy() {
614 let mut worst = 0.0f64;
615 for k in 0..=7 {
616 let eps = 1e-4 * 10f64.powf(k as f64 / 2.0);
617 let sn = nuclear_stopping_reduced(Screening::ZblUniversal, eps, 1e-4, 1e3, 40);
618 worst = worst.max((sn / zbl_fit(eps) - 1.0).abs());
619 }
620 eprintln!("zbl fit: worst rel dev for eps <= 0.3: {worst:.3e}");
621 assert!(worst < 5e-3, "eps <= 0.3: {worst}");
622 }
623
624 /// High-energy part: s_n from the quadrature against the analytic
625 /// asymptote [`high_energy_asymptote`] for every sum-of-exponentials
626 /// screening, `< 0.2 %` for `1e3 <= eps <= 1e6`. Measured: 0.10 % at
627 /// `eps = 1e3`, falling roughly as `1/eps` (below 1e-4 from `eps = 1e4`).
628 ///
629 /// This also fixes the cause of the deviation from the ZBL fit above
630 /// `eps ~ 1`. The fit's `eps > 30` branch, `ln(eps) / (2 eps)`, is this
631 /// asymptote with `C = 0`, so it underestimates the stopping of the ZBL
632 /// potential by `C / ln(eps)`: 4.0 % at `1e3` and 1.5 % at `1e8`. The
633 /// quadrature reproduces this to within the asymptote's own `O(1/eps)`
634 /// error (measured 3.9 % and 1.5 %). The fit's `eps <= 30` branch joins
635 /// that branch to about 1 % at `eps = 30`, which is where the deviation
636 /// of a few percent for `1 < eps < 30` comes from.
637 #[test]
638 fn stopping_matches_high_energy_asymptote() {
639 for s in [Screening::ZblUniversal, Screening::KrC, Screening::Moliere] {
640 for &eps in &[1e3, 1e4, 1e5, 1e6] {
641 let sn = nuclear_stopping_reduced(s, eps, stopping_beta_min(eps), 1e3, 40);
642 let rel = (sn / high_energy_asymptote(s, eps) - 1.0).abs();
643 assert!(rel < 2e-3, "{s:?} eps={eps}: {rel}");
644 }
645 }
646 // The ZBL fit's high-energy branch misses exactly the constant C.
647 let eps = 1e6;
648 let c = 2.0 * eps * high_energy_asymptote(Screening::ZblUniversal, eps) - eps.ln();
649 assert!((c - 0.2744).abs() < 2e-3, "C = {c}");
650 }
651
652 /// Between the two regimes (`0.3 < eps < 1e3`) there is no independent
653 /// published reference, and the ZBL fit deviates there for the reason given
654 /// in [`stopping_matches_high_energy_asymptote`] (measured: up to 5.5 % near
655 /// `eps = 30`). This test checks that the quadrature s_n is converged there:
656 /// 16x the angle nodes, 4x the Simpson density and a 1e4x wider `beta` range
657 /// change it by less than 2e-5. The deviation from the fit is printed, not
658 /// asserted.
659 #[test]
660 fn stopping_is_converged_between_regimes() {
661 let s = Screening::ZblUniversal;
662 for k in 0..=7 {
663 let eps = 10f64.powf(-0.5 + k as f64 / 2.0);
664 let sn = nuclear_stopping_reduced(s, eps, stopping_beta_min(eps), 1e3, 40);
665 let fine = nuclear_stopping_with(
666 |b| theta_quadrature_n(s, eps, b, 16 * DEFAULT_NODES),
667 eps,
668 stopping_beta_min(eps) * 1e-2,
669 1e5,
670 160,
671 );
672 let conv = (sn / fine - 1.0).abs();
673 eprintln!(
674 "eps={eps:.3e}: convergence {conv:.1e}, dev from ZBL fit {:+.4}",
675 sn / zbl_fit(eps) - 1.0
676 );
677 assert!(conv < 2e-5, "eps={eps}: {conv}");
678 }
679 }
680
681 /// Worst `|cos(theta_magic/2) - cos(theta_quadrature/2)|` over
682 /// `eps` in `[1e-3, 1e3]` and `beta` in `[1e-3, 1e2]`.
683 fn magic_worst(s: Screening, k: &MagicConstants) -> f64 {
684 let mut worst = 0.0f64;
685 for &eps in &[1e-3, 1e-2, 1e-1, 1.0, 10.0, 100.0, 1e3] {
686 for j in 0..50 {
687 let beta = 1e-3 * 10f64.powf(j as f64 / 10.0);
688 let q = theta_quadrature(s, eps, beta);
689 let m = theta_magic(s, k, eps, beta);
690 // Compare cos(theta/2): relative error in theta is ill-conditioned
691 // where theta is tiny.
692 worst = worst.max(((0.5 * m).cos() - (0.5 * q).cos()).abs());
693 }
694 }
695 worst
696 }
697
698 #[test]
699 fn magic_formula_tracks_quadrature_for_zbl() {
700 // Measured 0.016.
701 let worst = magic_worst(Screening::ZblUniversal, &MagicConstants::ZBL);
702 eprintln!("zbl magic worst |d cos(theta/2)| {worst}");
703 assert!(worst < 2e-2, "{worst}");
704 }
705
706 #[test]
707 fn magic_formula_tracks_quadrature_for_moliere() {
708 // Measured 0.0060.
709 let worst = magic_worst(Screening::Moliere, &MagicConstants::MOLIERE);
710 eprintln!("moliere magic worst |d cos(theta/2)| {worst}");
711 assert!(worst < 1e-2, "{worst}");
712 }
713
714 /// The Moliere check above can tell a mis-transcribed constant set apart.
715 /// The ZBL set on the Moliere potential (measured 0.015) and a `C4`/`C5`
716 /// swap (measured 0.07 to 0.09) all exceed its tolerance.
717 #[test]
718 fn magic_formula_check_rejects_wrong_constants() {
719 assert!(magic_worst(Screening::Moliere, &MagicConstants::ZBL) > 1e-2);
720 for (s, k) in [
721 (Screening::Moliere, MagicConstants::MOLIERE),
722 (Screening::ZblUniversal, MagicConstants::ZBL),
723 ] {
724 let mut swapped = k;
725 swapped.c.swap(3, 4);
726 assert!(magic_worst(s, &swapped) > 5e-2, "{s:?}");
727 }
728 }
729
730 #[test]
731 fn table_interpolation_within_stated_bound() {
732 let pot = Potential::new(Screening::ZblUniversal, 31.0, 7.0);
733 let spec = TableSpec {
734 eps_min: 1e-3,
735 eps_max: 1e2,
736 beta_min: 1e-3,
737 beta_max: 1e2,
738 per_decade: 32,
739 };
740 let t = ScatteringTable::build(&pot, &spec);
741 eprintln!("table abs {} rel {}", t.max_abs_error(), t.max_rel_error());
742 assert!(t.max_abs_error() < 1e-2);
743 // Deterministic off-grid points (LCG).
744 let mut st = 12345u64;
745 let mut next = || {
746 st = st
747 .wrapping_mul(6364136223846793005)
748 .wrapping_add(1442695040888963407);
749 (st >> 11) as f64 / (1u64 << 53) as f64
750 };
751 for _ in 0..500 {
752 let eps = 1e-3 * 10f64.powf(5.0 * next());
753 let beta = 1e-3 * 10f64.powf(4.9 * next() + 0.05);
754 let direct = theta_quadrature(Screening::ZblUniversal, eps, beta);
755 let tab = t.theta(eps, beta).unwrap();
756 assert!(
757 (tab - direct).abs() <= 1.5 * t.max_abs_error() + 1e-12,
758 "eps={eps} beta={beta} {tab} {direct}"
759 );
760 }
761 }
762
763 #[test]
764 fn table_limits_and_range() {
765 let pot = Potential::new(Screening::KrC, 18.0, 18.0);
766 let spec = TableSpec {
767 per_decade: 16,
768 ..TableSpec::default()
769 };
770 let t = ScatteringTable::build(&pot, &spec);
771 assert_eq!(t.theta(1e-9, 1.0), None);
772 assert_eq!(t.theta(1.0, 1e5), Some(0.0));
773 assert!(PI - t.theta(1.0, 1e-8).unwrap() < 1e-3);
774 }
775
776 #[test]
777 fn table_build_is_deterministic() {
778 let pot = Potential::new(Screening::Moliere, 14.0, 14.0);
779 let spec = TableSpec {
780 eps_min: 1e-2,
781 eps_max: 1e1,
782 beta_min: 1e-2,
783 beta_max: 1e1,
784 per_decade: 8,
785 };
786 let a = ScatteringTable::build(&pot, &spec);
787 let b = ScatteringTable::build(&pot, &spec);
788 assert_eq!(a.y, b.y);
789 }
790}