Skip to main content

simular/orbit/
physics.rs

1//! Orbital physics engine with symplectic integrators.
2//!
3//! Implements numerical integration for orbital mechanics:
4//! - Yoshida 4th order (symplectic, time-reversible)
5//! - N-body gravitational force field
6//! - Adaptive time-stepping for close encounters
7//!
8//! # References
9//!
10//! [8] Hairer, Lubich, Wanner, "Geometric Numerical Integration," 2006.
11//! [13] H. Yoshida, "Construction of higher order symplectic integrators," 1990.
12//! [30] Hockney & Eastwood, "Computer Simulation Using Particles," 1988.
13
14use crate::error::{SimError, SimResult};
15use crate::orbit::units::{Acceleration3D, OrbitMass, OrbitTime, Position3D, Velocity3D, G};
16use uom::si::length::meter;
17
18/// Body state in the N-body system.
19#[derive(Debug, Clone)]
20pub struct OrbitBody {
21    pub mass: OrbitMass,
22    pub position: Position3D,
23    pub velocity: Velocity3D,
24}
25
26impl OrbitBody {
27    /// Create a new orbital body.
28    #[must_use]
29    pub fn new(mass: OrbitMass, position: Position3D, velocity: Velocity3D) -> Self {
30        Self {
31            mass,
32            position,
33            velocity,
34        }
35    }
36
37    /// Calculate kinetic energy (J).
38    #[must_use]
39    pub fn kinetic_energy(&self) -> f64 {
40        let v_sq = self.velocity.magnitude_squared();
41        0.5 * self.mass.as_kg() * v_sq
42    }
43}
44
45/// N-body gravitational system state.
46#[derive(Debug, Clone)]
47pub struct NBodyState {
48    pub bodies: Vec<OrbitBody>,
49    pub time: OrbitTime,
50    softening: f64,
51}
52
53impl NBodyState {
54    /// Create a new N-body state with optional softening parameter.
55    #[must_use]
56    pub fn new(bodies: Vec<OrbitBody>, softening: f64) -> Self {
57        Self {
58            bodies,
59            time: OrbitTime::from_seconds(0.0),
60            softening,
61        }
62    }
63
64    /// Number of bodies in the system.
65    #[must_use]
66    pub fn num_bodies(&self) -> usize {
67        self.bodies.len()
68    }
69
70    /// Calculate total kinetic energy.
71    #[must_use]
72    pub fn kinetic_energy(&self) -> f64 {
73        self.bodies.iter().map(OrbitBody::kinetic_energy).sum()
74    }
75
76    /// Calculate total potential energy.
77    #[must_use]
78    pub fn potential_energy(&self) -> f64 {
79        let mut pe = 0.0;
80        let n = self.bodies.len();
81        let eps_sq = self.softening * self.softening;
82
83        for i in 0..n {
84            for j in (i + 1)..n {
85                let r = self.bodies[i].position - self.bodies[j].position;
86                let r_mag_sq = r.magnitude_squared() + eps_sq;
87                let r_mag = r_mag_sq.sqrt();
88
89                if r_mag > f64::EPSILON {
90                    pe -= G * self.bodies[i].mass.as_kg() * self.bodies[j].mass.as_kg() / r_mag;
91                }
92            }
93        }
94
95        pe
96    }
97
98    /// Calculate total mechanical energy.
99    #[must_use]
100    pub fn total_energy(&self) -> f64 {
101        self.kinetic_energy() + self.potential_energy()
102    }
103
104    /// Calculate total angular momentum vector (Lx, Ly, Lz).
105    #[must_use]
106    pub fn angular_momentum(&self) -> (f64, f64, f64) {
107        let mut lx = 0.0;
108        let mut ly = 0.0;
109        let mut lz = 0.0;
110
111        for body in &self.bodies {
112            let m = body.mass.as_kg();
113            let (rx, ry, rz) = body.position.as_meters();
114            let (vx, vy, vz) = body.velocity.as_mps();
115
116            // L = m * (r × v)
117            lx += m * (ry * vz - rz * vy);
118            ly += m * (rz * vx - rx * vz);
119            lz += m * (rx * vy - ry * vx);
120        }
121
122        (lx, ly, lz)
123    }
124
125    /// Calculate angular momentum magnitude.
126    #[must_use]
127    pub fn angular_momentum_magnitude(&self) -> f64 {
128        let (lx, ly, lz) = self.angular_momentum();
129        (lx * lx + ly * ly + lz * lz).sqrt()
130    }
131
132    /// Get minimum pairwise separation between bodies.
133    #[must_use]
134    pub fn min_separation(&self) -> f64 {
135        let mut min_sep = f64::MAX;
136        let n = self.bodies.len();
137
138        for i in 0..n {
139            for j in (i + 1)..n {
140                let r = self.bodies[i].position - self.bodies[j].position;
141                let sep = r.magnitude().get::<meter>();
142                if sep < min_sep {
143                    min_sep = sep;
144                }
145            }
146        }
147
148        min_sep
149    }
150
151    /// Check if all values are finite.
152    #[must_use]
153    pub fn is_finite(&self) -> bool {
154        self.bodies
155            .iter()
156            .all(|b| b.position.is_finite() && b.velocity.is_finite())
157    }
158}
159
160/// Compute gravitational accelerations for all bodies.
161fn compute_accelerations(state: &NBodyState) -> Vec<Acceleration3D> {
162    let n = state.bodies.len();
163    let eps_sq = state.softening * state.softening;
164    let mut accelerations = vec![Acceleration3D::zero(); n];
165
166    for i in 0..n {
167        for j in 0..n {
168            if i == j {
169                continue;
170            }
171
172            let r_ij = state.bodies[j].position - state.bodies[i].position;
173            let (rx, ry, rz) = r_ij.as_meters();
174            let r_mag_sq = rx * rx + ry * ry + rz * rz + eps_sq;
175            let r_mag = r_mag_sq.sqrt();
176
177            if r_mag > f64::EPSILON {
178                // a_i = G * m_j / r² * r̂
179                let factor = G * state.bodies[j].mass.as_kg() / (r_mag_sq * r_mag);
180                let (ax, ay, az) = accelerations[i].as_mps2();
181                accelerations[i] =
182                    Acceleration3D::from_mps2(ax + factor * rx, ay + factor * ry, az + factor * rz);
183            }
184        }
185    }
186
187    accelerations
188}
189
190/// Yoshida 4th order symplectic integrator.
191///
192/// Higher-order symplectic method with excellent energy conservation.
193/// Composed of three Verlet steps with specific coefficients.
194///
195/// # Properties
196///
197/// - Order: 4
198/// - Symplectic: Yes (preserves phase space volume)
199/// - Time-reversible: Yes
200/// - Energy drift: O(dt⁴) bounded oscillation
201///
202/// # References
203///
204/// [13] Yoshida, "Construction of higher order symplectic integrators," 1990.
205#[derive(Debug, Clone, Default)]
206pub struct YoshidaIntegrator {
207    /// Yoshida coefficient w1 = 1/(2 - 2^(1/3))
208    w1: f64,
209    /// Yoshida coefficient w0 = -2^(1/3)/(2 - 2^(1/3))
210    w0: f64,
211}
212
213impl YoshidaIntegrator {
214    /// Create a new Yoshida 4th order integrator.
215    #[must_use]
216    pub fn new() -> Self {
217        let cbrt2 = 2.0_f64.cbrt();
218        let w1 = 1.0 / (2.0 - cbrt2);
219        let w0 = -cbrt2 / (2.0 - cbrt2);
220
221        Self { w1, w0 }
222    }
223
224    /// Get the position coefficients c[0..4].
225    fn c_coefficients(&self) -> [f64; 4] {
226        [
227            self.w1 / 2.0,
228            (self.w0 + self.w1) / 2.0,
229            (self.w0 + self.w1) / 2.0,
230            self.w1 / 2.0,
231        ]
232    }
233
234    /// Get the velocity coefficients d[0..3].
235    fn d_coefficients(&self) -> [f64; 3] {
236        [self.w1, self.w0, self.w1]
237    }
238
239    /// Step the state forward by dt.
240    ///
241    /// # Errors
242    ///
243    /// Returns error if state becomes invalid.
244    pub fn step(&self, state: &mut NBodyState, dt: OrbitTime) -> SimResult<()> {
245        let dt_secs = dt.as_seconds();
246        let c = self.c_coefficients();
247        let d = self.d_coefficients();
248
249        // Yoshida 4th order: composed of three Verlet-like steps
250        // Step 1: c[0] position, d[0] velocity
251        self.drift(state, c[0] * dt_secs);
252        self.kick(state, d[0] * dt_secs)?;
253
254        // Step 2: c[1] position, d[1] velocity
255        self.drift(state, c[1] * dt_secs);
256        self.kick(state, d[1] * dt_secs)?;
257
258        // Step 3: c[2] position, d[2] velocity
259        self.drift(state, c[2] * dt_secs);
260        self.kick(state, d[2] * dt_secs)?;
261
262        // Step 4: c[3] position only
263        self.drift(state, c[3] * dt_secs);
264
265        // Update simulation time
266        state.time = OrbitTime::from_seconds(state.time.as_seconds() + dt_secs);
267
268        Ok(())
269    }
270
271    /// Drift: update positions.
272    #[allow(clippy::unused_self)] // Method for future extensibility
273    fn drift(&self, state: &mut NBodyState, dt: f64) {
274        for body in &mut state.bodies {
275            let (vx, vy, vz) = body.velocity.as_mps();
276            let (px, py, pz) = body.position.as_meters();
277            body.position = Position3D::from_meters(px + vx * dt, py + vy * dt, pz + vz * dt);
278        }
279    }
280
281    /// Kick: update velocities.
282    #[allow(clippy::unused_self)] // Method for future extensibility
283    fn kick(&self, state: &mut NBodyState, dt: f64) -> SimResult<()> {
284        let accelerations = compute_accelerations(state);
285
286        for (i, body) in state.bodies.iter_mut().enumerate() {
287            let (ax, ay, az) = accelerations[i].as_mps2();
288            let (vx, vy, vz) = body.velocity.as_mps();
289            body.velocity = Velocity3D::from_mps(vx + ax * dt, vy + ay * dt, vz + az * dt);
290
291            if !body.velocity.is_finite() {
292                return Err(SimError::NonFiniteValue {
293                    location: format!("body {i} velocity"),
294                });
295            }
296        }
297
298        Ok(())
299    }
300
301    /// Get integrator order.
302    #[must_use]
303    pub const fn order(&self) -> u32 {
304        4
305    }
306
307    /// Check if integrator is symplectic.
308    #[must_use]
309    pub const fn is_symplectic(&self) -> bool {
310        true
311    }
312}
313
314/// Adaptive time-stepping for close encounters.
315#[derive(Debug, Clone)]
316pub struct AdaptiveIntegrator {
317    pub base_dt: f64,
318    pub min_dt: f64,
319    pub max_dt: f64,
320    pub encounter_threshold: f64,
321    yoshida: YoshidaIntegrator,
322}
323
324impl AdaptiveIntegrator {
325    /// Create a new adaptive integrator.
326    #[must_use]
327    pub fn new(base_dt: f64, min_dt: f64, max_dt: f64, encounter_threshold: f64) -> Self {
328        Self {
329            base_dt,
330            min_dt,
331            max_dt,
332            encounter_threshold,
333            yoshida: YoshidaIntegrator::new(),
334        }
335    }
336
337    /// Compute adaptive time step based on state.
338    #[must_use]
339    pub fn compute_dt(&self, state: &NBodyState) -> f64 {
340        let min_sep = state.min_separation();
341
342        // Reduce time step for close encounters
343        let dt = if min_sep < self.encounter_threshold {
344            // Scale dt by separation / threshold
345            let ratio = min_sep / self.encounter_threshold;
346            self.base_dt * ratio.max(0.01)
347        } else {
348            self.base_dt
349        };
350
351        dt.clamp(self.min_dt, self.max_dt)
352    }
353
354    /// Step with adaptive time stepping.
355    ///
356    /// # Errors
357    ///
358    /// Returns error if integration fails.
359    pub fn step(&self, state: &mut NBodyState) -> SimResult<f64> {
360        let dt = self.compute_dt(state);
361        self.yoshida.step(state, OrbitTime::from_seconds(dt))?;
362        Ok(dt)
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::orbit::units::{AU, EARTH_MASS, SOLAR_MASS};
370    use uom::si::acceleration::meter_per_second_squared;
371
372    const EPSILON: f64 = 1e-10;
373
374    fn create_sun() -> OrbitBody {
375        OrbitBody::new(
376            OrbitMass::from_kg(SOLAR_MASS),
377            Position3D::zero(),
378            Velocity3D::zero(),
379        )
380    }
381
382    fn create_earth() -> OrbitBody {
383        // Earth at 1 AU with circular orbital velocity
384        let v_circular = (G * SOLAR_MASS / AU).sqrt();
385        OrbitBody::new(
386            OrbitMass::from_kg(EARTH_MASS),
387            Position3D::from_au(1.0, 0.0, 0.0),
388            Velocity3D::from_mps(0.0, v_circular, 0.0),
389        )
390    }
391
392    #[test]
393    fn test_orbit_body_kinetic_energy() {
394        let body = OrbitBody::new(
395            OrbitMass::from_kg(1.0),
396            Position3D::zero(),
397            Velocity3D::from_mps(10.0, 0.0, 0.0),
398        );
399        let ke = body.kinetic_energy();
400        assert!((ke - 50.0).abs() < EPSILON); // 0.5 * 1 * 100
401    }
402
403    #[test]
404    fn test_nbody_state_creation() {
405        let bodies = vec![create_sun(), create_earth()];
406        let state = NBodyState::new(bodies, 0.0);
407        assert_eq!(state.num_bodies(), 2);
408    }
409
410    #[test]
411    fn test_nbody_state_kinetic_energy() {
412        let bodies = vec![create_sun(), create_earth()];
413        let state = NBodyState::new(bodies, 0.0);
414        let ke = state.kinetic_energy();
415        assert!(ke > 0.0);
416    }
417
418    #[test]
419    fn test_nbody_state_potential_energy() {
420        let bodies = vec![create_sun(), create_earth()];
421        let state = NBodyState::new(bodies, 0.0);
422        let pe = state.potential_energy();
423        assert!(pe < 0.0); // Gravitational PE is negative
424    }
425
426    #[test]
427    fn test_nbody_state_total_energy() {
428        let bodies = vec![create_sun(), create_earth()];
429        let state = NBodyState::new(bodies, 0.0);
430        let e = state.total_energy();
431        assert!(e < 0.0); // Bound orbit has negative total energy
432    }
433
434    #[test]
435    fn test_nbody_state_angular_momentum() {
436        let bodies = vec![create_sun(), create_earth()];
437        let state = NBodyState::new(bodies, 0.0);
438        let l = state.angular_momentum_magnitude();
439        assert!(l > 0.0);
440    }
441
442    #[test]
443    fn test_nbody_state_min_separation() {
444        let bodies = vec![create_sun(), create_earth()];
445        let state = NBodyState::new(bodies, 0.0);
446        let min_sep = state.min_separation();
447        let expected = AU;
448        assert!((min_sep - expected).abs() / expected < 0.01);
449    }
450
451    #[test]
452    fn test_yoshida_coefficients() {
453        let yoshida = YoshidaIntegrator::new();
454        let c = yoshida.c_coefficients();
455        let d = yoshida.d_coefficients();
456
457        // Sum of c coefficients should be 1
458        let c_sum: f64 = c.iter().sum();
459        assert!((c_sum - 1.0).abs() < EPSILON);
460
461        // Sum of d coefficients should be 1
462        let d_sum: f64 = d.iter().sum();
463        assert!((d_sum - 1.0).abs() < EPSILON);
464    }
465
466    #[test]
467    fn test_yoshida_order() {
468        let yoshida = YoshidaIntegrator::new();
469        assert_eq!(yoshida.order(), 4);
470        assert!(yoshida.is_symplectic());
471    }
472
473    #[test]
474    fn test_yoshida_energy_conservation_short_term() {
475        let bodies = vec![create_sun(), create_earth()];
476        let mut state = NBodyState::new(bodies, 1e6); // Small softening
477
478        let yoshida = YoshidaIntegrator::new();
479        let initial_energy = state.total_energy();
480
481        // Simulate for 100 steps
482        let dt = OrbitTime::from_seconds(86400.0); // 1 day
483        for _ in 0..100 {
484            yoshida.step(&mut state, dt).expect("step failed");
485        }
486
487        let final_energy = state.total_energy();
488        let relative_error = (final_energy - initial_energy).abs() / initial_energy.abs();
489
490        // Energy should be conserved to better than 1e-6 over 100 days
491        assert!(
492            relative_error < 1e-6,
493            "Energy drift too large: {relative_error}"
494        );
495    }
496
497    #[test]
498    fn test_yoshida_angular_momentum_conservation() {
499        let bodies = vec![create_sun(), create_earth()];
500        let mut state = NBodyState::new(bodies, 1e6);
501
502        let yoshida = YoshidaIntegrator::new();
503        let initial_l = state.angular_momentum_magnitude();
504
505        let dt = OrbitTime::from_seconds(86400.0);
506        for _ in 0..100 {
507            yoshida.step(&mut state, dt).expect("step failed");
508        }
509
510        let final_l = state.angular_momentum_magnitude();
511        let relative_error = (final_l - initial_l).abs() / initial_l;
512
513        // Angular momentum should be conserved to machine precision
514        assert!(
515            relative_error < 1e-12,
516            "Angular momentum drift: {relative_error}"
517        );
518    }
519
520    #[test]
521    fn test_yoshida_orbit_period() {
522        let bodies = vec![create_sun(), create_earth()];
523        let mut state = NBodyState::new(bodies, 1e6);
524
525        let yoshida = YoshidaIntegrator::new();
526        let _initial_y = state.bodies[1].position.as_meters().1;
527
528        // Simulate for ~1 year (365.25 days)
529        let dt = OrbitTime::from_seconds(3600.0); // 1 hour steps for precision
530        let steps = (365.25 * 24.0) as usize;
531
532        for _ in 0..steps {
533            yoshida.step(&mut state, dt).expect("step failed");
534        }
535
536        // Earth should be back near starting position after 1 year
537        let (x, y, _) = state.bodies[1].position.as_meters();
538        let final_distance = (x * x + y * y).sqrt();
539        let expected_distance = AU;
540
541        let relative_error = (final_distance - expected_distance).abs() / expected_distance;
542        assert!(
543            relative_error < 0.01,
544            "Orbit radius error: {relative_error}"
545        );
546    }
547
548    #[test]
549    fn test_adaptive_integrator_creation() {
550        let adaptive = AdaptiveIntegrator::new(86400.0, 3600.0, 604800.0, 1e9);
551        assert!((adaptive.base_dt - 86400.0).abs() < EPSILON);
552    }
553
554    #[test]
555    fn test_adaptive_integrator_normal_dt() {
556        let bodies = vec![create_sun(), create_earth()];
557        let state = NBodyState::new(bodies, 0.0);
558
559        let adaptive = AdaptiveIntegrator::new(86400.0, 3600.0, 604800.0, 1e9);
560        let dt = adaptive.compute_dt(&state);
561
562        // Normal separation, should use base dt
563        assert!((dt - 86400.0).abs() < EPSILON);
564    }
565
566    #[test]
567    fn test_adaptive_integrator_close_encounter() {
568        // Two bodies very close together
569        let bodies = vec![
570            OrbitBody::new(
571                OrbitMass::from_kg(1e20),
572                Position3D::from_meters(0.0, 0.0, 0.0),
573                Velocity3D::zero(),
574            ),
575            OrbitBody::new(
576                OrbitMass::from_kg(1e20),
577                Position3D::from_meters(1e8, 0.0, 0.0), // 100 km apart
578                Velocity3D::zero(),
579            ),
580        ];
581        let state = NBodyState::new(bodies, 0.0);
582
583        let adaptive = AdaptiveIntegrator::new(86400.0, 3600.0, 604800.0, 1e9);
584        let dt = adaptive.compute_dt(&state);
585
586        // Should reduce dt for close encounter
587        assert!(dt < 86400.0);
588        assert!(dt >= 3600.0); // But not below minimum
589    }
590
591    #[test]
592    fn test_compute_accelerations_two_body() {
593        let bodies = vec![create_sun(), create_earth()];
594        let state = NBodyState::new(bodies, 0.0);
595
596        let accelerations = compute_accelerations(&state);
597
598        // Sun should have tiny acceleration toward Earth
599        let (ax_sun, _ay_sun, _) = accelerations[0].as_mps2();
600        assert!(ax_sun > 0.0); // Toward Earth (at +x)
601
602        // Earth should have large acceleration toward Sun
603        let (ax_earth, _, _) = accelerations[1].as_mps2();
604        assert!(ax_earth < 0.0); // Toward Sun (at origin)
605
606        // Earth's acceleration magnitude should be ~g at 1 AU
607        let a_mag = accelerations[1]
608            .magnitude()
609            .get::<meter_per_second_squared>();
610        let expected = G * SOLAR_MASS / (AU * AU);
611        let relative_error = (a_mag - expected).abs() / expected;
612        assert!(relative_error < 0.01);
613    }
614
615    #[test]
616    fn test_nbody_is_finite() {
617        let bodies = vec![create_sun(), create_earth()];
618        let state = NBodyState::new(bodies, 0.0);
619        assert!(state.is_finite());
620    }
621}
622
623#[cfg(test)]
624mod proptests {
625    use super::*;
626    use crate::orbit::units::SOLAR_MASS;
627    use proptest::prelude::*;
628
629    proptest! {
630        /// Gravitational force follows inverse square law.
631        #[test]
632        fn prop_gravity_inverse_square(
633            r1 in 1e10f64..1e12,
634            r2 in 1e10f64..1e12,
635        ) {
636            // F ∝ 1/r²
637            let mass = 1e24;
638            let sun_mass = SOLAR_MASS;
639
640            let f1 = G * sun_mass * mass / (r1 * r1);
641            let f2 = G * sun_mass * mass / (r2 * r2);
642
643            // Verify inverse square relationship
644            let ratio = (r1 / r2).powi(2);
645            let force_ratio = f2 / f1;
646            prop_assert!((force_ratio - ratio).abs() / ratio < 1e-10);
647        }
648
649        /// Total energy is conserved in n-body system (Hamiltonian).
650        #[test]
651        fn prop_energy_finite(
652            mass in 1e20f64..1e30,
653            distance in 1e9f64..1e12,
654        ) {
655            // Kinetic + Potential should be finite for bound orbits
656            let v_orbital = (G * SOLAR_MASS / distance).sqrt();
657            let ke = 0.5 * mass * v_orbital * v_orbital;
658            let pe = -G * SOLAR_MASS * mass / distance;
659            let total = ke + pe;
660
661            // For circular orbit, total energy should be negative (bound)
662            prop_assert!(total.is_finite());
663            prop_assert!(total < 0.0, "E={} should be negative for bound orbit", total);
664        }
665
666        /// Circular orbital velocity formula.
667        #[test]
668        fn prop_circular_velocity(
669            r in 1e10f64..1e13,
670        ) {
671            // v = sqrt(GM/r)
672            let v = (G * SOLAR_MASS / r).sqrt();
673
674            // Velocity should be positive and finite
675            prop_assert!(v > 0.0);
676            prop_assert!(v.is_finite());
677
678            // Velocity should decrease with distance
679            let v2 = (G * SOLAR_MASS / (r * 2.0)).sqrt();
680            prop_assert!(v2 < v);
681        }
682
683        /// Acceleration magnitude depends on mass and distance.
684        #[test]
685        fn prop_acceleration_magnitude(
686            central_mass in 1e25f64..1e31,
687            distance in 1e9f64..1e13,
688        ) {
689            // a = GM/r²
690            let a = G * central_mass / (distance * distance);
691
692            prop_assert!(a > 0.0);
693            prop_assert!(a.is_finite());
694
695            // Larger mass -> larger acceleration
696            let a2 = G * (central_mass * 2.0) / (distance * distance);
697            prop_assert!(a2 > a);
698        }
699
700        /// OrbitBody mass must be positive.
701        #[test]
702        fn prop_body_mass_positive(
703            mass in 1e10f64..1e35,
704        ) {
705            let body = OrbitBody::new(
706                OrbitMass::from_kg(mass),
707                Position3D::zero(),
708                Velocity3D::zero(),
709            );
710            prop_assert!(body.mass.as_kg() > 0.0);
711        }
712    }
713}