pub fn rk4_step<F>(f: &F, t: f64, y: &[f64], h: f64) -> Vec<f64>
where
F: Fn(f64, &[f64]) -> Vec<f64>,
{
let n = y.len();
let k1 = f(t, y);
let y2: Vec<f64> = (0..n).map(|i| y[i] + 0.5 * h * k1[i]).collect();
let k2 = f(t + 0.5 * h, &y2);
let y3: Vec<f64> = (0..n).map(|i| y[i] + 0.5 * h * k2[i]).collect();
let k3 = f(t + 0.5 * h, &y3);
let y4: Vec<f64> = (0..n).map(|i| y[i] + h * k3[i]).collect();
let k4 = f(t + h, &y4);
(0..n)
.map(|i| y[i] + (h / 6.0) * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]))
.collect()
}
#[derive(Clone, Copy, Debug)]
pub struct Tolerance {
pub rtol: f64,
pub atol: f64,
pub h_min: f64,
pub h_max: f64,
}
impl Default for Tolerance {
fn default() -> Self {
Self {
rtol: 1e-9,
atol: 1e-12,
h_min: 1e-12,
h_max: f64::INFINITY,
}
}
}
pub fn step_doubling<F>(f: &F, t: f64, y: &[f64], h: f64, tol: &Tolerance) -> (Vec<f64>, f64, f64)
where
F: Fn(f64, &[f64]) -> Vec<f64>,
{
let big = rk4_step(f, t, y, h);
let half = rk4_step(f, t, y, 0.5 * h);
let two_half = rk4_step(f, t + 0.5 * h, &half, 0.5 * h);
let mut err = 0.0_f64;
for i in 0..y.len() {
let diff = (two_half[i] - big[i]) / 15.0;
let scale = tol.atol + tol.rtol * two_half[i].abs().max(y[i].abs());
let r = diff / scale;
err += r * r;
}
err = (err / y.len() as f64).sqrt();
let factor = if err > 0.0 { 0.9 * err.powf(-0.2) } else { 5.0 };
let h_next = (h * factor.clamp(0.2, 5.0)).clamp(tol.h_min, tol.h_max);
(two_half, err, h_next)
}
#[derive(Clone, Debug)]
pub struct Solution {
pub t: f64,
pub y: Vec<f64>,
pub accepted: usize,
pub rejected: usize,
}
pub fn integrate<F>(f: &F, t0: f64, y0: &[f64], t_end: f64, h0: f64, tol: &Tolerance) -> Solution
where
F: Fn(f64, &[f64]) -> Vec<f64>,
{
let mut t = t0;
let mut y = y0.to_vec();
let mut h = h0.max(tol.h_min).min(tol.h_max);
let (mut accepted, mut rejected) = (0, 0);
while t < t_end {
if t + h > t_end {
h = t_end - t;
}
let (y_next, err, h_next) = step_doubling(f, t, &y, h, tol);
if err <= 1.0 || h <= tol.h_min {
t += h;
y = y_next;
accepted += 1;
h = h_next;
} else {
rejected += 1;
h = h_next;
}
}
Solution {
t,
y,
accepted,
rejected,
}
}
pub fn dopri54_step<F>(f: &F, t: f64, y: &[f64], h: f64, tol: &Tolerance) -> (Vec<f64>, f64, f64)
where
F: Fn(f64, &[f64]) -> Vec<f64>,
{
let n = y.len();
let k1 = f(t, y);
let y2: Vec<f64> = (0..n).map(|i| y[i] + h * (k1[i] / 5.0)).collect();
let k2 = f(t + h / 5.0, &y2);
let y3: Vec<f64> = (0..n)
.map(|i| y[i] + h * (3.0 / 40.0 * k1[i] + 9.0 / 40.0 * k2[i]))
.collect();
let k3 = f(t + 3.0 * h / 10.0, &y3);
let y4: Vec<f64> = (0..n)
.map(|i| y[i] + h * (44.0 / 45.0 * k1[i] - 56.0 / 15.0 * k2[i] + 32.0 / 9.0 * k3[i]))
.collect();
let k4 = f(t + 4.0 * h / 5.0, &y4);
let y5: Vec<f64> = (0..n)
.map(|i| {
y[i] + h
* (19372.0 / 6561.0 * k1[i] - 25360.0 / 2187.0 * k2[i] + 64448.0 / 6561.0 * k3[i]
- 212.0 / 729.0 * k4[i])
})
.collect();
let k5 = f(t + 8.0 * h / 9.0, &y5);
let y6: Vec<f64> = (0..n)
.map(|i| {
y[i] + h
* (9017.0 / 3168.0 * k1[i] - 355.0 / 33.0 * k2[i]
+ 46732.0 / 5247.0 * k3[i]
+ 49.0 / 176.0 * k4[i]
- 5103.0 / 18656.0 * k5[i])
})
.collect();
let k6 = f(t + h, &y6);
let y_next: Vec<f64> = (0..n)
.map(|i| {
y[i] + h
* (35.0 / 384.0 * k1[i] + 500.0 / 1113.0 * k3[i] + 125.0 / 192.0 * k4[i]
- 2187.0 / 6784.0 * k5[i]
+ 11.0 / 84.0 * k6[i])
})
.collect();
let k7 = f(t + h, &y_next);
let mut err = 0.0_f64;
for i in 0..n {
let ei = h
* (71.0 / 57600.0 * k1[i] - 71.0 / 16695.0 * k3[i] + 71.0 / 1920.0 * k4[i]
- 17253.0 / 339200.0 * k5[i]
+ 22.0 / 525.0 * k6[i]
- 1.0 / 40.0 * k7[i]);
let scale = tol.atol + tol.rtol * y_next[i].abs().max(y[i].abs());
let r = ei / scale;
err += r * r;
}
err = (err / n as f64).sqrt();
let factor = if err > 0.0 { 0.9 * err.powf(-0.2) } else { 5.0 };
let h_next = (h * factor.clamp(0.2, 5.0)).clamp(tol.h_min, tol.h_max);
(y_next, err, h_next)
}
pub fn integrate_dopri<F>(
f: &F,
t0: f64,
y0: &[f64],
t_end: f64,
h0: f64,
tol: &Tolerance,
) -> Solution
where
F: Fn(f64, &[f64]) -> Vec<f64>,
{
let mut t = t0;
let mut y = y0.to_vec();
let mut h = h0.max(tol.h_min).min(tol.h_max);
let (mut accepted, mut rejected) = (0, 0);
while t < t_end {
if t + h > t_end {
h = t_end - t;
}
let (y_next, err, h_next) = dopri54_step(f, t, &y, h, tol);
if err <= 1.0 || h <= tol.h_min {
t += h;
y = y_next;
accepted += 1;
h = h_next;
} else {
rejected += 1;
h = h_next;
}
}
Solution {
t,
y,
accepted,
rejected,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rk4_integrates_exponential_growth() {
let f = |_t: f64, y: &[f64]| vec![y[0]];
let mut y = vec![1.0];
let h = 0.001;
let mut t = 0.0;
while t < 1.0 - 1e-9 {
y = rk4_step(&f, t, &y, h);
t += h;
}
assert!((y[0] - std::f64::consts::E).abs() < 1e-9, "y(1) = {}", y[0]);
}
#[test]
fn rk4_is_fourth_order() {
let f = |_t: f64, y: &[f64]| vec![y[0]];
let err_at = |h: f64| {
let mut y = vec![1.0];
let mut t = 0.0;
let steps = (1.0 / h).round() as usize;
for _ in 0..steps {
y = rk4_step(&f, t, &y, h);
t += h;
}
(y[0] - std::f64::consts::E).abs()
};
let ratio = err_at(0.05) / err_at(0.025);
assert!((12.0..20.0).contains(&ratio), "4th-order ratio = {ratio}");
}
#[test]
fn harmonic_oscillator_conserves_energy() {
let f = |_t: f64, y: &[f64]| vec![y[1], -y[0]];
let mut y = vec![1.0, 0.0]; let n_steps = 10_000usize;
let h = std::f64::consts::TAU / n_steps as f64;
let mut t = 0.0;
for _ in 0..n_steps {
y = rk4_step(&f, t, &y, h);
t += h;
}
let energy = y[0] * y[0] + y[1] * y[1];
assert!((energy - 1.0).abs() < 1e-8, "energy drift {energy}");
assert!(
(y[0] - 1.0).abs() < 1e-6 && y[1].abs() < 1e-6,
"state {:?}",
y
);
}
#[test]
fn adaptive_integration_meets_tolerance_with_variable_steps() {
let f = |_t: f64, y: &[f64]| vec![y[0]];
let tol = Tolerance {
rtol: 1e-10,
atol: 1e-12,
..Tolerance::default()
};
let sol = integrate(&f, 0.0, &[1.0], 1.0, 0.1, &tol);
assert!((sol.t - 1.0).abs() < 1e-12);
assert!(
(sol.y[0] - std::f64::consts::E).abs() < 1e-8,
"y = {}",
sol.y[0]
);
assert!(
sol.accepted >= 1,
"should take real steps: {}",
sol.accepted
);
}
#[test]
fn adaptive_takes_larger_steps_on_an_easy_problem() {
let easy = integrate(
&|_t, _y| vec![0.01],
0.0,
&[0.0],
10.0,
0.01,
&Tolerance::default(),
);
let hard = integrate(
&|_t, y: &[f64]| vec![y[0]],
0.0,
&[1.0],
10.0,
0.01,
&Tolerance::default(),
);
assert!(
easy.accepted < hard.accepted,
"easy {} should need fewer steps than hard {}",
easy.accepted,
hard.accepted
);
}
#[test]
fn step_doubling_error_estimate_shrinks_with_step() {
let f = |_t: f64, y: &[f64]| vec![y[0]];
let tol = Tolerance::default();
let (_, e1, _) = step_doubling(&f, 0.0, &[1.0], 0.2, &tol);
let (_, e2, _) = step_doubling(&f, 0.0, &[1.0], 0.1, &tol);
assert!(
e2 < e1,
"smaller step should have smaller error: {e2} !< {e1}"
);
}
#[test]
fn dopri54_adaptive_meets_a_tight_tolerance_on_exponential_growth() {
let f = |_t: f64, y: &[f64]| vec![y[0]];
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-14,
..Tolerance::default()
};
let sol = integrate_dopri(&f, 0.0, &[1.0], 1.0, 0.1, &tol);
assert!((sol.t - 1.0).abs() < 1e-12);
assert!(
(sol.y[0] - std::f64::consts::E).abs() < 1e-9,
"DP5(4) y(1) = {} vs e",
sol.y[0]
);
assert!(
sol.accepted >= 1,
"should take real steps: {}",
sol.accepted
);
}
#[test]
fn dopri54_embedded_error_estimate_is_fifth_order() {
let f = |_t: f64, y: &[f64]| vec![y[0]];
let tol = Tolerance::default();
let (_, e1, _) = dopri54_step(&f, 0.0, &[1.0], 0.2, &tol);
let (_, e2, _) = dopri54_step(&f, 0.0, &[1.0], 0.1, &tol);
let ratio = e1 / e2;
assert!(
(20.0..=50.0).contains(&ratio),
"DP5(4) error should scale ~h⁵ (ratio ~32): {ratio}"
);
}
#[test]
fn dopri54_harmonic_oscillator_conserves_energy_over_many_periods() {
let f = |_t: f64, y: &[f64]| vec![y[1], -y[0]];
let tol = Tolerance {
rtol: 1e-11,
atol: 1e-13,
..Tolerance::default()
};
let t_end = 50.0 * std::f64::consts::TAU;
let sol = integrate_dopri(&f, 0.0, &[1.0, 0.0], t_end, 0.1, &tol);
let energy = sol.y[0] * sol.y[0] + sol.y[1] * sol.y[1];
assert!((energy - 1.0).abs() < 1e-6, "energy drift {energy}");
assert!(
(sol.y[0] - 1.0).abs() < 1e-4 && sol.y[1].abs() < 1e-4,
"state should return to start: {:?}",
sol.y
);
}
#[test]
fn dopri54_is_cheaper_than_step_doubling_at_the_same_tolerance() {
let f = |_t: f64, y: &[f64]| vec![y[0]];
let tol = Tolerance {
rtol: 1e-10,
atol: 1e-12,
..Tolerance::default()
};
let sd = integrate(&f, 0.0, &[1.0], 10.0, 0.1, &tol);
let dp = integrate_dopri(&f, 0.0, &[1.0], 10.0, 0.1, &tol);
let sd_evals = (sd.accepted + sd.rejected) * 11;
let dp_evals = (dp.accepted + dp.rejected) * 7;
assert!(
dp_evals < sd_evals,
"DP5(4) {dp_evals} evals should beat step-doubling {sd_evals}"
);
for sol in [&sd, &dp] {
assert!(
(sol.y[0] - 10.0_f64.exp()).abs() / 10.0_f64.exp() < 1e-7,
"y(10) = {} vs e¹⁰",
sol.y[0]
);
}
}
}