1use crate::error::{SimError, SimResult};
15use crate::orbit::units::{Acceleration3D, OrbitMass, OrbitTime, Position3D, Velocity3D, G};
16use uom::si::length::meter;
17
18#[derive(Debug, Clone)]
20pub struct OrbitBody {
21 pub mass: OrbitMass,
22 pub position: Position3D,
23 pub velocity: Velocity3D,
24}
25
26impl OrbitBody {
27 #[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 #[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#[derive(Debug, Clone)]
47pub struct NBodyState {
48 pub bodies: Vec<OrbitBody>,
49 pub time: OrbitTime,
50 softening: f64,
51}
52
53impl NBodyState {
54 #[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 #[must_use]
66 pub fn num_bodies(&self) -> usize {
67 self.bodies.len()
68 }
69
70 #[must_use]
72 pub fn kinetic_energy(&self) -> f64 {
73 self.bodies.iter().map(OrbitBody::kinetic_energy).sum()
74 }
75
76 #[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 #[must_use]
100 pub fn total_energy(&self) -> f64 {
101 self.kinetic_energy() + self.potential_energy()
102 }
103
104 #[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 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 #[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 #[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 #[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
160fn 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 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#[derive(Debug, Clone, Default)]
206pub struct YoshidaIntegrator {
207 w1: f64,
209 w0: f64,
211}
212
213impl YoshidaIntegrator {
214 #[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 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 fn d_coefficients(&self) -> [f64; 3] {
236 [self.w1, self.w0, self.w1]
237 }
238
239 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 self.drift(state, c[0] * dt_secs);
252 self.kick(state, d[0] * dt_secs)?;
253
254 self.drift(state, c[1] * dt_secs);
256 self.kick(state, d[1] * dt_secs)?;
257
258 self.drift(state, c[2] * dt_secs);
260 self.kick(state, d[2] * dt_secs)?;
261
262 self.drift(state, c[3] * dt_secs);
264
265 state.time = OrbitTime::from_seconds(state.time.as_seconds() + dt_secs);
267
268 Ok(())
269 }
270
271 #[allow(clippy::unused_self)] 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 #[allow(clippy::unused_self)] 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 #[must_use]
303 pub const fn order(&self) -> u32 {
304 4
305 }
306
307 #[must_use]
309 pub const fn is_symplectic(&self) -> bool {
310 true
311 }
312}
313
314#[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 #[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 #[must_use]
339 pub fn compute_dt(&self, state: &NBodyState) -> f64 {
340 let min_sep = state.min_separation();
341
342 let dt = if min_sep < self.encounter_threshold {
344 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 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 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); }
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); }
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); }
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 let c_sum: f64 = c.iter().sum();
459 assert!((c_sum - 1.0).abs() < EPSILON);
460
461 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); let yoshida = YoshidaIntegrator::new();
479 let initial_energy = state.total_energy();
480
481 let dt = OrbitTime::from_seconds(86400.0); 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 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 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 let dt = OrbitTime::from_seconds(3600.0); 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 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 assert!((dt - 86400.0).abs() < EPSILON);
564 }
565
566 #[test]
567 fn test_adaptive_integrator_close_encounter() {
568 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), 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 assert!(dt < 86400.0);
588 assert!(dt >= 3600.0); }
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 let (ax_sun, _ay_sun, _) = accelerations[0].as_mps2();
600 assert!(ax_sun > 0.0); let (ax_earth, _, _) = accelerations[1].as_mps2();
604 assert!(ax_earth < 0.0); 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 #[test]
632 fn prop_gravity_inverse_square(
633 r1 in 1e10f64..1e12,
634 r2 in 1e10f64..1e12,
635 ) {
636 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 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 #[test]
651 fn prop_energy_finite(
652 mass in 1e20f64..1e30,
653 distance in 1e9f64..1e12,
654 ) {
655 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 prop_assert!(total.is_finite());
663 prop_assert!(total < 0.0, "E={} should be negative for bound orbit", total);
664 }
665
666 #[test]
668 fn prop_circular_velocity(
669 r in 1e10f64..1e13,
670 ) {
671 let v = (G * SOLAR_MASS / r).sqrt();
673
674 prop_assert!(v > 0.0);
676 prop_assert!(v.is_finite());
677
678 let v2 = (G * SOLAR_MASS / (r * 2.0)).sqrt();
680 prop_assert!(v2 < v);
681 }
682
683 #[test]
685 fn prop_acceleration_magnitude(
686 central_mass in 1e25f64..1e31,
687 distance in 1e9f64..1e13,
688 ) {
689 let a = G * central_mass / (distance * distance);
691
692 prop_assert!(a > 0.0);
693 prop_assert!(a.is_finite());
694
695 let a2 = G * (central_mass * 2.0) / (distance * distance);
697 prop_assert!(a2 > a);
698 }
699
700 #[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}