Skip to main content

lindhard/ion/bca/
kinematics.rs

1//! Two-body elastic kinematics, direction rotation and planar surface
2//! refraction. Pure functions, no state.
3
4use std::f64::consts::PI;
5
6use crate::vec3::cross;
7
8/// Laboratory deflection of projectile and recoil for a centre-of-mass
9/// scattering angle `theta` and mass ratio `mu = M1 / M2`.
10///
11/// Classical elastic two-body kinematics (e.g. H. Goldstein, *Classical
12/// Mechanics*, 2nd ed. (1980), Sec. 3.11; M. T. Robinson and I. M. Torrens,
13/// Phys. Rev. B 9 (1974) 5008):
14///
15/// * projectile: `tan(psi) = sin(theta) / (cos(theta) + mu)`, `psi` in `[0, pi]`,
16/// * recoil: `phi = (pi - theta) / 2`, on the opposite azimuth,
17/// * energy transfer: `T = gamma E sin^2(theta/2)`,
18///   `gamma = 4 M1 M2 / (M1 + M2)^2`.
19///
20/// Returns `(psi, phi)`.
21pub fn lab_angles(theta: f64, mu: f64) -> (f64, f64) {
22    let psi = theta.sin().atan2(theta.cos() + mu);
23    (psi, 0.5 * (PI - theta))
24}
25
26/// Kinematic factor `gamma = 4 M1 M2 / (M1 + M2)^2`.
27pub fn gamma(m1: f64, m2: f64) -> f64 {
28    4.0 * m1 * m2 / ((m1 + m2) * (m1 + m2))
29}
30
31/// Normalise a vector to unit length.
32pub fn normalize(v: [f64; 3]) -> [f64; 3] {
33    crate::vec3::normalize(v)
34}
35
36/// Rotate unit vector `u` by polar angle `polar` (from `u`) at azimuth
37/// `azimuth` about `u`. The azimuth is measured in a frame built from `u` and
38/// the coordinate axis least aligned with it; since the azimuth is drawn
39/// uniformly the choice of frame does not matter, it only has to be fixed so
40/// results are reproducible.
41pub fn rotate(u: [f64; 3], polar: f64, azimuth: f64) -> [f64; 3] {
42    rotate_sc(u, polar.sin_cos(), azimuth.sin_cos())
43}
44
45/// [`rotate`] with the polar and azimuthal angles given as `(sin, cos)`
46/// pairs, so a caller that already has them (the collision code gets the
47/// lab deflection from the half-angle tangent, without an inverse
48/// trigonometric call) pays for no `sin_cos`.
49///
50/// `u` must be a unit vector to rounding and `polar` must have
51/// `sin^2 + cos^2 = 1` to rounding. The result is renormalised to first
52/// order, `v (3 - |v|^2) / 2`, which is exact to second order in
53/// `|v|^2 - 1` (about 1e-16 here) and so costs a multiply instead of a square
54/// root and a division; the rounding drift of a long chain of rotations
55/// therefore does not accumulate.
56#[inline]
57pub fn rotate_sc(u: [f64; 3], polar: (f64, f64), azimuth: (f64, f64)) -> [f64; 3] {
58    let k = if u[0].abs() < 0.5 {
59        [1.0, 0.0, 0.0]
60    } else if u[1].abs() < 0.5 {
61        [0.0, 1.0, 0.0]
62    } else {
63        [0.0, 0.0, 1.0]
64    };
65    let e1 = normalize(cross(u, k));
66    let e2 = cross(u, e1);
67    let (sp, cp) = polar;
68    let (sa, ca) = azimuth;
69    let v = [
70        cp * u[0] + sp * (ca * e1[0] + sa * e2[0]),
71        cp * u[1] + sp * (ca * e1[1] + sa * e2[1]),
72        cp * u[2] + sp * (ca * e1[2] + sa * e2[2]),
73    ];
74    let f = 0.5 * (3.0 - (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]));
75    [v[0] * f, v[1] * f, v[2] * f]
76}
77
78/// `(sin, cos)` of the angle `2 pi u` for `u` in `[0, 1)`, the azimuth of a
79/// collision drawn as a fraction of a turn.
80///
81/// The azimuth is the one transcendental call left per collision that the
82/// geometry of the scattering does not remove, and the platform `sin` and
83/// `cos` are two separate library calls. This does the same with exact
84/// argument reduction and short Maclaurin series: `x = 4u` is split as the
85/// nearest integer number of quarter turns `k` plus a remainder, so the
86/// series argument `r = (x - k) pi / 2` lies in `[-pi/4, pi/4]`, where the
87/// series through `r^17` (sine) and `r^18` (cosine) have truncation error
88/// below 1e-19 (the first omitted terms, `r^19 / 19!` and `r^20 / 20!`). The
89/// quarter turn `k` then rotates `(sin r, cos r)` into place. Results agree
90/// with the library `sin_cos` to about 2e-16 and are the same on every
91/// platform, because only IEEE multiplications and additions are used.
92#[inline]
93pub fn sin_cos_turns(u: f64) -> (f64, f64) {
94    let x = 4.0 * u;
95    let k = (x + 0.5).floor();
96    let r = (x - k) * (0.5 * PI);
97    let z = r * r;
98    let sin_r = r
99        * (1.0
100            + z * (-1.0 / 6.0
101                + z * (1.0 / 120.0
102                    + z * (-1.0 / 5_040.0
103                        + z * (1.0 / 362_880.0
104                            + z * (-1.0 / 39_916_800.0
105                                + z * (1.0 / 6_227_020_800.0
106                                    + z * (-1.0 / 1_307_674_368_000.0
107                                        + z * (1.0 / 355_687_428_096_000.0)))))))));
108    let cos_r = 1.0
109        + z * (-1.0 / 2.0
110            + z * (1.0 / 24.0
111                + z * (-1.0 / 720.0
112                    + z * (1.0 / 40_320.0
113                        + z * (-1.0 / 3_628_800.0
114                            + z * (1.0 / 479_001_600.0
115                                + z * (-1.0 / 87_178_291_200.0
116                                    + z * (1.0 / 20_922_789_888_000.0
117                                        + z * (-1.0 / 6_402_373_705_728_000.0)))))))));
118    match k as u8 & 3 {
119        0 => (sin_r, cos_r),
120        1 => (cos_r, -sin_r),
121        2 => (-sin_r, -cos_r),
122        _ => (-cos_r, sin_r),
123    }
124}
125
126/// Lab deflection of the projectile, as `(sin psi, cos psi)`, from the
127/// half-angle sine and cosine of the centre-of-mass angle (`s = sin(theta/2)`,
128/// `c = cos(theta/2)`, `s^2 + c^2 = 1`) and `mu = M1 / M2`.
129///
130/// The same relation as in [`lab_angles`], `tan(psi) = sin(theta) /
131/// (cos(theta) + mu)` (Goldstein, Sec. 3.11), written without the angle:
132/// `sin theta = 2 s c`, `cos theta = 1 - 2 s^2`, and the vector
133/// `(sin theta, cos theta + mu)` has length `sqrt(1 + 2 mu cos theta + mu^2)`.
134/// The recoil deflection `phi = (pi - theta) / 2` has `(sin, cos) = (c, s)`.
135#[inline]
136pub fn lab_projectile_sc(s: f64, c: f64, mu: f64) -> (f64, f64) {
137    let sin_t = 2.0 * s * c;
138    let cos_t = 1.0 - 2.0 * s * s;
139    let x = cos_t + mu;
140    let inv = 1.0 / (sin_t * sin_t + x * x).sqrt();
141    (sin_t * inv, x * inv)
142}
143
144/// Planar surface barrier at a face with outward normal along `x` (sign of
145/// `dir[0]` gives which face).
146///
147/// A particle with energy `e` reaching the face with direction cosine
148/// `c = |dir[0]|` to the normal escapes if `e c^2 > e_s` (the planar barrier
149/// model: only the normal component of the momentum is reduced, by the
150/// surface binding energy `E_s`). P. Sigmund, Phys. Rev. 184 (1969) 383;
151/// W. Eckstein, *Computer Simulation of Ion-Solid Interactions* (Springer,
152/// 1991). Outside, the energy is `e - e_s`, the parallel momentum is unchanged
153/// and the normal component satisfies `e' c'^2 = e c^2 - e_s` (refraction away
154/// from the normal).
155///
156/// Returns `Some((energy outside, direction outside))`, or `None` if the
157/// particle cannot escape (the caller reflects it specularly).
158pub fn refract_out(e: f64, dir: [f64; 3], e_s: f64) -> Option<(f64, [f64; 3])> {
159    refract_out_normal(e, dir, [1.0f64.copysign(dir[0]), 0.0, 0.0], e_s)
160}
161
162/// [`refract_out`] through a face with outward unit normal `normal` (any
163/// orientation, not only the planar `x` faces): the normal component of the
164/// momentum is reduced, the tangential components are unchanged. The
165/// particle must be moving outward (`dir . normal > 0`) to escape.
166pub fn refract_out_normal(
167    e: f64,
168    dir: [f64; 3],
169    normal: [f64; 3],
170    e_s: f64,
171) -> Option<(f64, [f64; 3])> {
172    let dn = dir[0] * normal[0] + dir[1] * normal[1] + dir[2] * normal[2];
173    let c = dn.abs();
174    let e_normal = e * c * c;
175    if e_normal <= e_s {
176        return None;
177    }
178    if e_s == 0.0 {
179        return Some((e, dir));
180    }
181    let e_out = e - e_s;
182    // Momentum components scale with sqrt(energy): tangential unchanged,
183    // normal from the reduced normal energy.
184    let f_par = (e / e_out).sqrt();
185    let c_out = ((e_normal - e_s) / e_out).sqrt().copysign(dn);
186    let mut out = [0.0; 3];
187    for k in 0..3 {
188        out[k] = (dir[k] - dn * normal[k]) * f_par + c_out * normal[k];
189    }
190    Some((e_out, normalize(out)))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::vec3::dot;
197
198    #[test]
199    fn momentum_and_energy_are_conserved() {
200        // Projectile along +x; check p1 = p1' + p2 and E = E1' + T.
201        for &(m1, m2) in &[(1.0, 28.0), (28.0, 28.0), (75.0, 12.0)] {
202            for &theta in &[0.01f64, 0.5, 1.5, 2.5, 3.1] {
203                let e = 1000.0;
204                let mu = m1 / m2;
205                let t = gamma(m1, m2) * e * (0.5 * theta).sin().powi(2);
206                let (psi, phi) = lab_angles(theta, mu);
207                let p1 = (2.0 * m1 * e).sqrt();
208                let p1p = (2.0 * m1 * (e - t)).sqrt();
209                let p2 = (2.0 * m2 * t).sqrt();
210                // Projectile at +psi, recoil at -phi in the scattering plane.
211                let px = p1p * psi.cos() + p2 * phi.cos();
212                let py = p1p * psi.sin() - p2 * phi.sin();
213                assert!((px - p1).abs() < 1e-9 * p1, "{m1} {m2} {theta}");
214                assert!(py.abs() < 1e-9 * p1, "{m1} {m2} {theta}");
215            }
216        }
217    }
218
219    #[test]
220    fn rotation_gives_requested_polar_angle() {
221        let dirs = [
222            [1.0, 0.0, 0.0],
223            [0.0, 1.0, 0.0],
224            normalize([0.3, -0.4, 0.86]),
225            normalize([-0.7, 0.7, 0.1]),
226        ];
227        for u in dirs {
228            for &pol in &[0.0, 0.2, 1.0, 2.9] {
229                for &az in &[0.0, 1.0, 4.0] {
230                    let v = rotate(u, pol, az);
231                    assert!((dot(v, v) - 1.0).abs() < 1e-14);
232                    assert!((dot(u, v) - pol.cos()).abs() < 1e-12);
233                }
234            }
235        }
236        // Opposite azimuths lie on opposite sides of u.
237        let u = normalize([0.6, 0.0, 0.8]);
238        let a = rotate(u, 0.3, 0.7);
239        let b = rotate(u, 0.3, 0.7 + PI);
240        let s = normalize([a[0] + b[0], a[1] + b[1], a[2] + b[2]]);
241        assert!((dot(s, u) - 1.0).abs() < 1e-12);
242    }
243
244    #[test]
245    fn sin_cos_forms_agree_with_the_angle_forms() {
246        for &mu in &[0.05, 0.4, 1.0, 2.5, 6.0] {
247            for &theta in &[1e-6f64, 0.01, 0.5, 1.5, 2.5, 3.1, PI - 1e-5] {
248                let (psi, phi) = lab_angles(theta, mu);
249                let (s, c) = (0.5 * theta).sin_cos();
250                let (sp, cp) = lab_projectile_sc(s, c, mu);
251                assert!((sp - psi.sin()).abs() < 1e-12, "{mu} {theta}");
252                assert!((cp - psi.cos()).abs() < 1e-12, "{mu} {theta}");
253                // Recoil: (sin phi, cos phi) = (c, s).
254                assert!((c - phi.sin()).abs() < 1e-14);
255                assert!((s - phi.cos()).abs() < 1e-14);
256            }
257        }
258        let u = normalize([0.3, -0.4, 0.86]);
259        let a = rotate(u, 1.1, 2.2);
260        let b = rotate_sc(u, 1.1f64.sin_cos(), 2.2f64.sin_cos());
261        assert_eq!(a, b);
262    }
263
264    #[test]
265    fn sin_cos_turns_matches_the_library() {
266        let mut worst = 0.0f64;
267        let n = 100_000;
268        for i in 0..=n {
269            // Grid including every octant boundary, plus the largest u < 1.
270            let u = if i == n {
271                1.0 - f64::EPSILON / 2.0
272            } else {
273                i as f64 / n as f64
274            };
275            let (s, c) = sin_cos_turns(u);
276            let (rs, rc) = (2.0 * PI * u).sin_cos();
277            worst = worst.max((s - rs).abs()).max((c - rc).abs());
278            assert!((s * s + c * c - 1.0).abs() < 1e-15, "{u}");
279        }
280        assert!(worst < 1e-15, "{worst}");
281        assert_eq!(sin_cos_turns(0.0), (0.0, 1.0));
282        let (s, c) = sin_cos_turns(0.25);
283        assert!((s - 1.0).abs() < 1e-16 && c.abs() < 1e-16);
284    }
285
286    #[test]
287    fn long_rotation_chain_stays_unit() {
288        let mut u = normalize([0.3, -0.4, 0.86]);
289        for i in 0..200_000 {
290            let x = i as f64;
291            u = rotate(u, 0.1 + (x * 0.37).sin().abs(), x * 1.3);
292        }
293        let n = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
294        assert!((n - 1.0).abs() < 1e-14, "{n}");
295    }
296
297    #[test]
298    fn surface_refraction() {
299        // Normal incidence on the front face, E = 10, Es = 4.
300        let (e, d) = refract_out(10.0, [-1.0, 0.0, 0.0], 4.0).unwrap();
301        assert_eq!(e, 6.0);
302        assert_eq!(d, [-1.0, 0.0, 0.0]);
303        // Below the barrier in normal energy: trapped.
304        let c = 0.5f64; // E cos^2 = 2.5 < 4
305        let dir = [-c, (1.0 - c * c).sqrt(), 0.0];
306        assert!(refract_out(10.0, dir, 4.0).is_none());
307        // Oblique: parallel momentum conserved, normal energy reduced.
308        let c = 0.8f64;
309        let dir = [c, 0.0, (1.0 - c * c).sqrt()];
310        let (e, d) = refract_out(10.0, dir, 2.0).unwrap();
311        assert!((e - 8.0).abs() < 1e-14);
312        assert!((e * d[0] * d[0] - (10.0 * c * c - 2.0)).abs() < 1e-12);
313        assert!((e * d[2] * d[2] - 10.0 * (1.0 - c * c)).abs() < 1e-12);
314        assert!(d[0] > 0.0);
315    }
316}