Skip to main content

phyz_gravity/
pn.rs

1//! Post-Newtonian gravity solver (Layer 4).
2//!
3//! Implements relativistic corrections to Newtonian gravity:
4//! - 1PN: O(v²/c²) corrections (perihelion precession)
5//! - 2.5PN: O(v⁵/c⁵) gravitational radiation damping
6//!
7//! # Example: Mercury Perihelion Precession
8//!
9//! ```
10//! use phyz_gravity::{GravityParticle, PostNewtonianSolver, GravitySolver};
11//! use phyz_math::Vec3;
12//!
13//! let m_sun = 1.989e30;
14//! let m_mercury = 3.285e23;
15//!
16//! let mut particles = vec![
17//!     GravityParticle::new(Vec3::zeros(), Vec3::zeros(), m_sun),
18//!     GravityParticle::new(
19//!         Vec3::new(57.9e9, 0.0, 0.0),
20//!         Vec3::new(0.0, 47.4e3, 0.0),
21//!         m_mercury,
22//!     ),
23//! ];
24//!
25//! let mut solver = PostNewtonianSolver::new(1.0); // 1PN
26//! solver.compute_forces(&mut particles);
27//! ```
28//!
29//! # References
30//!
31//! - Blanchet (2014): "Gravitational Radiation from Post-Newtonian Sources"
32//! - Will (2014): "The Confrontation between General Relativity and Experiment"
33
34use crate::{C, G, GravityParticle, GravitySolver};
35use phyz_math::Vec3;
36
37/// Post-Newtonian gravity solver.
38#[derive(Debug, Clone)]
39pub struct PostNewtonianSolver {
40    /// Maximum PN order (1.0 = 1PN, 2.5 = 2.5PN).
41    pub max_order: f64,
42    /// Softening length (m).
43    pub softening: f64,
44    /// Include gravitational radiation (2.5PN).
45    pub include_radiation: bool,
46}
47
48impl PostNewtonianSolver {
49    /// Create a new post-Newtonian solver.
50    pub fn new(max_order: f64) -> Self {
51        Self {
52            max_order,
53            softening: 1e-3,
54            include_radiation: max_order >= 2.5,
55        }
56    }
57
58    /// Compute Newtonian acceleration.
59    fn newtonian_acceleration(
60        &self,
61        xi: Vec3,
62        _vi: Vec3,
63        _mi: f64,
64        xj: Vec3,
65        _vj: Vec3,
66        mj: f64,
67    ) -> Vec3 {
68        let r = xj - xi;
69        let r2 = r.norm_squared() + self.softening * self.softening;
70        let r_mag = r2.sqrt();
71
72        // a_N = G * mj / r² * r̂
73        G * mj / r2 * (r / r_mag)
74    }
75
76    /// Compute 1PN acceleration correction.
77    ///
78    /// From Blanchet (2014), eq. 6.22:
79    ///
80    /// ```text
81    /// a_1PN = G*mj/r² * [
82    ///   (4*G*(mi+mj)/r - vi²) * n
83    ///   + 4*(vi·vj) * n
84    ///   - (vi·n) * vj
85    /// ] / c²
86    /// ```
87    ///
88    /// where n = r̂ is the unit vector from i to j.
89    fn pn1_acceleration(&self, xi: Vec3, vi: Vec3, mi: f64, xj: Vec3, vj: Vec3, mj: f64) -> Vec3 {
90        let r = xj - xi;
91        let r2 = r.norm_squared() + self.softening * self.softening;
92        let r_mag = r2.sqrt();
93        let n = r / r_mag;
94
95        let v2_i = vi.norm_squared();
96        let v2_j = vj.norm_squared();
97        let vi_dot_vj = vi.dot(&vj);
98        let vi_dot_n = vi.dot(&n);
99        let vj_dot_n = vj.dot(&n);
100
101        let c2 = C * C;
102
103        // Schwarzschild-like term: 4*G*(mi+mj)/r
104        let schwarzschild = 4.0 * G * (mi + mj) / r_mag;
105
106        // 1PN acceleration components
107        let coeff = G * mj / r2 / c2;
108
109        let term1 =
110            (schwarzschild - v2_i - 2.0 * v2_j + 4.0 * vi_dot_vj + 1.5 * vj_dot_n.powi(2)) * n;
111        let term2 = (4.0 * vi_dot_n - 3.0 * vj_dot_n) * vj;
112
113        coeff * (term1 + term2)
114    }
115
116    /// Compute 2.5PN gravitational radiation damping.
117    ///
118    /// From Blanchet (2014), eq. 9.20 (leading order GW emission):
119    ///
120    /// ```text
121    /// a_2.5PN = -8/5 * G²*mi*mj*(mi+mj) / (c⁵*r³) * [
122    ///   (vi - vj) - 3*(vi-vj)·n * n
123    /// ]
124    /// ```
125    ///
126    /// This is the radiation reaction force causing orbital decay.
127    fn pn2_5_acceleration(&self, xi: Vec3, vi: Vec3, mi: f64, xj: Vec3, vj: Vec3, mj: f64) -> Vec3 {
128        if !self.include_radiation {
129            return Vec3::zeros();
130        }
131
132        let r = xj - xi;
133        let r2 = r.norm_squared() + self.softening * self.softening;
134        let r_mag = r2.sqrt();
135        let r3 = r_mag * r2;
136        let n = r / r_mag;
137
138        let v_rel = vi - vj;
139        let v_rel_dot_n = v_rel.dot(&n);
140
141        let c5 = C.powi(5);
142        let coeff = -8.0 / 5.0 * G * G * mi * mj * (mi + mj) / (c5 * r3);
143
144        coeff * (v_rel - 3.0 * v_rel_dot_n * n)
145    }
146
147    /// Compute total PN acceleration on particle i due to j.
148    fn pn_acceleration(&self, xi: Vec3, vi: Vec3, mi: f64, xj: Vec3, vj: Vec3, mj: f64) -> Vec3 {
149        let mut a = self.newtonian_acceleration(xi, vi, mi, xj, vj, mj);
150
151        if self.max_order >= 1.0 {
152            a += self.pn1_acceleration(xi, vi, mi, xj, vj, mj);
153        }
154
155        if self.max_order >= 2.5 {
156            a += self.pn2_5_acceleration(xi, vi, mi, xj, vj, mj);
157        }
158
159        a
160    }
161}
162
163impl GravitySolver for PostNewtonianSolver {
164    fn compute_forces(&mut self, particles: &mut [GravityParticle]) {
165        let n = particles.len();
166
167        // Reset forces
168        for p in particles.iter_mut() {
169            p.reset_force();
170        }
171
172        // Compute pairwise forces
173        // Note: We need positions AND velocities, so we can't use Newton's 3rd law
174        // directly (PN forces are velocity-dependent and not symmetric).
175        for i in 0..n {
176            for j in 0..n {
177                if i == j {
178                    continue;
179                }
180
181                let xi = particles[i].x;
182                let vi = particles[i].v;
183                let mi = particles[i].m;
184
185                let xj = particles[j].x;
186                let vj = particles[j].v;
187                let mj = particles[j].m;
188
189                let a = self.pn_acceleration(xi, vi, mi, xj, vj, mj);
190                particles[i].add_force(a * mi);
191            }
192        }
193    }
194
195    fn potential_energy(&self, particles: &[GravityParticle]) -> f64 {
196        let n = particles.len();
197        let mut u = 0.0;
198
199        // Newtonian potential energy
200        for i in 0..n {
201            for j in i + 1..n {
202                let r = (particles[j].x - particles[i].x).norm();
203                let r_soft = (r * r + self.softening * self.softening).sqrt();
204                u -= G * particles[i].m * particles[j].m / r_soft;
205            }
206        }
207
208        // PN corrections to energy are complex; we'll just return Newtonian for now
209        u
210    }
211}
212
213/// Compute orbital elements from state.
214pub fn orbital_elements(x: Vec3, v: Vec3, m_central: f64) -> (f64, f64, f64, f64, f64, f64) {
215    let mu = G * m_central;
216
217    let r = x.norm();
218    let v2 = v.norm_squared();
219
220    // Specific orbital energy
221    let energy = v2 / 2.0 - mu / r;
222
223    // Angular momentum vector
224    let h = x.cross(&v);
225    let h_mag = h.norm();
226
227    // Semi-major axis
228    let a = -mu / (2.0 * energy);
229
230    // Eccentricity vector: e = (v × h) / μ - r̂
231    let e_vec = v.cross(&h) / mu - x / r;
232    let e = e_vec.norm();
233
234    // Inclination
235    let i = (h.z / h_mag).acos();
236
237    // Longitude of ascending node
238    let n = Vec3::new(-h.y, h.x, 0.0);
239    let n_mag = n.norm();
240    let omega = if n_mag > 1e-10 {
241        let omega_raw = (n.x / n_mag).acos();
242        if n.y < 0.0 {
243            2.0 * std::f64::consts::PI - omega_raw
244        } else {
245            omega_raw
246        }
247    } else {
248        0.0
249    };
250
251    // Argument of periapsis
252    let omega_bar = if n_mag > 1e-10 && e > 1e-10 {
253        let omega_bar_raw = (n.dot(&e_vec) / (n_mag * e)).acos();
254        if e_vec.z < 0.0 {
255            2.0 * std::f64::consts::PI - omega_bar_raw
256        } else {
257            omega_bar_raw
258        }
259    } else {
260        0.0
261    };
262
263    // True anomaly
264    let nu = if e > 1e-10 {
265        let nu_raw = (e_vec.dot(&x) / (e * r)).acos();
266        if x.dot(&v) < 0.0 {
267            2.0 * std::f64::consts::PI - nu_raw
268        } else {
269            nu_raw
270        }
271    } else {
272        0.0
273    };
274
275    (a, e, i, omega, omega_bar, nu)
276}
277
278/// Compute perihelion precession rate (arcsec/century).
279///
280/// For 1PN approximation (Schwarzschild spacetime):
281/// Δω = 6π G M / (c² a (1 - e²))
282///
283/// For Mercury: 43.1 arcsec/century.
284pub fn perihelion_precession_rate(a: f64, e: f64, m_central: f64, period: f64) -> f64 {
285    let delta_omega_per_orbit =
286        6.0 * std::f64::consts::PI * G * m_central / (C * C * a * (1.0 - e * e));
287
288    // Convert to arcsec/century
289    let orbits_per_century = 3.15576e9 / period; // seconds in a century / period
290    let delta_omega_century = delta_omega_per_orbit * orbits_per_century;
291
292    // Radians to arcsec: 1 rad = 206265 arcsec
293    delta_omega_century * 206265.0
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_newtonian_vs_pn() {
302        let mut solver_newton = PostNewtonianSolver::new(0.0); // Newtonian only
303        let mut solver_pn = PostNewtonianSolver::new(1.0); // 1PN
304
305        let mut particles_newton = vec![
306            GravityParticle::new(Vec3::zeros(), Vec3::zeros(), 1e10),
307            GravityParticle::new(Vec3::new(1.0, 0.0, 0.0), Vec3::zeros(), 1e10),
308        ];
309
310        let mut particles_pn = particles_newton.clone();
311
312        solver_newton.compute_forces(&mut particles_newton);
313        solver_pn.compute_forces(&mut particles_pn);
314
315        // PN force should be slightly different (but close for low velocities)
316        let diff = (particles_newton[0].f - particles_pn[0].f).norm();
317        // For v=0, 1PN corrections vanish, so they should be very close
318        assert!(diff < 1e-6);
319    }
320
321    #[test]
322    fn test_mercury_precession() {
323        // Mercury orbital parameters
324        let a = 57.9e9; // semi-major axis (m)
325        let e = 0.2056; // eccentricity
326        let m_sun = 1.989e30; // kg
327        let period = 87.969 * 24.0 * 3600.0; // seconds
328
329        let precession = perihelion_precession_rate(a, e, m_sun, period);
330
331        // Expected: 43.1 arcsec/century
332        // Allow 10% tolerance due to simplified formula
333        assert!(precession > 38.0 && precession < 48.0);
334    }
335
336    #[test]
337    fn test_orbital_elements() {
338        // Circular orbit
339        let m_sun = 1.989e30;
340        let r = 1.5e11; // 1 AU
341        let v_circular = (G * m_sun / r).sqrt();
342
343        let x = Vec3::new(r, 0.0, 0.0);
344        let v = Vec3::new(0.0, v_circular, 0.0);
345
346        let (a, e, _i, _omega, _omega_bar, _nu) = orbital_elements(x, v, m_sun);
347
348        // Should be circular (e ≈ 0) with a ≈ r
349        assert!((a - r).abs() / r < 1e-6);
350        assert!(e < 1e-6);
351    }
352
353    #[test]
354    fn test_2_5pn_radiation() {
355        let mut solver = PostNewtonianSolver::new(2.5);
356
357        // Binary system with velocities
358        let mut particles = vec![
359            GravityParticle::new(Vec3::new(-0.5e9, 0.0, 0.0), Vec3::new(0.0, 1e4, 0.0), 1e30),
360            GravityParticle::new(Vec3::new(0.5e9, 0.0, 0.0), Vec3::new(0.0, -1e4, 0.0), 1e30),
361        ];
362
363        solver.compute_forces(&mut particles);
364
365        // Forces should be non-zero and include radiation damping
366        assert!(particles[0].f.norm() > 0.0);
367    }
368}