use crate::mass::MassProperties;
use crate::rotational::*;
use crate::state::TranslationalState;
use astrodyn_math::JeodQuat;
use glam::DVec3;
const A21: f64 = 1.0 / 4.0;
const A31: f64 = 3.0 / 32.0;
const A32: f64 = 9.0 / 32.0;
const A41: f64 = 1932.0 / 2197.0;
const A42: f64 = -7200.0 / 2197.0;
const A43: f64 = 7296.0 / 2197.0;
const A51: f64 = 439.0 / 216.0;
const A52: f64 = -8.0;
const A53: f64 = 3680.0 / 513.0;
const A54: f64 = -845.0 / 4104.0;
const A61: f64 = -8.0 / 27.0;
const A62: f64 = 2.0;
const A63: f64 = -3544.0 / 2565.0;
const A64: f64 = 1859.0 / 4104.0;
const A65: f64 = -11.0 / 40.0;
const B51: f64 = 16.0 / 135.0;
const B53: f64 = 6656.0 / 12825.0;
const B54: f64 = 28561.0 / 56430.0;
const B55: f64 = -9.0 / 50.0;
const B56: f64 = 2.0 / 55.0;
const B41: f64 = 25.0 / 216.0;
const B43: f64 = 1408.0 / 2565.0;
const B44: f64 = 2197.0 / 4104.0;
const B45: f64 = -1.0 / 5.0;
pub fn rkf45_translational_step(
state: &TranslationalState,
accel_fn: impl Fn(&TranslationalState, f64) -> DVec3,
dt: f64,
) -> TranslationalState {
assert!(
dt.is_finite(),
"rkf45_translational_step requires a finite dt, got {dt}"
);
let pos0 = state.position;
let vel0 = state.velocity;
let k1_v = vel0;
let k1_a = accel_fn(state, 0.0);
let s2 = TranslationalState {
position: pos0 + k1_v * (A21 * dt),
velocity: vel0 + k1_a * (A21 * dt),
};
let k2_v = s2.velocity;
let k2_a = accel_fn(&s2, 0.25);
let s3 = TranslationalState {
position: pos0 + (k1_v * A31 + k2_v * A32) * dt,
velocity: vel0 + (k1_a * A31 + k2_a * A32) * dt,
};
let k3_v = s3.velocity;
let k3_a = accel_fn(&s3, 3.0 / 8.0);
let s4 = TranslationalState {
position: pos0 + (k1_v * A41 + k2_v * A42 + k3_v * A43) * dt,
velocity: vel0 + (k1_a * A41 + k2_a * A42 + k3_a * A43) * dt,
};
let k4_v = s4.velocity;
let k4_a = accel_fn(&s4, 12.0 / 13.0);
let s5 = TranslationalState {
position: pos0 + (k1_v * A51 + k2_v * A52 + k3_v * A53 + k4_v * A54) * dt,
velocity: vel0 + (k1_a * A51 + k2_a * A52 + k3_a * A53 + k4_a * A54) * dt,
};
let k5_v = s5.velocity;
let k5_a = accel_fn(&s5, 1.0);
let s6 = TranslationalState {
position: pos0 + (k1_v * A61 + k2_v * A62 + k3_v * A63 + k4_v * A64 + k5_v * A65) * dt,
velocity: vel0 + (k1_a * A61 + k2_a * A62 + k3_a * A63 + k4_a * A64 + k5_a * A65) * dt,
};
let k6_v = s6.velocity;
let k6_a = accel_fn(&s6, 0.5);
TranslationalState {
position: pos0 + (k1_v * B51 + k3_v * B53 + k4_v * B54 + k5_v * B55 + k6_v * B56) * dt,
velocity: vel0 + (k1_a * B51 + k3_a * B53 + k4_a * B54 + k5_a * B55 + k6_a * B56) * dt,
}
}
pub fn rkf45_sixdof_step(
state: &SixDofState,
accel_fn: impl Fn(&SixDofState, f64) -> DVec3,
torque_fn: impl Fn(&SixDofState) -> DVec3,
mass_props: &MassProperties,
dt: f64,
) -> SixDofState {
assert!(
dt.is_finite(),
"rkf45_sixdof_step requires a finite dt, got {dt}"
);
let pos0 = state.trans.position;
let vel0 = state.trans.velocity;
let q0 = state.rot.quaternion.data;
let omega0 = state.rot.ang_vel_body;
let make_state = |pos: DVec3, vel: DVec3, q: [f64; 4], omega: DVec3| -> SixDofState {
SixDofState {
trans: TranslationalState {
position: pos,
velocity: vel,
},
rot: RotationalState {
quaternion: JeodQuat::new(q[0], q[1], q[2], q[3]),
ang_vel_body: omega,
},
}
};
let eval_derivs = |s: &SixDofState, time_frac: f64| -> (DVec3, DVec3, [f64; 4], DVec3) {
let k_v = s.trans.velocity;
let k_a = accel_fn(s, time_frac);
let k_qdot = compute_left_quat_deriv(&s.rot.quaternion, s.rot.ang_vel_body);
let k_alpha = compute_rotational_acceleration(
&mass_props.inertia,
&mass_props.inverse_inertia,
s.rot.ang_vel_body,
torque_fn(s),
);
(k_v, k_a, k_qdot, k_alpha)
};
let step_q = |q_base: [f64; 4], stages: &[([f64; 4], f64)]| -> [f64; 4] {
let mut result = q_base;
for (k, coeff) in stages {
for i in 0..4 {
result[i] += k[i] * coeff;
}
}
result
};
let (k1_v, k1_a, k1_qd, k1_al) = eval_derivs(state, 0.0);
let h1 = A21 * dt;
let s2 = make_state(
pos0 + k1_v * h1,
vel0 + k1_a * h1,
step_q(q0, &[(k1_qd, h1)]),
omega0 + k1_al * h1,
);
let (k2_v, k2_a, k2_qd, k2_al) = eval_derivs(&s2, 0.25);
let s3 = make_state(
pos0 + (k1_v * A31 + k2_v * A32) * dt,
vel0 + (k1_a * A31 + k2_a * A32) * dt,
step_q(q0, &[(k1_qd, A31 * dt), (k2_qd, A32 * dt)]),
omega0 + (k1_al * A31 + k2_al * A32) * dt,
);
let (k3_v, k3_a, k3_qd, k3_al) = eval_derivs(&s3, 3.0 / 8.0);
let s4 = make_state(
pos0 + (k1_v * A41 + k2_v * A42 + k3_v * A43) * dt,
vel0 + (k1_a * A41 + k2_a * A42 + k3_a * A43) * dt,
step_q(
q0,
&[(k1_qd, A41 * dt), (k2_qd, A42 * dt), (k3_qd, A43 * dt)],
),
omega0 + (k1_al * A41 + k2_al * A42 + k3_al * A43) * dt,
);
let (k4_v, k4_a, k4_qd, k4_al) = eval_derivs(&s4, 12.0 / 13.0);
let s5 = make_state(
pos0 + (k1_v * A51 + k2_v * A52 + k3_v * A53 + k4_v * A54) * dt,
vel0 + (k1_a * A51 + k2_a * A52 + k3_a * A53 + k4_a * A54) * dt,
step_q(
q0,
&[
(k1_qd, A51 * dt),
(k2_qd, A52 * dt),
(k3_qd, A53 * dt),
(k4_qd, A54 * dt),
],
),
omega0 + (k1_al * A51 + k2_al * A52 + k3_al * A53 + k4_al * A54) * dt,
);
let (k5_v, k5_a, k5_qd, k5_al) = eval_derivs(&s5, 1.0);
let s6 = make_state(
pos0 + (k1_v * A61 + k2_v * A62 + k3_v * A63 + k4_v * A64 + k5_v * A65) * dt,
vel0 + (k1_a * A61 + k2_a * A62 + k3_a * A63 + k4_a * A64 + k5_a * A65) * dt,
step_q(
q0,
&[
(k1_qd, A61 * dt),
(k2_qd, A62 * dt),
(k3_qd, A63 * dt),
(k4_qd, A64 * dt),
(k5_qd, A65 * dt),
],
),
omega0 + (k1_al * A61 + k2_al * A62 + k3_al * A63 + k4_al * A64 + k5_al * A65) * dt,
);
let (k6_v, k6_a, k6_qd, k6_al) = eval_derivs(&s6, 0.5);
let final_pos = pos0 + (k1_v * B51 + k3_v * B53 + k4_v * B54 + k5_v * B55 + k6_v * B56) * dt;
let final_vel = vel0 + (k1_a * B51 + k3_a * B53 + k4_a * B54 + k5_a * B55 + k6_a * B56) * dt;
let final_omega =
omega0 + (k1_al * B51 + k3_al * B53 + k4_al * B54 + k5_al * B55 + k6_al * B56) * dt;
let final_q = step_q(
q0,
&[
(k1_qd, B51 * dt),
(k3_qd, B53 * dt),
(k4_qd, B54 * dt),
(k5_qd, B55 * dt),
(k6_qd, B56 * dt),
],
);
let mut final_quat = JeodQuat::new(final_q[0], final_q[1], final_q[2], final_q[3]);
normalize_integ(&mut final_quat);
SixDofState {
trans: TranslationalState {
position: final_pos,
velocity: final_vel,
},
rot: RotationalState {
quaternion: final_quat,
ang_vel_body: final_omega,
},
}
}
#[derive(Debug, Clone, Copy)]
pub struct AdaptiveConfig {
pub pos_tolerance: f64,
pub vel_tolerance: f64,
pub safety_factor: f64,
pub min_step: f64,
pub max_step: f64,
pub max_rejections: usize,
}
impl Default for AdaptiveConfig {
fn default() -> Self {
Self {
pos_tolerance: 1e-6,
vel_tolerance: 1e-6,
safety_factor: 0.9,
min_step: 1e-6,
max_step: 1000.0,
max_rejections: 10,
}
}
}
impl AdaptiveConfig {
pub fn check(&self) -> Result<(), &'static str> {
if !self.pos_tolerance.is_finite() {
return Err("AdaptiveConfig.pos_tolerance must be finite");
}
if self.pos_tolerance <= 0.0 {
return Err("AdaptiveConfig.pos_tolerance must be > 0");
}
if !self.vel_tolerance.is_finite() {
return Err("AdaptiveConfig.vel_tolerance must be finite");
}
if self.vel_tolerance <= 0.0 {
return Err("AdaptiveConfig.vel_tolerance must be > 0");
}
if !self.safety_factor.is_finite() {
return Err("AdaptiveConfig.safety_factor must be finite");
}
if self.safety_factor <= 0.0 || self.safety_factor > 1.0 {
return Err("AdaptiveConfig.safety_factor must satisfy 0 < safety_factor <= 1");
}
if !self.min_step.is_finite() {
return Err("AdaptiveConfig.min_step must be finite");
}
if self.min_step <= 0.0 {
return Err("AdaptiveConfig.min_step must be > 0");
}
if !self.max_step.is_finite() {
return Err("AdaptiveConfig.max_step must be finite");
}
if self.max_step < self.min_step {
return Err("AdaptiveConfig.max_step must be >= min_step");
}
Ok(())
}
pub fn validate(&self) {
if let Err(err) = self.check() {
panic!("{err}");
}
}
}
#[derive(Debug, Clone)]
pub struct AdaptiveResult<S> {
pub state: S,
pub dt_used: f64,
pub dt_next: f64,
pub rejected: bool,
pub forced_accept: bool,
pub pos_error: f64,
pub vel_error: f64,
pub error_ratio: f64,
}
fn compute_step_factor(err_ratio: f64, safety: f64, growing: bool) -> f64 {
if err_ratio == 0.0 {
return safety * 5.0;
}
let exponent = if growing { 0.2 } else { 0.25 };
safety * err_ratio.powf(-exponent)
}
fn signed_clamp(dt: f64, min_step: f64, max_step: f64) -> f64 {
if dt == 0.0 {
return 0.0;
}
dt.signum() * dt.abs().clamp(min_step, max_step)
}
pub fn rkf45_adaptive_translational_step(
state: &TranslationalState,
accel_fn: impl Fn(&TranslationalState, f64) -> DVec3,
dt: f64,
config: &AdaptiveConfig,
) -> AdaptiveResult<TranslationalState> {
if let Err(err) = config.check() {
panic!("rkf45_adaptive_translational_step: invalid AdaptiveConfig: {err}");
}
assert!(
dt.is_finite(),
"rkf45_adaptive_translational_step requires a finite dt, got {dt}"
);
if dt == 0.0 {
return AdaptiveResult {
state: *state,
dt_used: 0.0,
dt_next: 0.0,
rejected: false,
forced_accept: false,
pos_error: 0.0,
vel_error: 0.0,
error_ratio: 0.0,
};
}
let mut dt_try = signed_clamp(dt, config.min_step, config.max_step);
let mut rejected = false;
for attempt in 0..=config.max_rejections {
let pos0 = state.position;
let vel0 = state.velocity;
let k1_v = vel0;
let k1_a = accel_fn(state, 0.0);
let s2 = TranslationalState {
position: pos0 + k1_v * (A21 * dt_try),
velocity: vel0 + k1_a * (A21 * dt_try),
};
let k2_v = s2.velocity;
let k2_a = accel_fn(&s2, 0.25);
let s3 = TranslationalState {
position: pos0 + (k1_v * A31 + k2_v * A32) * dt_try,
velocity: vel0 + (k1_a * A31 + k2_a * A32) * dt_try,
};
let k3_v = s3.velocity;
let k3_a = accel_fn(&s3, 3.0 / 8.0);
let s4 = TranslationalState {
position: pos0 + (k1_v * A41 + k2_v * A42 + k3_v * A43) * dt_try,
velocity: vel0 + (k1_a * A41 + k2_a * A42 + k3_a * A43) * dt_try,
};
let k4_v = s4.velocity;
let k4_a = accel_fn(&s4, 12.0 / 13.0);
let s5 = TranslationalState {
position: pos0 + (k1_v * A51 + k2_v * A52 + k3_v * A53 + k4_v * A54) * dt_try,
velocity: vel0 + (k1_a * A51 + k2_a * A52 + k3_a * A53 + k4_a * A54) * dt_try,
};
let k5_v = s5.velocity;
let k5_a = accel_fn(&s5, 1.0);
let s6 = TranslationalState {
position: pos0
+ (k1_v * A61 + k2_v * A62 + k3_v * A63 + k4_v * A64 + k5_v * A65) * dt_try,
velocity: vel0
+ (k1_a * A61 + k2_a * A62 + k3_a * A63 + k4_a * A64 + k5_a * A65) * dt_try,
};
let k6_v = s6.velocity;
let k6_a = accel_fn(&s6, 0.5);
let pos5 = pos0 + (k1_v * B51 + k3_v * B53 + k4_v * B54 + k5_v * B55 + k6_v * B56) * dt_try;
let vel5 = vel0 + (k1_a * B51 + k3_a * B53 + k4_a * B54 + k5_a * B55 + k6_a * B56) * dt_try;
let pos4 = pos0 + (k1_v * B41 + k3_v * B43 + k4_v * B44 + k5_v * B45) * dt_try;
let vel4 = vel0 + (k1_a * B41 + k3_a * B43 + k4_a * B44 + k5_a * B45) * dt_try;
let pos_err_vec = (pos5 - pos4).abs();
let vel_err_vec = (vel5 - vel4).abs();
let pos_error = pos_err_vec.x.max(pos_err_vec.y).max(pos_err_vec.z);
let vel_error = vel_err_vec.x.max(vel_err_vec.y).max(vel_err_vec.z);
let err_ratio = (pos_error / config.pos_tolerance).max(vel_error / config.vel_tolerance);
let within_tolerance = err_ratio <= 1.0;
let forced_accept = !within_tolerance && attempt == config.max_rejections;
if within_tolerance || forced_accept {
let factor = compute_step_factor(err_ratio, config.safety_factor, within_tolerance);
let dt_next = signed_clamp(dt_try * factor, config.min_step, config.max_step);
return AdaptiveResult {
state: TranslationalState {
position: pos5,
velocity: vel5,
},
dt_used: dt_try,
dt_next,
rejected,
forced_accept,
pos_error,
vel_error,
error_ratio: err_ratio,
};
}
rejected = true;
let factor = compute_step_factor(err_ratio, config.safety_factor, false);
dt_try = signed_clamp(dt_try * factor, config.min_step, config.max_step);
}
unreachable!("loop always returns via attempt == max_rejections")
}
pub fn rkf45_adaptive_sixdof_step(
state: &SixDofState,
accel_fn: impl Fn(&SixDofState, f64) -> DVec3,
torque_fn: impl Fn(&SixDofState) -> DVec3,
mass_props: &MassProperties,
dt: f64,
config: &AdaptiveConfig,
) -> AdaptiveResult<SixDofState> {
if let Err(err) = config.check() {
panic!("rkf45_adaptive_sixdof_step: invalid AdaptiveConfig: {err}");
}
assert!(
dt.is_finite(),
"rkf45_adaptive_sixdof_step requires a finite dt, got {dt}"
);
if dt == 0.0 {
return AdaptiveResult {
state: *state,
dt_used: 0.0,
dt_next: 0.0,
rejected: false,
forced_accept: false,
pos_error: 0.0,
vel_error: 0.0,
error_ratio: 0.0,
};
}
let mut dt_try = signed_clamp(dt, config.min_step, config.max_step);
let mut rejected = false;
let make_state = |pos: DVec3, vel: DVec3, q: [f64; 4], omega: DVec3| -> SixDofState {
SixDofState {
trans: TranslationalState {
position: pos,
velocity: vel,
},
rot: RotationalState {
quaternion: JeodQuat::new(q[0], q[1], q[2], q[3]),
ang_vel_body: omega,
},
}
};
for attempt in 0..=config.max_rejections {
let pos0 = state.trans.position;
let vel0 = state.trans.velocity;
let q0 = state.rot.quaternion.data;
let omega0 = state.rot.ang_vel_body;
let eval_derivs = |s: &SixDofState, time_frac: f64| -> (DVec3, DVec3, [f64; 4], DVec3) {
let k_v = s.trans.velocity;
let k_a = accel_fn(s, time_frac);
let k_qdot = compute_left_quat_deriv(&s.rot.quaternion, s.rot.ang_vel_body);
let k_alpha = compute_rotational_acceleration(
&mass_props.inertia,
&mass_props.inverse_inertia,
s.rot.ang_vel_body,
torque_fn(s),
);
(k_v, k_a, k_qdot, k_alpha)
};
let step_q = |q_base: [f64; 4], stages: &[([f64; 4], f64)]| -> [f64; 4] {
let mut result = q_base;
for (k, coeff) in stages {
for i in 0..4 {
result[i] += k[i] * coeff;
}
}
result
};
let (k1_v, k1_a, k1_qd, k1_al) = eval_derivs(state, 0.0);
let h1 = A21 * dt_try;
let s2 = make_state(
pos0 + k1_v * h1,
vel0 + k1_a * h1,
step_q(q0, &[(k1_qd, h1)]),
omega0 + k1_al * h1,
);
let (k2_v, k2_a, k2_qd, k2_al) = eval_derivs(&s2, 0.25);
let s3 = make_state(
pos0 + (k1_v * A31 + k2_v * A32) * dt_try,
vel0 + (k1_a * A31 + k2_a * A32) * dt_try,
step_q(q0, &[(k1_qd, A31 * dt_try), (k2_qd, A32 * dt_try)]),
omega0 + (k1_al * A31 + k2_al * A32) * dt_try,
);
let (k3_v, k3_a, k3_qd, k3_al) = eval_derivs(&s3, 3.0 / 8.0);
let s4 = make_state(
pos0 + (k1_v * A41 + k2_v * A42 + k3_v * A43) * dt_try,
vel0 + (k1_a * A41 + k2_a * A42 + k3_a * A43) * dt_try,
step_q(
q0,
&[
(k1_qd, A41 * dt_try),
(k2_qd, A42 * dt_try),
(k3_qd, A43 * dt_try),
],
),
omega0 + (k1_al * A41 + k2_al * A42 + k3_al * A43) * dt_try,
);
let (k4_v, k4_a, k4_qd, k4_al) = eval_derivs(&s4, 12.0 / 13.0);
let s5 = make_state(
pos0 + (k1_v * A51 + k2_v * A52 + k3_v * A53 + k4_v * A54) * dt_try,
vel0 + (k1_a * A51 + k2_a * A52 + k3_a * A53 + k4_a * A54) * dt_try,
step_q(
q0,
&[
(k1_qd, A51 * dt_try),
(k2_qd, A52 * dt_try),
(k3_qd, A53 * dt_try),
(k4_qd, A54 * dt_try),
],
),
omega0 + (k1_al * A51 + k2_al * A52 + k3_al * A53 + k4_al * A54) * dt_try,
);
let (k5_v, k5_a, k5_qd, k5_al) = eval_derivs(&s5, 1.0);
let s6 = make_state(
pos0 + (k1_v * A61 + k2_v * A62 + k3_v * A63 + k4_v * A64 + k5_v * A65) * dt_try,
vel0 + (k1_a * A61 + k2_a * A62 + k3_a * A63 + k4_a * A64 + k5_a * A65) * dt_try,
step_q(
q0,
&[
(k1_qd, A61 * dt_try),
(k2_qd, A62 * dt_try),
(k3_qd, A63 * dt_try),
(k4_qd, A64 * dt_try),
(k5_qd, A65 * dt_try),
],
),
omega0 + (k1_al * A61 + k2_al * A62 + k3_al * A63 + k4_al * A64 + k5_al * A65) * dt_try,
);
let (k6_v, k6_a, k6_qd, k6_al) = eval_derivs(&s6, 0.5);
let pos5 = pos0 + (k1_v * B51 + k3_v * B53 + k4_v * B54 + k5_v * B55 + k6_v * B56) * dt_try;
let vel5 = vel0 + (k1_a * B51 + k3_a * B53 + k4_a * B54 + k5_a * B55 + k6_a * B56) * dt_try;
let omega5 =
omega0 + (k1_al * B51 + k3_al * B53 + k4_al * B54 + k5_al * B55 + k6_al * B56) * dt_try;
let q5 = step_q(
q0,
&[
(k1_qd, B51 * dt_try),
(k3_qd, B53 * dt_try),
(k4_qd, B54 * dt_try),
(k5_qd, B55 * dt_try),
(k6_qd, B56 * dt_try),
],
);
let pos4 = pos0 + (k1_v * B41 + k3_v * B43 + k4_v * B44 + k5_v * B45) * dt_try;
let vel4 = vel0 + (k1_a * B41 + k3_a * B43 + k4_a * B44 + k5_a * B45) * dt_try;
let pos_err_vec = (pos5 - pos4).abs();
let vel_err_vec = (vel5 - vel4).abs();
let pos_error = pos_err_vec.x.max(pos_err_vec.y).max(pos_err_vec.z);
let vel_error = vel_err_vec.x.max(vel_err_vec.y).max(vel_err_vec.z);
let err_ratio = (pos_error / config.pos_tolerance).max(vel_error / config.vel_tolerance);
let within_tolerance = err_ratio <= 1.0;
let forced_accept = !within_tolerance && attempt == config.max_rejections;
if within_tolerance || forced_accept {
let factor = compute_step_factor(err_ratio, config.safety_factor, within_tolerance);
let dt_next = signed_clamp(dt_try * factor, config.min_step, config.max_step);
let mut final_quat = JeodQuat::new(q5[0], q5[1], q5[2], q5[3]);
normalize_integ(&mut final_quat);
return AdaptiveResult {
state: SixDofState {
trans: TranslationalState {
position: pos5,
velocity: vel5,
},
rot: RotationalState {
quaternion: final_quat,
ang_vel_body: omega5,
},
},
dt_used: dt_try,
dt_next,
rejected,
forced_accept,
pos_error,
vel_error,
error_ratio: err_ratio,
};
}
rejected = true;
let factor = compute_step_factor(err_ratio, config.safety_factor, false);
dt_try = signed_clamp(dt_try * factor, config.min_step, config.max_step);
}
unreachable!("loop always returns via attempt == max_rejections")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::integration::rk4_translational_step;
#[test]
fn rkf45_more_accurate_than_rk4() {
let dt: f64 = 0.1;
let steps = 100; let t_final = dt * steps as f64;
let initial = TranslationalState {
position: DVec3::new(1.0, 0.0, 0.0),
velocity: DVec3::ZERO,
};
let accel_fn = |s: &TranslationalState, _t: f64| -> DVec3 { -s.position };
let mut state_rk4 = initial;
let mut state_rkf45 = initial;
for _ in 0..steps {
state_rk4 = rk4_translational_step(&state_rk4, accel_fn, dt);
state_rkf45 = rkf45_translational_step(&state_rkf45, accel_fn, dt);
}
let exact_pos = t_final.cos();
let err_rk4 = (state_rk4.position.x - exact_pos).abs();
let err_rkf45 = (state_rkf45.position.x - exact_pos).abs();
assert!(
err_rkf45 < err_rk4 / 10.0,
"RKF45 error ({err_rkf45:.2e}) should be << RK4 error ({err_rk4:.2e})"
);
}
#[test]
fn rkf45_convergence_order() {
let dt_coarse: f64 = 0.1;
let dt_fine = dt_coarse / 2.0;
let total_time: f64 = 1.0;
let initial = TranslationalState {
position: DVec3::new(1.0, 0.0, 0.0),
velocity: DVec3::ZERO,
};
let accel_fn = |s: &TranslationalState, _t: f64| -> DVec3 { -s.position };
let exact_pos = total_time.cos();
let steps_coarse = (total_time / dt_coarse).round() as usize;
let mut state_coarse = initial;
for _ in 0..steps_coarse {
state_coarse = rkf45_translational_step(&state_coarse, accel_fn, dt_coarse);
}
let err_coarse = (state_coarse.position.x - exact_pos).abs();
let steps_fine = (total_time / dt_fine).round() as usize;
let mut state_fine = initial;
for _ in 0..steps_fine {
state_fine = rkf45_translational_step(&state_fine, accel_fn, dt_fine);
}
let err_fine = (state_fine.position.x - exact_pos).abs();
let ratio = err_coarse / err_fine;
assert!(
(ratio - 32.0).abs() < 8.0,
"Convergence ratio {ratio:.1} not close to 32 (5th order)"
);
}
#[test]
fn rkf45_free_particle() {
let initial_pos = DVec3::new(1.0, 2.0, 3.0);
let initial_vel = DVec3::new(4.0, 5.0, 6.0);
let dt = 0.5;
let mut state = TranslationalState {
position: initial_pos,
velocity: initial_vel,
};
let zero_accel = |_: &TranslationalState, _t: f64| DVec3::ZERO;
for _ in 0..10 {
state = rkf45_translational_step(&state, zero_accel, dt);
}
let expected_pos = initial_pos + initial_vel * 5.0;
let pos_error = (state.position - expected_pos).length();
assert!(pos_error < 1e-12, "Free particle pos error: {pos_error}");
}
fn central_gravity_accel(pos: DVec3, mu: f64) -> DVec3 {
let r = pos.length();
-mu / (r * r * r) * pos
}
#[test]
fn adaptive_accepts_large_step_for_smooth_problem() {
let mu: f64 = 3.986_004_415e14;
let r0: f64 = 6_778_000.0; let v0 = (mu / r0).sqrt();
let initial = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let config = AdaptiveConfig {
pos_tolerance: 1.0, vel_tolerance: 1e-3, safety_factor: 0.9,
min_step: 1.0,
max_step: 120.0,
max_rejections: 10,
};
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let mut state = initial;
let mut t = 0.0;
let mut dt = 10.0;
let mut max_dt_used = 0.0_f64;
let t_end = 5560.0;
while t < t_end {
let result = rkf45_adaptive_translational_step(&state, accel_fn, dt, &config);
state = result.state;
t += result.dt_used;
dt = result.dt_next;
max_dt_used = max_dt_used.max(result.dt_used);
}
assert!(
max_dt_used >= config.max_step * 0.99,
"Expected step size to grow to max_step ({:.0}), but max dt used was {:.1}",
config.max_step,
max_dt_used,
);
}
#[test]
fn adaptive_reduces_step_for_stiff_problem() {
let mu: f64 = 3.986_004_415e14;
let r_peri: f64 = 6_578_000.0; let e: f64 = 0.9;
let _a = r_peri / (1.0 - e);
let v_peri = (mu * (1.0 + e) / r_peri).sqrt();
let initial = TranslationalState {
position: DVec3::new(r_peri, 0.0, 0.0),
velocity: DVec3::new(0.0, v_peri, 0.0),
};
let config = AdaptiveConfig {
pos_tolerance: 1e-3, vel_tolerance: 1e-6, safety_factor: 0.9,
min_step: 0.01,
max_step: 1000.0,
max_rejections: 20,
};
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let mut state = initial;
let mut t: f64 = 0.0;
let mut dt: f64 = 100.0; let mut min_dt_used = f64::MAX;
let t_end: f64 = 500.0;
while t < t_end {
let result = rkf45_adaptive_translational_step(&state, accel_fn, dt, &config);
state = result.state;
t += result.dt_used;
dt = result.dt_next;
min_dt_used = min_dt_used.min(result.dt_used);
}
assert!(
min_dt_used < 50.0,
"Expected step size to shrink near periapsis, but min dt was {:.1} s",
min_dt_used,
);
}
#[test]
fn adaptive_error_bounded() {
let mu: f64 = 3.986_004_415e14;
let r0: f64 = 6_778_000.0;
let v0 = (mu / r0).sqrt();
let initial = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let config = AdaptiveConfig {
pos_tolerance: 0.1, vel_tolerance: 1e-4, safety_factor: 0.9,
min_step: 0.1,
max_step: 60.0,
max_rejections: 10,
};
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let mut state = initial;
let mut t = 0.0;
let mut dt = 10.0;
let mut max_ratio = 0.0_f64;
let mut any_forced = false;
let t_end = 1000.0;
while t < t_end {
let result = rkf45_adaptive_translational_step(&state, accel_fn, dt, &config);
max_ratio = max_ratio.max(result.error_ratio);
any_forced |= result.forced_accept;
state = result.state;
t += result.dt_used;
dt = result.dt_next;
}
assert!(
!any_forced,
"step was forced-accepted despite adequate budget"
);
assert!(
max_ratio <= 1.0,
"Max error ratio {max_ratio:.3} exceeds 1.0 (tolerance violated)"
);
}
#[test]
fn adaptive_consistent_with_fixed_step() {
let mu: f64 = 3.986_004_415e14;
let r0: f64 = 6_778_000.0;
let v0 = (mu / r0).sqrt();
let initial = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let dt: f64 = 10.0;
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let config = AdaptiveConfig {
pos_tolerance: 1.0,
vel_tolerance: 1.0,
safety_factor: 0.9,
min_step: dt,
max_step: dt,
max_rejections: 0,
};
let fixed = rkf45_translational_step(&initial, accel_fn, dt);
let adaptive = rkf45_adaptive_translational_step(&initial, accel_fn, dt, &config);
assert!(!adaptive.rejected, "step should not be rejected");
assert!(
!adaptive.forced_accept,
"step should not be forced-accepted"
);
assert_eq!(adaptive.dt_used, dt);
assert_eq!(
adaptive.state.position, fixed.position,
"adaptive and fixed 5th-order position differ"
);
assert_eq!(
adaptive.state.velocity, fixed.velocity,
"adaptive and fixed 5th-order velocity differ"
);
}
#[test]
fn adaptive_zero_dt_is_noop() {
let mu: f64 = 3.986_004_415e14;
let r0: f64 = 6_778_000.0;
let v0 = (mu / r0).sqrt();
let initial = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let config = AdaptiveConfig::default();
let result = rkf45_adaptive_translational_step(&initial, accel_fn, 0.0, &config);
assert_eq!(result.dt_used, 0.0);
assert_eq!(result.dt_next, 0.0);
assert_eq!(result.state.position, initial.position);
assert_eq!(result.state.velocity, initial.velocity);
assert_eq!(result.error_ratio, 0.0);
assert!(!result.rejected && !result.forced_accept);
}
#[test]
fn adaptive_negative_dt_reverses_time() {
let mu: f64 = 3.986_004_415e14;
let r0: f64 = 6_778_000.0;
let v0 = (mu / r0).sqrt();
let initial = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let config = AdaptiveConfig {
pos_tolerance: 1.0,
vel_tolerance: 1.0,
safety_factor: 0.9,
min_step: 1.0,
max_step: 1.0,
max_rejections: 0,
};
let forward = rkf45_adaptive_translational_step(&initial, accel_fn, 1.0, &config);
assert!(forward.dt_used > 0.0, "forward dt should be positive");
let back = rkf45_adaptive_translational_step(&forward.state, accel_fn, -1.0, &config);
assert!(back.dt_used < 0.0, "reverse dt should be negative");
let pos_err = (back.state.position - initial.position).length();
let vel_err = (back.state.velocity - initial.velocity).length();
assert!(pos_err < 1e-3, "forward+reverse pos err: {pos_err}");
assert!(vel_err < 1e-6, "forward+reverse vel err: {vel_err}");
}
#[test]
fn adaptive_matches_fixed_for_small_tolerance() {
let mu: f64 = 3.986_004_415e14;
let r0: f64 = 6_778_000.0;
let v0 = (mu / r0).sqrt();
let initial = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let dt_fixed: f64 = 1.0;
let t_end: f64 = 100.0;
let steps = (t_end / dt_fixed).round() as usize;
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let mut state_fixed = initial;
for _ in 0..steps {
state_fixed = rkf45_translational_step(&state_fixed, accel_fn, dt_fixed);
}
let config = AdaptiveConfig {
pos_tolerance: 1e-6,
vel_tolerance: 1e-9,
safety_factor: 0.9,
min_step: 1e-12,
max_step: 1.0, max_rejections: 10,
};
let mut state_adaptive = initial;
let mut t: f64 = 0.0;
let mut dt: f64 = 1.0;
while t < t_end - 1e-10 {
let remaining = t_end - t;
let dt_try = dt.min(remaining);
let result =
rkf45_adaptive_translational_step(&state_adaptive, accel_fn, dt_try, &config);
state_adaptive = result.state;
t += result.dt_used;
dt = result.dt_next;
}
assert!(
(t - t_end).abs() < 1e-9,
"adaptive loop did not land on t_end: t = {t}, t_end = {t_end}"
);
let pos_diff = (state_adaptive.position - state_fixed.position).length();
assert!(
pos_diff < 10.0,
"Adaptive vs fixed position difference: {:.2e} m (expected < 10 m)",
pos_diff,
);
}
#[test]
fn adaptive_config_validation() {
let ok = AdaptiveConfig::default();
assert!(ok.check().is_ok());
let bad = AdaptiveConfig {
pos_tolerance: -1.0,
..AdaptiveConfig::default()
};
assert!(bad.check().is_err());
let bad = AdaptiveConfig {
safety_factor: 1.5,
..AdaptiveConfig::default()
};
assert!(bad.check().is_err());
let bad = AdaptiveConfig {
min_step: 10.0,
max_step: 5.0,
..AdaptiveConfig::default()
};
assert!(bad.check().is_err());
}
#[test]
fn fixed_step_unchanged() {
let mu: f64 = 3.986_004_415e14;
let r0: f64 = 6_778_000.0;
let v0 = (mu / r0).sqrt();
let initial = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let dt = 10.0;
let accel_fn =
|s: &TranslationalState, _c_i: f64| -> DVec3 { central_gravity_accel(s.position, mu) };
let result = rkf45_translational_step(&initial, accel_fn, dt);
let r = result.position.length();
assert!(
(r - r0).abs() < 1.0,
"Radius should be near r0 after one step, got delta = {:.6} m",
(r - r0).abs(),
);
let v = result.velocity.length();
assert!(
(v - v0).abs() < 0.01,
"Speed should be near v0 after one step, got delta = {:.6} m/s",
(v - v0).abs(),
);
}
}