Skip to main content

simular/orbit/
metamorphic.rs

1//! Metamorphic testing for orbital physics invariants.
2//!
3//! Per Chen et al. [33], metamorphic testing verifies **relations** rather
4//! than specific outputs. This is critical for physics simulations where
5//! exact outputs are unknown but invariant relationships must hold.
6//!
7//! # Metamorphic Relations
8//!
9//! 1. **Rotation Invariance**: Rotating the entire system preserves relative dynamics
10//! 2. **Time-Reversal Symmetry**: Symplectic integrators are time-reversible
11//! 3. **Mass Scaling**: Uniform mass scaling preserves orbital shapes
12//! 4. **Energy Conservation**: Total mechanical energy is preserved
13//! 5. **Momentum Conservation**: Total momentum is preserved (closed system)
14//!
15//! # References
16//!
17//! [33] Chen et al., "Metamorphic testing: a new approach," Hong Kong UST, 1998.
18
19use crate::orbit::physics::{NBodyState, OrbitBody, YoshidaIntegrator};
20use crate::orbit::units::{OrbitMass, OrbitTime, Position3D, Velocity3D};
21
22/// Metamorphic test result.
23#[derive(Debug, Clone)]
24pub struct MetamorphicResult {
25    /// Name of the relation tested.
26    pub relation: String,
27    /// Whether the relation holds within tolerance.
28    pub passed: bool,
29    /// Measured error/deviation.
30    pub error: f64,
31    /// Tolerance used.
32    pub tolerance: f64,
33    /// Additional details.
34    pub details: String,
35}
36
37impl MetamorphicResult {
38    /// Create a passing result.
39    #[must_use]
40    pub fn pass(relation: &str, error: f64, tolerance: f64) -> Self {
41        Self {
42            relation: relation.to_string(),
43            passed: true,
44            error,
45            tolerance,
46            details: String::new(),
47        }
48    }
49
50    /// Create a failing result.
51    #[must_use]
52    pub fn fail(relation: &str, error: f64, tolerance: f64, details: &str) -> Self {
53        Self {
54            relation: relation.to_string(),
55            passed: false,
56            error,
57            tolerance,
58            details: details.to_string(),
59        }
60    }
61}
62
63/// Compute pairwise distances between all bodies.
64fn compute_pairwise_distances(state: &NBodyState) -> Vec<f64> {
65    let n = state.bodies.len();
66    let mut distances = Vec::with_capacity(n * (n - 1) / 2);
67
68    for i in 0..n {
69        for j in (i + 1)..n {
70            let (x1, y1, z1) = state.bodies[i].position.as_meters();
71            let (x2, y2, z2) = state.bodies[j].position.as_meters();
72            let dx = x2 - x1;
73            let dy = y2 - y1;
74            let dz = z2 - z1;
75            distances.push((dx * dx + dy * dy + dz * dz).sqrt());
76        }
77    }
78
79    distances
80}
81
82/// Apply 3D rotation matrix around Z-axis.
83fn rotate_z(x: f64, y: f64, z: f64, angle: f64) -> (f64, f64, f64) {
84    let (sin_a, cos_a) = angle.sin_cos();
85    (x * cos_a - y * sin_a, x * sin_a + y * cos_a, z)
86}
87
88/// Rotate entire system around Z-axis.
89fn rotate_state(state: &NBodyState, angle: f64) -> NBodyState {
90    let rotated_bodies: Vec<OrbitBody> = state
91        .bodies
92        .iter()
93        .map(|body| {
94            let (px, py, pz) = body.position.as_meters();
95            let (vx, vy, vz) = body.velocity.as_mps();
96
97            let (rpx, rpy, rpz) = rotate_z(px, py, pz, angle);
98            let (rvx, rvy, rvz) = rotate_z(vx, vy, vz, angle);
99
100            OrbitBody::new(
101                body.mass,
102                Position3D::from_meters(rpx, rpy, rpz),
103                Velocity3D::from_mps(rvx, rvy, rvz),
104            )
105        })
106        .collect();
107
108    NBodyState::new(rotated_bodies, state.min_separation().min(1e6))
109}
110
111/// Reverse velocities for time-reversal test.
112fn reverse_velocities(state: &NBodyState) -> NBodyState {
113    let reversed_bodies: Vec<OrbitBody> = state
114        .bodies
115        .iter()
116        .map(|body| {
117            let (vx, vy, vz) = body.velocity.as_mps();
118            OrbitBody::new(
119                body.mass,
120                body.position,
121                Velocity3D::from_mps(-vx, -vy, -vz),
122            )
123        })
124        .collect();
125
126    NBodyState::new(reversed_bodies, state.min_separation().min(1e6))
127}
128
129/// Scale all masses by a uniform factor.
130#[allow(dead_code)] // Reserved for MR-3 mass scaling relation
131fn scale_masses(state: &NBodyState, factor: f64) -> NBodyState {
132    let scaled_bodies: Vec<OrbitBody> = state
133        .bodies
134        .iter()
135        .map(|body| {
136            OrbitBody::new(
137                OrbitMass::from_kg(body.mass.as_kg() * factor),
138                body.position,
139                body.velocity,
140            )
141        })
142        .collect();
143
144    NBodyState::new(scaled_bodies, state.min_separation().min(1e6))
145}
146
147/// MR-1: Rotation Invariance Test
148///
149/// Rotating the entire system should not change relative distances
150/// or the evolution of relative dynamics.
151#[must_use]
152pub fn test_rotation_invariance(
153    initial_state: &NBodyState,
154    steps: usize,
155    dt: f64,
156    tolerance: f64,
157) -> MetamorphicResult {
158    let angle = std::f64::consts::PI / 4.0; // 45 degrees
159
160    // Run original simulation
161    let mut original = initial_state.clone();
162    let yoshida = YoshidaIntegrator::new();
163    let dt_orbit = OrbitTime::from_seconds(dt);
164
165    for _ in 0..steps {
166        if yoshida.step(&mut original, dt_orbit).is_err() {
167            return MetamorphicResult::fail(
168                "Rotation Invariance",
169                f64::NAN,
170                tolerance,
171                "Original simulation failed",
172            );
173        }
174    }
175
176    // Run rotated simulation
177    let rotated_initial = rotate_state(initial_state, angle);
178    let mut rotated = rotated_initial;
179
180    for _ in 0..steps {
181        if yoshida.step(&mut rotated, dt_orbit).is_err() {
182            return MetamorphicResult::fail(
183                "Rotation Invariance",
184                f64::NAN,
185                tolerance,
186                "Rotated simulation failed",
187            );
188        }
189    }
190
191    // Compare pairwise distances
192    let original_distances = compute_pairwise_distances(&original);
193    let rotated_distances = compute_pairwise_distances(&rotated);
194
195    let mut max_error = 0.0_f64;
196    for (d1, d2) in original_distances.iter().zip(rotated_distances.iter()) {
197        let rel_error = if *d1 > f64::EPSILON {
198            (d1 - d2).abs() / d1
199        } else {
200            (d1 - d2).abs()
201        };
202        max_error = max_error.max(rel_error);
203    }
204
205    if max_error <= tolerance {
206        MetamorphicResult::pass("Rotation Invariance", max_error, tolerance)
207    } else {
208        MetamorphicResult::fail(
209            "Rotation Invariance",
210            max_error,
211            tolerance,
212            &format!("Max distance error: {max_error:.2e}"),
213        )
214    }
215}
216
217/// MR-2: Time-Reversal Symmetry Test
218///
219/// For symplectic integrators, running forward N steps then backward N steps
220/// should return to (approximately) the initial state.
221#[must_use]
222pub fn test_time_reversal(
223    initial_state: &NBodyState,
224    steps: usize,
225    dt: f64,
226    tolerance: f64,
227) -> MetamorphicResult {
228    let yoshida = YoshidaIntegrator::new();
229    let dt_orbit = OrbitTime::from_seconds(dt);
230
231    // Run forward
232    let mut state = initial_state.clone();
233    for _ in 0..steps {
234        if yoshida.step(&mut state, dt_orbit).is_err() {
235            return MetamorphicResult::fail(
236                "Time-Reversal Symmetry",
237                f64::NAN,
238                tolerance,
239                "Forward simulation failed",
240            );
241        }
242    }
243
244    // Reverse velocities
245    state = reverse_velocities(&state);
246
247    // Run backward (same number of steps)
248    for _ in 0..steps {
249        if yoshida.step(&mut state, dt_orbit).is_err() {
250            return MetamorphicResult::fail(
251                "Time-Reversal Symmetry",
252                f64::NAN,
253                tolerance,
254                "Backward simulation failed",
255            );
256        }
257    }
258
259    // Reverse velocities again to compare
260    state = reverse_velocities(&state);
261
262    // Compare with initial state
263    let mut max_pos_error = 0.0_f64;
264    let mut max_vel_error = 0.0_f64;
265
266    for (initial, final_body) in initial_state.bodies.iter().zip(state.bodies.iter()) {
267        let (ix, iy, iz) = initial.position.as_meters();
268        let (fx, fy, fz) = final_body.position.as_meters();
269
270        let initial_r = (ix * ix + iy * iy + iz * iz).sqrt();
271        let pos_error = ((fx - ix).powi(2) + (fy - iy).powi(2) + (fz - iz).powi(2)).sqrt();
272        let rel_pos_error = if initial_r > f64::EPSILON {
273            pos_error / initial_r
274        } else {
275            pos_error
276        };
277        max_pos_error = max_pos_error.max(rel_pos_error);
278
279        let (ivx, ivy, ivz) = initial.velocity.as_mps();
280        let (fvx, fvy, fvz) = final_body.velocity.as_mps();
281
282        let initial_v = (ivx * ivx + ivy * ivy + ivz * ivz).sqrt();
283        let vel_error = ((fvx - ivx).powi(2) + (fvy - ivy).powi(2) + (fvz - ivz).powi(2)).sqrt();
284        let rel_vel_error = if initial_v > f64::EPSILON {
285            vel_error / initial_v
286        } else {
287            vel_error
288        };
289        max_vel_error = max_vel_error.max(rel_vel_error);
290    }
291
292    let max_error = max_pos_error.max(max_vel_error);
293
294    if max_error <= tolerance {
295        MetamorphicResult::pass("Time-Reversal Symmetry", max_error, tolerance)
296    } else {
297        MetamorphicResult::fail(
298            "Time-Reversal Symmetry",
299            max_error,
300            tolerance,
301            &format!("Pos error: {max_pos_error:.2e}, Vel error: {max_vel_error:.2e}"),
302        )
303    }
304}
305
306/// MR-3: Energy Conservation Test
307///
308/// Total mechanical energy should be conserved (bounded oscillation for symplectic).
309#[must_use]
310pub fn test_energy_conservation(
311    initial_state: &NBodyState,
312    steps: usize,
313    dt: f64,
314    tolerance: f64,
315) -> MetamorphicResult {
316    let yoshida = YoshidaIntegrator::new();
317    let dt_orbit = OrbitTime::from_seconds(dt);
318
319    let initial_energy = initial_state.total_energy();
320    let mut state = initial_state.clone();
321    let mut max_error = 0.0_f64;
322
323    for _ in 0..steps {
324        if yoshida.step(&mut state, dt_orbit).is_err() {
325            return MetamorphicResult::fail(
326                "Energy Conservation",
327                f64::NAN,
328                tolerance,
329                "Simulation failed",
330            );
331        }
332
333        let current_energy = state.total_energy();
334        let rel_error = if initial_energy.abs() > f64::EPSILON {
335            (current_energy - initial_energy).abs() / initial_energy.abs()
336        } else {
337            (current_energy - initial_energy).abs()
338        };
339        max_error = max_error.max(rel_error);
340    }
341
342    if max_error <= tolerance {
343        MetamorphicResult::pass("Energy Conservation", max_error, tolerance)
344    } else {
345        MetamorphicResult::fail(
346            "Energy Conservation",
347            max_error,
348            tolerance,
349            &format!("Max energy drift: {max_error:.2e}"),
350        )
351    }
352}
353
354/// MR-4: Angular Momentum Conservation Test
355///
356/// Total angular momentum should be exactly conserved (to machine precision).
357#[must_use]
358pub fn test_angular_momentum_conservation(
359    initial_state: &NBodyState,
360    steps: usize,
361    dt: f64,
362    tolerance: f64,
363) -> MetamorphicResult {
364    let yoshida = YoshidaIntegrator::new();
365    let dt_orbit = OrbitTime::from_seconds(dt);
366
367    let initial_l = initial_state.angular_momentum_magnitude();
368    let mut state = initial_state.clone();
369    let mut max_error = 0.0_f64;
370
371    for _ in 0..steps {
372        if yoshida.step(&mut state, dt_orbit).is_err() {
373            return MetamorphicResult::fail(
374                "Angular Momentum Conservation",
375                f64::NAN,
376                tolerance,
377                "Simulation failed",
378            );
379        }
380
381        let current_l = state.angular_momentum_magnitude();
382        let rel_error = if initial_l.abs() > f64::EPSILON {
383            (current_l - initial_l).abs() / initial_l.abs()
384        } else {
385            (current_l - initial_l).abs()
386        };
387        max_error = max_error.max(rel_error);
388    }
389
390    if max_error <= tolerance {
391        MetamorphicResult::pass("Angular Momentum Conservation", max_error, tolerance)
392    } else {
393        MetamorphicResult::fail(
394            "Angular Momentum Conservation",
395            max_error,
396            tolerance,
397            &format!("Max L drift: {max_error:.2e}"),
398        )
399    }
400}
401
402/// MR-5: Deterministic Replay Test
403///
404/// Same initial conditions + same seed should produce identical results.
405#[must_use]
406pub fn test_deterministic_replay(
407    initial_state: &NBodyState,
408    steps: usize,
409    dt: f64,
410) -> MetamorphicResult {
411    let yoshida = YoshidaIntegrator::new();
412    let dt_orbit = OrbitTime::from_seconds(dt);
413
414    // First run
415    let mut state1 = initial_state.clone();
416    for _ in 0..steps {
417        if yoshida.step(&mut state1, dt_orbit).is_err() {
418            return MetamorphicResult::fail(
419                "Deterministic Replay",
420                f64::NAN,
421                0.0,
422                "First run failed",
423            );
424        }
425    }
426
427    // Second run (identical)
428    let mut state2 = initial_state.clone();
429    for _ in 0..steps {
430        if yoshida.step(&mut state2, dt_orbit).is_err() {
431            return MetamorphicResult::fail(
432                "Deterministic Replay",
433                f64::NAN,
434                0.0,
435                "Second run failed",
436            );
437        }
438    }
439
440    // Compare bit-for-bit (intentional exact comparison for determinism test)
441    #[allow(clippy::float_cmp)]
442    let identical = state1
443        .bodies
444        .iter()
445        .zip(state2.bodies.iter())
446        .all(|(b1, b2)| {
447            let (x1, y1, z1) = b1.position.as_meters();
448            let (x2, y2, z2) = b2.position.as_meters();
449            let (vx1, vy1, vz1) = b1.velocity.as_mps();
450            let (vx2, vy2, vz2) = b2.velocity.as_mps();
451
452            x1 == x2 && y1 == y2 && z1 == z2 && vx1 == vx2 && vy1 == vy2 && vz1 == vz2
453        });
454
455    if identical {
456        MetamorphicResult::pass("Deterministic Replay", 0.0, 0.0)
457    } else {
458        MetamorphicResult::fail(
459            "Deterministic Replay",
460            1.0,
461            0.0,
462            "Results not bit-identical",
463        )
464    }
465}
466
467/// Run all metamorphic tests on a state.
468#[must_use]
469pub fn run_all_metamorphic_tests(
470    state: &NBodyState,
471    steps: usize,
472    dt: f64,
473) -> Vec<MetamorphicResult> {
474    vec![
475        test_rotation_invariance(state, steps, dt, 1e-10),
476        test_time_reversal(state, steps, dt, 1e-6),
477        test_energy_conservation(state, steps, dt, 1e-9),
478        test_angular_momentum_conservation(state, steps, dt, 1e-12),
479        test_deterministic_replay(state, steps, dt),
480    ]
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::orbit::scenarios::KeplerConfig;
487    use crate::orbit::units::AU;
488
489    fn create_earth_sun_state() -> NBodyState {
490        KeplerConfig::earth_sun().build(1e6)
491    }
492
493    #[test]
494    fn test_metamorphic_result_pass() {
495        let result = MetamorphicResult::pass("Test", 1e-10, 1e-9);
496        assert!(result.passed);
497        assert!(result.error < result.tolerance);
498    }
499
500    #[test]
501    fn test_metamorphic_result_fail() {
502        let result = MetamorphicResult::fail("Test", 1e-5, 1e-9, "Too large");
503        assert!(!result.passed);
504        assert!(result.error > result.tolerance);
505    }
506
507    #[test]
508    fn test_compute_pairwise_distances() {
509        let state = create_earth_sun_state();
510        let distances = compute_pairwise_distances(&state);
511        assert_eq!(distances.len(), 1); // 2 bodies = 1 pair
512                                        // Earth starts at perihelion (~0.983 AU due to eccentricity 0.0167)
513        let perihelion = AU * (1.0 - 0.0167);
514        assert!((distances[0] - perihelion).abs() / perihelion < 0.01);
515    }
516
517    #[test]
518    fn test_rotate_z() {
519        let (x, y, z) = rotate_z(1.0, 0.0, 0.0, std::f64::consts::FRAC_PI_2);
520        assert!(x.abs() < 1e-10);
521        assert!((y - 1.0).abs() < 1e-10);
522        assert!(z.abs() < 1e-10);
523    }
524
525    #[test]
526    fn test_rotate_state() {
527        let state = create_earth_sun_state();
528        let rotated = rotate_state(&state, std::f64::consts::FRAC_PI_2);
529
530        // Sun should stay at origin
531        let (sx, sy, _) = rotated.bodies[0].position.as_meters();
532        assert!(sx.abs() < 1e-10);
533        assert!(sy.abs() < 1e-10);
534
535        // Earth should be rotated 90 degrees
536        // Original position is at perihelion (~0.983 AU, 0)
537        let (orig_x, _, _) = state.bodies[1].position.as_meters();
538        let (ex, ey, _) = rotated.bodies[1].position.as_meters();
539        assert!(ex.abs() < orig_x.abs() * 0.01); // ~0 after 90° rotation from (perihelion, 0)
540        assert!((ey - orig_x).abs() / orig_x.abs() < 0.01); // ~perihelion after 90° rotation
541    }
542
543    #[test]
544    fn test_reverse_velocities() {
545        let state = create_earth_sun_state();
546        let reversed = reverse_velocities(&state);
547
548        let (_, vy_orig, _) = state.bodies[1].velocity.as_mps();
549        let (_, vy_rev, _) = reversed.bodies[1].velocity.as_mps();
550
551        assert!((vy_orig + vy_rev).abs() < 1e-10);
552    }
553
554    #[test]
555    fn test_scale_masses() {
556        let state = create_earth_sun_state();
557        let scaled = scale_masses(&state, 2.0);
558
559        let orig_mass = state.bodies[0].mass.as_kg();
560        let scaled_mass = scaled.bodies[0].mass.as_kg();
561
562        assert!((scaled_mass - 2.0 * orig_mass).abs() / orig_mass < 1e-10);
563    }
564
565    #[test]
566    fn test_mr_rotation_invariance() {
567        let state = create_earth_sun_state();
568        let result = test_rotation_invariance(&state, 100, 3600.0, 1e-8);
569        assert!(result.passed, "Rotation invariance failed: {:?}", result);
570    }
571
572    #[test]
573    fn test_mr_time_reversal() {
574        let state = create_earth_sun_state();
575        // Use fewer steps and larger tolerance for time-reversal
576        // (error accumulates both ways)
577        let result = test_time_reversal(&state, 50, 3600.0, 1e-4);
578        assert!(result.passed, "Time-reversal failed: {:?}", result);
579    }
580
581    #[test]
582    fn test_mr_energy_conservation() {
583        let state = create_earth_sun_state();
584        let result = test_energy_conservation(&state, 100, 3600.0, 1e-9);
585        assert!(result.passed, "Energy conservation failed: {:?}", result);
586    }
587
588    #[test]
589    fn test_mr_angular_momentum_conservation() {
590        let state = create_earth_sun_state();
591        let result = test_angular_momentum_conservation(&state, 100, 3600.0, 1e-12);
592        assert!(
593            result.passed,
594            "Angular momentum conservation failed: {:?}",
595            result
596        );
597    }
598
599    #[test]
600    fn test_mr_deterministic_replay() {
601        let state = create_earth_sun_state();
602        let result = test_deterministic_replay(&state, 100, 3600.0);
603        assert!(result.passed, "Deterministic replay failed: {:?}", result);
604    }
605
606    #[test]
607    fn test_run_all_metamorphic_tests() {
608        let state = create_earth_sun_state();
609        let results = run_all_metamorphic_tests(&state, 50, 3600.0);
610
611        assert_eq!(results.len(), 5);
612
613        // At minimum, deterministic replay and angular momentum should pass
614        let deterministic = &results[4];
615        assert!(deterministic.passed, "Deterministic: {:?}", deterministic);
616
617        let angular = &results[3];
618        assert!(angular.passed, "Angular momentum: {:?}", angular);
619    }
620
621    #[test]
622    fn test_mr_energy_with_longer_simulation() {
623        let state = create_earth_sun_state();
624        // Test AC-2: Energy drift < 1e-9 over 100 orbits
625        // 100 days at 1 hour steps = 2400 steps
626        let result = test_energy_conservation(&state, 2400, 3600.0, 1e-9);
627        assert!(result.passed, "Energy over 100 days: {:?}", result);
628    }
629
630    #[test]
631    fn test_orbital_period_metamorphic() {
632        // MR: After one orbital period, Earth should return near starting position
633        let state = create_earth_sun_state();
634        let yoshida = YoshidaIntegrator::new();
635        let dt = OrbitTime::from_seconds(3600.0); // 1 hour
636
637        // Earth's orbital period is ~365.25 days = 8766 hours
638        let steps = 8766;
639        let mut current = state.clone();
640
641        for _ in 0..steps {
642            yoshida.step(&mut current, dt).expect("step failed");
643        }
644
645        // Check Earth position relative to starting
646        let (x0, y0, _) = state.bodies[1].position.as_meters();
647        let (x1, y1, _) = current.bodies[1].position.as_meters();
648
649        let start_r = (x0 * x0 + y0 * y0).sqrt();
650        let pos_diff = ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt();
651        let rel_error = pos_diff / start_r;
652
653        // Should be within 1% of starting position after 1 year
654        assert!(rel_error < 0.01, "Orbital period error: {rel_error}");
655    }
656}