use approx::assert_abs_diff_eq;
use scirs2_core::ndarray::{Array1, ArrayView1};
use scirs2_integrate::{
solve_ivp, solve_ivp_with_events, terminal_event, EventAction, EventDirection, ODEMethod,
ODEOptions, ODEOptionsWithEvents, ODEResultWithEvents, SymplecticMethod,
SymplecticSeparableSystem, SymplecticStepper,
};
use std::f64::consts::PI;
#[test]
fn test_rk45_exponential_decay_vs_analytical() {
let k = 2.0_f64;
let y0 = 3.0_f64;
let f = move |_t: f64, y: ArrayView1<f64>| -> Array1<f64> { Array1::from_vec(vec![-k * y[0]]) };
let opts = ODEOptions {
method: ODEMethod::RK45,
rtol: 1e-8,
atol: 1e-10,
..Default::default()
};
let result = solve_ivp(f, [0.0_f64, 2.0], Array1::from_vec(vec![y0]), Some(opts))
.expect("RK45 exponential decay solve failed");
assert!(result.success, "RK45 solve did not report success");
let t_final = *result.t.last().expect("empty t in result");
let y_final = result.y.last().expect("empty y in result")[0];
let y_analytical = y0 * (-k * t_final).exp();
assert_abs_diff_eq!(y_final, y_analytical, epsilon = 1e-5);
}
#[test]
fn test_rk45_harmonic_oscillator_accuracy() {
let omega = 2.0_f64;
let y0 = 1.0_f64;
let dy0 = 0.0_f64;
let f = move |_t: f64, y: ArrayView1<f64>| -> Array1<f64> {
Array1::from_vec(vec![y[1], -omega * omega * y[0]])
};
let opts = ODEOptions {
method: ODEMethod::RK45,
rtol: 1e-10,
atol: 1e-12,
..Default::default()
};
let t_end = 2.0 * PI / omega; let result = solve_ivp(
f,
[0.0_f64, t_end],
Array1::from_vec(vec![y0, dy0]),
Some(opts),
)
.expect("RK45 harmonic oscillator solve failed");
assert!(result.success, "Harmonic oscillator solve did not succeed");
let y_end = &result
.y
.last()
.expect("empty y in harmonic oscillator result");
assert_abs_diff_eq!(y_end[0], y0, epsilon = 1e-6);
assert_abs_diff_eq!(y_end[1], dy0, epsilon = 1e-6);
}
#[test]
fn test_bdf_stiff_ode_versus_rk45() {
let f = |_t: f64, y: ArrayView1<f64>| -> Array1<f64> { Array1::from_vec(vec![-10.0 * y[0]]) };
let opts_bdf = ODEOptions {
method: ODEMethod::Bdf,
rtol: 1e-4,
atol: 1e-6,
max_steps: 50_000,
..Default::default()
};
let opts_rk = ODEOptions {
method: ODEMethod::RK45,
rtol: 1e-8,
atol: 1e-10,
..Default::default()
};
let t_end = 1.0_f64;
let y0 = Array1::from_vec(vec![1.0_f64]);
let result_bdf =
solve_ivp(f, [0.0_f64, t_end], y0.clone(), Some(opts_bdf)).expect("BDF solve failed");
let result_rk = solve_ivp(f, [0.0_f64, t_end], y0, Some(opts_rk)).expect("RK45 solve failed");
assert!(result_bdf.success, "BDF did not converge");
assert!(result_rk.success, "RK45 reference did not converge");
let y_bdf = result_bdf.y.last().expect("empty y BDF")[0];
let y_rk = result_rk.y.last().expect("empty y RK")[0];
let y_exact = (-10.0_f64 * t_end).exp();
assert_abs_diff_eq!(y_bdf, y_exact, epsilon = 1e-2);
assert_abs_diff_eq!(y_rk, y_exact, epsilon = 1e-5);
}
#[test]
fn test_dop853_lorenz_system_stability() {
let sigma = 10.0_f64;
let rho = 28.0_f64;
let beta = 8.0_f64 / 3.0;
let f = move |_t: f64, y: ArrayView1<f64>| -> Array1<f64> {
let x = y[0];
let yy = y[1];
let z = y[2];
Array1::from_vec(vec![
sigma * (yy - x),
x * (rho - z) - yy,
x * yy - beta * z,
])
};
let opts = ODEOptions {
method: ODEMethod::DOP853,
rtol: 1e-9,
atol: 1e-11,
max_steps: 200_000,
..Default::default()
};
let y0 = Array1::from_vec(vec![1.0_f64, 1.0, 1.0]);
let result = solve_ivp(f, [0.0_f64, 2.0], y0, Some(opts)).expect("DOP853 Lorenz solve failed");
assert!(result.success, "DOP853 Lorenz did not succeed");
assert!(!result.y.is_empty(), "Lorenz result is empty");
for state in &result.y {
for &val in state.iter() {
assert!(
val.is_finite() && val.abs() < 200.0,
"Lorenz state blew up: {val}"
);
}
}
}
#[test]
fn test_event_detection_zero_crossing_sine() {
let g = 9.81_f64;
let f =
move |_t: f64, _y: ArrayView1<f64>| -> Array1<f64> { Array1::from_vec(vec![_y[1], -g]) };
let event_fn = |_t: f64, y: ArrayView1<f64>| -> f64 { y[0] };
let base_opts = ODEOptions {
method: ODEMethod::RK45,
rtol: 1e-10,
atol: 1e-12,
dense_output: true,
..Default::default()
};
let event_spec = terminal_event::<f64>("ground_hit", EventDirection::Falling);
let opts_with_events = ODEOptionsWithEvents {
base_options: base_opts,
event_specs: vec![event_spec],
};
let y0 = Array1::from_vec(vec![10.0_f64, 0.0]);
let result: ODEResultWithEvents<f64> =
solve_ivp_with_events(f, [0.0_f64, 5.0], y0, vec![event_fn], opts_with_events)
.expect("Event detection solve failed");
let t_final = *result.base_result.t.last().expect("empty t");
let t_analytical = (2.0 * 10.0 / g).sqrt();
assert_abs_diff_eq!(t_final, t_analytical, epsilon = 1e-4);
}
#[test]
fn test_euler_method_first_order_accuracy() {
let f = |_t: f64, y: ArrayView1<f64>| -> Array1<f64> { y.to_owned() };
let t_end = 1.0_f64;
let h0 = 0.01_f64;
let opts = ODEOptions {
method: ODEMethod::Euler,
h0: Some(h0),
rtol: 1.0, atol: 1.0,
..Default::default()
};
let result = solve_ivp(
f,
[0.0_f64, t_end],
Array1::from_vec(vec![1.0_f64]),
Some(opts),
)
.expect("Euler solve failed");
let y_numerical = result.y.last().expect("empty y Euler")[0];
let y_exact = t_end.exp();
let expected_error = h0 * t_end * y_exact;
assert!(
(y_numerical - y_exact).abs() < 2.0 * expected_error,
"Euler error {:.2e} larger than expected O(h) bound {:.2e}",
(y_numerical - y_exact).abs(),
expected_error
);
}
#[test]
fn test_symplectic_stormer_verlet_energy_conservation() {
let omega = 1.0_f64;
let kinetic_grad = move |_t: f64, p: &Array1<f64>| -> Array1<f64> {
p.clone() };
let potential_grad = move |_t: f64, q: &Array1<f64>| -> Array1<f64> {
q.mapv(|qi| omega * omega * qi) };
let system = SymplecticSeparableSystem::new(1, kinetic_grad, potential_grad).with_energy(
|_t: f64, p: &Array1<f64>| p[0] * p[0] / 2.0,
move |_t: f64, q: &Array1<f64>| omega * omega * q[0] * q[0] / 2.0,
);
let q0 = Array1::from_vec(vec![1.0_f64]); let p0 = Array1::from_vec(vec![0.0_f64]); let e0 = 0.5_f64;
let stepper =
scirs2_integrate::create_symplectic_stepper::<f64>(SymplecticMethod::StormerVerlet);
let t_end = 10.0 * 2.0 * PI; let dt = 0.01_f64;
let result =
scirs2_integrate::solve_hamiltonian(&system, &*stepper, 0.0_f64, t_end, dt, q0, p0)
.expect("Symplectic SHO integration failed");
assert!(!result.t.is_empty(), "Symplectic result is empty");
if let Some(monitor) = &result.energy_monitor {
let energies = &monitor.energy_history;
for &e in energies {
assert_abs_diff_eq!(e, e0, epsilon = 1e-3);
}
} else {
let q_end = result.q.last().expect("empty q");
let p_end = result.p.last().expect("empty p");
let e_end = p_end[0] * p_end[0] / 2.0 + omega * omega * q_end[0] * q_end[0] / 2.0;
assert_abs_diff_eq!(e_end, e0, epsilon = 1e-3);
}
}
#[test]
fn test_yoshida4_kepler_orbit_energy_conservation() {
let kinetic_grad = |_t: f64, p: &Array1<f64>| -> Array1<f64> {
p.clone() };
let potential_grad = |_t: f64, q: &Array1<f64>| -> Array1<f64> {
let r3 = (q[0] * q[0] + q[1] * q[1]).powf(1.5);
if r3 < 1e-10 {
Array1::zeros(2)
} else {
Array1::from_vec(vec![q[0] / r3, q[1] / r3])
}
};
let system = SymplecticSeparableSystem::new(2, kinetic_grad, potential_grad).with_energy(
|_t: f64, p: &Array1<f64>| (p[0] * p[0] + p[1] * p[1]) / 2.0,
|_t: f64, q: &Array1<f64>| {
let r = (q[0] * q[0] + q[1] * q[1]).sqrt();
if r < 1e-10 {
f64::INFINITY
} else {
-1.0 / r
}
},
);
let q0 = Array1::from_vec(vec![1.0_f64, 0.0]);
let p0 = Array1::from_vec(vec![0.0_f64, 1.0]);
let e0 = -0.5_f64;
let stepper = scirs2_integrate::create_symplectic_stepper::<f64>(SymplecticMethod::Yoshida4);
let t_end = 2.0 * PI; let dt = 0.01_f64;
let result =
scirs2_integrate::solve_hamiltonian(&system, &*stepper, 0.0_f64, t_end, dt, q0, p0)
.expect("Yoshida4 Kepler integration failed");
let q_end = result.q.last().expect("empty q");
assert_abs_diff_eq!(q_end[0], 1.0, epsilon = 0.01);
assert_abs_diff_eq!(q_end[1], 0.0, epsilon = 0.01);
if let Some(monitor) = &result.energy_monitor {
for &e in &monitor.energy_history {
assert_abs_diff_eq!(e, e0, epsilon = 0.01);
}
}
}
#[test]
fn test_rk4_vs_rk45_smooth_ode_agreement() {
let f = |t: f64, _y: ArrayView1<f64>| -> Array1<f64> { Array1::from_vec(vec![t.cos()]) };
let t_end = PI;
let opts_rk4 = ODEOptions {
method: ODEMethod::RK4,
h0: Some(0.01),
..Default::default()
};
let opts_rk45 = ODEOptions {
method: ODEMethod::RK45,
rtol: 1e-10,
atol: 1e-12,
..Default::default()
};
let y0 = Array1::from_vec(vec![0.0_f64]);
let res_rk4 =
solve_ivp(f, [0.0_f64, t_end], y0.clone(), Some(opts_rk4)).expect("RK4 solve failed");
let res_rk45 =
solve_ivp(f, [0.0_f64, t_end], y0, Some(opts_rk45)).expect("RK45 smooth ODE solve failed");
let y_rk4 = res_rk4.y.last().expect("empty y RK4")[0];
let y_rk45 = res_rk45.y.last().expect("empty y RK45")[0];
let y_exact = t_end.sin();
assert_abs_diff_eq!(y_rk4, y_exact, epsilon = 1e-4);
assert_abs_diff_eq!(y_rk45, y_exact, epsilon = 1e-8);
assert_abs_diff_eq!(y_rk4, y_rk45, epsilon = 1e-4);
}
#[test]
fn test_rk45_coupled_oscillators() {
let omega1 = 1.0_f64;
let omega2 = 1.5_f64;
let kc = 0.1_f64;
let f = move |_t: f64, s: ArrayView1<f64>| -> Array1<f64> {
let x = s[0];
let xp = s[1];
let y = s[2];
let yp = s[3];
Array1::from_vec(vec![
xp,
-omega1 * omega1 * x + kc * (y - x),
yp,
-omega2 * omega2 * y + kc * (x - y),
])
};
let opts = ODEOptions {
method: ODEMethod::RK45,
rtol: 1e-8,
atol: 1e-10,
max_steps: 100_000,
..Default::default()
};
let y0 = Array1::from_vec(vec![1.0_f64, 0.0, 0.0, 0.0]);
let result =
solve_ivp(f, [0.0_f64, 10.0], y0, Some(opts)).expect("Coupled oscillator solve failed");
assert!(result.success, "Coupled oscillator solve did not succeed");
for state in &result.y {
let x = state[0];
let xp = state[1];
let y = state[2];
let yp = state[3];
let e = 0.5 * (xp * xp + omega1 * omega1 * x * x + yp * yp + omega2 * omega2 * y * y);
assert!(
e < 2.0,
"Coupled oscillator energy blew up to {e} at t={}",
result.t[result.y.iter().position(|s| s[0] == state[0]).unwrap_or(0)]
);
}
}