use crate::orbit::physics::{NBodyState, OrbitBody, YoshidaIntegrator};
use crate::orbit::units::{OrbitMass, OrbitTime, Position3D, Velocity3D};
#[derive(Debug, Clone)]
pub struct MetamorphicResult {
pub relation: String,
pub passed: bool,
pub error: f64,
pub tolerance: f64,
pub details: String,
}
impl MetamorphicResult {
#[must_use]
pub fn pass(relation: &str, error: f64, tolerance: f64) -> Self {
Self {
relation: relation.to_string(),
passed: true,
error,
tolerance,
details: String::new(),
}
}
#[must_use]
pub fn fail(relation: &str, error: f64, tolerance: f64, details: &str) -> Self {
Self {
relation: relation.to_string(),
passed: false,
error,
tolerance,
details: details.to_string(),
}
}
}
fn compute_pairwise_distances(state: &NBodyState) -> Vec<f64> {
let n = state.bodies.len();
let mut distances = Vec::with_capacity(n * (n - 1) / 2);
for i in 0..n {
for j in (i + 1)..n {
let (x1, y1, z1) = state.bodies[i].position.as_meters();
let (x2, y2, z2) = state.bodies[j].position.as_meters();
let dx = x2 - x1;
let dy = y2 - y1;
let dz = z2 - z1;
distances.push((dx * dx + dy * dy + dz * dz).sqrt());
}
}
distances
}
fn rotate_z(x: f64, y: f64, z: f64, angle: f64) -> (f64, f64, f64) {
let (sin_a, cos_a) = angle.sin_cos();
(x * cos_a - y * sin_a, x * sin_a + y * cos_a, z)
}
fn rotate_state(state: &NBodyState, angle: f64) -> NBodyState {
let rotated_bodies: Vec<OrbitBody> = state
.bodies
.iter()
.map(|body| {
let (px, py, pz) = body.position.as_meters();
let (vx, vy, vz) = body.velocity.as_mps();
let (rpx, rpy, rpz) = rotate_z(px, py, pz, angle);
let (rvx, rvy, rvz) = rotate_z(vx, vy, vz, angle);
OrbitBody::new(
body.mass,
Position3D::from_meters(rpx, rpy, rpz),
Velocity3D::from_mps(rvx, rvy, rvz),
)
})
.collect();
NBodyState::new(rotated_bodies, state.min_separation().min(1e6))
}
fn reverse_velocities(state: &NBodyState) -> NBodyState {
let reversed_bodies: Vec<OrbitBody> = state
.bodies
.iter()
.map(|body| {
let (vx, vy, vz) = body.velocity.as_mps();
OrbitBody::new(
body.mass,
body.position,
Velocity3D::from_mps(-vx, -vy, -vz),
)
})
.collect();
NBodyState::new(reversed_bodies, state.min_separation().min(1e6))
}
#[allow(dead_code)] fn scale_masses(state: &NBodyState, factor: f64) -> NBodyState {
let scaled_bodies: Vec<OrbitBody> = state
.bodies
.iter()
.map(|body| {
OrbitBody::new(
OrbitMass::from_kg(body.mass.as_kg() * factor),
body.position,
body.velocity,
)
})
.collect();
NBodyState::new(scaled_bodies, state.min_separation().min(1e6))
}
#[must_use]
pub fn test_rotation_invariance(
initial_state: &NBodyState,
steps: usize,
dt: f64,
tolerance: f64,
) -> MetamorphicResult {
let angle = std::f64::consts::PI / 4.0;
let mut original = initial_state.clone();
let yoshida = YoshidaIntegrator::new();
let dt_orbit = OrbitTime::from_seconds(dt);
for _ in 0..steps {
if yoshida.step(&mut original, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Rotation Invariance",
f64::NAN,
tolerance,
"Original simulation failed",
);
}
}
let rotated_initial = rotate_state(initial_state, angle);
let mut rotated = rotated_initial;
for _ in 0..steps {
if yoshida.step(&mut rotated, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Rotation Invariance",
f64::NAN,
tolerance,
"Rotated simulation failed",
);
}
}
let original_distances = compute_pairwise_distances(&original);
let rotated_distances = compute_pairwise_distances(&rotated);
let mut max_error = 0.0_f64;
for (d1, d2) in original_distances.iter().zip(rotated_distances.iter()) {
let rel_error = if *d1 > f64::EPSILON {
(d1 - d2).abs() / d1
} else {
(d1 - d2).abs()
};
max_error = max_error.max(rel_error);
}
if max_error <= tolerance {
MetamorphicResult::pass("Rotation Invariance", max_error, tolerance)
} else {
MetamorphicResult::fail(
"Rotation Invariance",
max_error,
tolerance,
&format!("Max distance error: {max_error:.2e}"),
)
}
}
#[must_use]
pub fn test_time_reversal(
initial_state: &NBodyState,
steps: usize,
dt: f64,
tolerance: f64,
) -> MetamorphicResult {
let yoshida = YoshidaIntegrator::new();
let dt_orbit = OrbitTime::from_seconds(dt);
let mut state = initial_state.clone();
for _ in 0..steps {
if yoshida.step(&mut state, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Time-Reversal Symmetry",
f64::NAN,
tolerance,
"Forward simulation failed",
);
}
}
state = reverse_velocities(&state);
for _ in 0..steps {
if yoshida.step(&mut state, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Time-Reversal Symmetry",
f64::NAN,
tolerance,
"Backward simulation failed",
);
}
}
state = reverse_velocities(&state);
let mut max_pos_error = 0.0_f64;
let mut max_vel_error = 0.0_f64;
for (initial, final_body) in initial_state.bodies.iter().zip(state.bodies.iter()) {
let (ix, iy, iz) = initial.position.as_meters();
let (fx, fy, fz) = final_body.position.as_meters();
let initial_r = (ix * ix + iy * iy + iz * iz).sqrt();
let pos_error = ((fx - ix).powi(2) + (fy - iy).powi(2) + (fz - iz).powi(2)).sqrt();
let rel_pos_error = if initial_r > f64::EPSILON {
pos_error / initial_r
} else {
pos_error
};
max_pos_error = max_pos_error.max(rel_pos_error);
let (ivx, ivy, ivz) = initial.velocity.as_mps();
let (fvx, fvy, fvz) = final_body.velocity.as_mps();
let initial_v = (ivx * ivx + ivy * ivy + ivz * ivz).sqrt();
let vel_error = ((fvx - ivx).powi(2) + (fvy - ivy).powi(2) + (fvz - ivz).powi(2)).sqrt();
let rel_vel_error = if initial_v > f64::EPSILON {
vel_error / initial_v
} else {
vel_error
};
max_vel_error = max_vel_error.max(rel_vel_error);
}
let max_error = max_pos_error.max(max_vel_error);
if max_error <= tolerance {
MetamorphicResult::pass("Time-Reversal Symmetry", max_error, tolerance)
} else {
MetamorphicResult::fail(
"Time-Reversal Symmetry",
max_error,
tolerance,
&format!("Pos error: {max_pos_error:.2e}, Vel error: {max_vel_error:.2e}"),
)
}
}
#[must_use]
pub fn test_energy_conservation(
initial_state: &NBodyState,
steps: usize,
dt: f64,
tolerance: f64,
) -> MetamorphicResult {
let yoshida = YoshidaIntegrator::new();
let dt_orbit = OrbitTime::from_seconds(dt);
let initial_energy = initial_state.total_energy();
let mut state = initial_state.clone();
let mut max_error = 0.0_f64;
for _ in 0..steps {
if yoshida.step(&mut state, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Energy Conservation",
f64::NAN,
tolerance,
"Simulation failed",
);
}
let current_energy = state.total_energy();
let rel_error = if initial_energy.abs() > f64::EPSILON {
(current_energy - initial_energy).abs() / initial_energy.abs()
} else {
(current_energy - initial_energy).abs()
};
max_error = max_error.max(rel_error);
}
if max_error <= tolerance {
MetamorphicResult::pass("Energy Conservation", max_error, tolerance)
} else {
MetamorphicResult::fail(
"Energy Conservation",
max_error,
tolerance,
&format!("Max energy drift: {max_error:.2e}"),
)
}
}
#[must_use]
pub fn test_angular_momentum_conservation(
initial_state: &NBodyState,
steps: usize,
dt: f64,
tolerance: f64,
) -> MetamorphicResult {
let yoshida = YoshidaIntegrator::new();
let dt_orbit = OrbitTime::from_seconds(dt);
let initial_l = initial_state.angular_momentum_magnitude();
let mut state = initial_state.clone();
let mut max_error = 0.0_f64;
for _ in 0..steps {
if yoshida.step(&mut state, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Angular Momentum Conservation",
f64::NAN,
tolerance,
"Simulation failed",
);
}
let current_l = state.angular_momentum_magnitude();
let rel_error = if initial_l.abs() > f64::EPSILON {
(current_l - initial_l).abs() / initial_l.abs()
} else {
(current_l - initial_l).abs()
};
max_error = max_error.max(rel_error);
}
if max_error <= tolerance {
MetamorphicResult::pass("Angular Momentum Conservation", max_error, tolerance)
} else {
MetamorphicResult::fail(
"Angular Momentum Conservation",
max_error,
tolerance,
&format!("Max L drift: {max_error:.2e}"),
)
}
}
#[must_use]
pub fn test_deterministic_replay(
initial_state: &NBodyState,
steps: usize,
dt: f64,
) -> MetamorphicResult {
let yoshida = YoshidaIntegrator::new();
let dt_orbit = OrbitTime::from_seconds(dt);
let mut state1 = initial_state.clone();
for _ in 0..steps {
if yoshida.step(&mut state1, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Deterministic Replay",
f64::NAN,
0.0,
"First run failed",
);
}
}
let mut state2 = initial_state.clone();
for _ in 0..steps {
if yoshida.step(&mut state2, dt_orbit).is_err() {
return MetamorphicResult::fail(
"Deterministic Replay",
f64::NAN,
0.0,
"Second run failed",
);
}
}
#[allow(clippy::float_cmp)]
let identical = state1
.bodies
.iter()
.zip(state2.bodies.iter())
.all(|(b1, b2)| {
let (x1, y1, z1) = b1.position.as_meters();
let (x2, y2, z2) = b2.position.as_meters();
let (vx1, vy1, vz1) = b1.velocity.as_mps();
let (vx2, vy2, vz2) = b2.velocity.as_mps();
x1 == x2 && y1 == y2 && z1 == z2 && vx1 == vx2 && vy1 == vy2 && vz1 == vz2
});
if identical {
MetamorphicResult::pass("Deterministic Replay", 0.0, 0.0)
} else {
MetamorphicResult::fail(
"Deterministic Replay",
1.0,
0.0,
"Results not bit-identical",
)
}
}
#[must_use]
pub fn run_all_metamorphic_tests(
state: &NBodyState,
steps: usize,
dt: f64,
) -> Vec<MetamorphicResult> {
vec![
test_rotation_invariance(state, steps, dt, 1e-10),
test_time_reversal(state, steps, dt, 1e-6),
test_energy_conservation(state, steps, dt, 1e-9),
test_angular_momentum_conservation(state, steps, dt, 1e-12),
test_deterministic_replay(state, steps, dt),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orbit::scenarios::KeplerConfig;
use crate::orbit::units::AU;
fn create_earth_sun_state() -> NBodyState {
KeplerConfig::earth_sun().build(1e6)
}
#[test]
fn test_metamorphic_result_pass() {
let result = MetamorphicResult::pass("Test", 1e-10, 1e-9);
assert!(result.passed);
assert!(result.error < result.tolerance);
}
#[test]
fn test_metamorphic_result_fail() {
let result = MetamorphicResult::fail("Test", 1e-5, 1e-9, "Too large");
assert!(!result.passed);
assert!(result.error > result.tolerance);
}
#[test]
fn test_compute_pairwise_distances() {
let state = create_earth_sun_state();
let distances = compute_pairwise_distances(&state);
assert_eq!(distances.len(), 1); let perihelion = AU * (1.0 - 0.0167);
assert!((distances[0] - perihelion).abs() / perihelion < 0.01);
}
#[test]
fn test_rotate_z() {
let (x, y, z) = rotate_z(1.0, 0.0, 0.0, std::f64::consts::FRAC_PI_2);
assert!(x.abs() < 1e-10);
assert!((y - 1.0).abs() < 1e-10);
assert!(z.abs() < 1e-10);
}
#[test]
fn test_rotate_state() {
let state = create_earth_sun_state();
let rotated = rotate_state(&state, std::f64::consts::FRAC_PI_2);
let (sx, sy, _) = rotated.bodies[0].position.as_meters();
assert!(sx.abs() < 1e-10);
assert!(sy.abs() < 1e-10);
let (orig_x, _, _) = state.bodies[1].position.as_meters();
let (ex, ey, _) = rotated.bodies[1].position.as_meters();
assert!(ex.abs() < orig_x.abs() * 0.01); assert!((ey - orig_x).abs() / orig_x.abs() < 0.01); }
#[test]
fn test_reverse_velocities() {
let state = create_earth_sun_state();
let reversed = reverse_velocities(&state);
let (_, vy_orig, _) = state.bodies[1].velocity.as_mps();
let (_, vy_rev, _) = reversed.bodies[1].velocity.as_mps();
assert!((vy_orig + vy_rev).abs() < 1e-10);
}
#[test]
fn test_scale_masses() {
let state = create_earth_sun_state();
let scaled = scale_masses(&state, 2.0);
let orig_mass = state.bodies[0].mass.as_kg();
let scaled_mass = scaled.bodies[0].mass.as_kg();
assert!((scaled_mass - 2.0 * orig_mass).abs() / orig_mass < 1e-10);
}
#[test]
fn test_mr_rotation_invariance() {
let state = create_earth_sun_state();
let result = test_rotation_invariance(&state, 100, 3600.0, 1e-8);
assert!(result.passed, "Rotation invariance failed: {:?}", result);
}
#[test]
fn test_mr_time_reversal() {
let state = create_earth_sun_state();
let result = test_time_reversal(&state, 50, 3600.0, 1e-4);
assert!(result.passed, "Time-reversal failed: {:?}", result);
}
#[test]
fn test_mr_energy_conservation() {
let state = create_earth_sun_state();
let result = test_energy_conservation(&state, 100, 3600.0, 1e-9);
assert!(result.passed, "Energy conservation failed: {:?}", result);
}
#[test]
fn test_mr_angular_momentum_conservation() {
let state = create_earth_sun_state();
let result = test_angular_momentum_conservation(&state, 100, 3600.0, 1e-12);
assert!(
result.passed,
"Angular momentum conservation failed: {:?}",
result
);
}
#[test]
fn test_mr_deterministic_replay() {
let state = create_earth_sun_state();
let result = test_deterministic_replay(&state, 100, 3600.0);
assert!(result.passed, "Deterministic replay failed: {:?}", result);
}
#[test]
fn test_run_all_metamorphic_tests() {
let state = create_earth_sun_state();
let results = run_all_metamorphic_tests(&state, 50, 3600.0);
assert_eq!(results.len(), 5);
let deterministic = &results[4];
assert!(deterministic.passed, "Deterministic: {:?}", deterministic);
let angular = &results[3];
assert!(angular.passed, "Angular momentum: {:?}", angular);
}
#[test]
fn test_mr_energy_with_longer_simulation() {
let state = create_earth_sun_state();
let result = test_energy_conservation(&state, 2400, 3600.0, 1e-9);
assert!(result.passed, "Energy over 100 days: {:?}", result);
}
#[test]
fn test_orbital_period_metamorphic() {
let state = create_earth_sun_state();
let yoshida = YoshidaIntegrator::new();
let dt = OrbitTime::from_seconds(3600.0);
let steps = 8766;
let mut current = state.clone();
for _ in 0..steps {
yoshida.step(&mut current, dt).expect("step failed");
}
let (x0, y0, _) = state.bodies[1].position.as_meters();
let (x1, y1, _) = current.bodies[1].position.as_meters();
let start_r = (x0 * x0 + y0 * y0).sqrt();
let pos_diff = ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt();
let rel_error = pos_diff / start_r;
assert!(rel_error < 0.01, "Orbital period error: {rel_error}");
}
}