Skip to content

Classic Problems

This section presents the standard test problems used throughout the ODE literature. Each example includes the mathematical formulation, a working Numra code example, expected behavior, and guidance on which solver to use.

The simplest ODE with a known analytical solution.

y=y,y(0)=1,solution: y(t)=ety' = -y, \quad y(0) = 1, \quad \text{solution: } y(t) = e^{-t}
use numra::ode::{DoPri5, Solver, OdeProblem, SolverOptions};
let problem = OdeProblem::new(
|_t, y: &[f64], dydt: &mut [f64]| {
dydt[0] = -y[0];
},
0.0, 5.0,
vec![1.0],
);
let options = SolverOptions::default().rtol(1e-8).atol(1e-10);
let result = DoPri5::solve(&problem, 0.0, 5.0, &[1.0], &options).unwrap();
let y_final = result.y_final().unwrap();
let exact = (-5.0_f64).exp();
let error = (y_final[0] - exact).abs();
println!("Numerical: {:.10}", y_final[0]);
println!("Exact: {:.10}", exact);
println!("Error: {:.2e}", error);
// Error should be well below 1e-8

Solver: Any explicit method works. DoPri5 or Tsit5 are natural choices. This problem is non-stiff so implicit methods would be overkill.

Purpose: Verifying basic solver correctness and convergence order.

A 2D system with periodic solutions and an energy invariant.

y0=y1,y1=y0y_0' = y_1, \quad y_1' = -y_0

The solution is y0(t)=cos(t)y_0(t) = \cos(t), y1(t)=sin(t)y_1(t) = -\sin(t) for initial conditions y(0)=[1,0]y(0) = [1, 0]. The energy E=y02+y12E = y_0^2 + y_1^2 is conserved (always equals 1).

use numra::ode::{DoPri5, Solver, OdeProblem, SolverOptions};
let problem = OdeProblem::new(
|_t, y: &[f64], dydt: &mut [f64]| {
dydt[0] = y[1];
dydt[1] = -y[0];
},
0.0, 100.0,
vec![1.0, 0.0],
);
let options = SolverOptions::default().rtol(1e-10).atol(1e-12);
let result = DoPri5::solve(&problem, 0.0, 100.0, &[1.0, 0.0], &options).unwrap();
// Check energy conservation across the trajectory
let mut max_energy_error = 0.0_f64;
for (_t, y) in result.iter() {
let energy = y[0] * y[0] + y[1] * y[1];
let err = (energy - 1.0).abs();
max_energy_error = max_energy_error.max(err);
}
let y_final = result.y_final().unwrap();
println!("y(100) = [{:.10}, {:.10}]", y_final[0], y_final[1]);
println!("Exact = [{:.10}, {:.10}]", 100.0_f64.cos(), -100.0_f64.sin());
println!("Max energy drift: {:.2e}", max_energy_error);

Solver: DoPri5 or Vern6 for moderate to high accuracy. Energy drift grows slowly over long integrations due to dissipation in non-symplectic methods. For very long integrations, tighter tolerances reduce the drift.

Purpose: Testing phase accuracy and long-term stability. Energy drift is a sensitive indicator of integration quality.

A nonlinear 2D system with periodic orbits in phase space.

dxdt=αxβxy,dydt=δxyγy\frac{dx}{dt} = \alpha x - \beta x y, \qquad \frac{dy}{dt} = \delta x y - \gamma y

The system has a conserved quantity V=δxγlnx+βyαlnyV = \delta x - \gamma \ln x + \beta y - \alpha \ln y, so orbits are closed curves in the (x,y)(x, y) plane.

use numra::ode::{Tsit5, Solver, OdeProblem, SolverOptions};
let alpha = 1.5;
let beta = 1.0;
let delta = 1.0;
let gamma = 3.0;
let problem = OdeProblem::new(
move |_t, y: &[f64], dydt: &mut [f64]| {
dydt[0] = alpha * y[0] - beta * y[0] * y[1];
dydt[1] = delta * y[0] * y[1] - gamma * y[1];
},
0.0, 30.0,
vec![10.0, 1.0],
);
let options = SolverOptions::default().rtol(1e-8).atol(1e-10);
let result = Tsit5::solve(&problem, 0.0, 30.0, &[10.0, 1.0], &options).unwrap();
// Check that the conserved quantity is maintained
let v = |x: f64, y: f64| -> f64 {
delta * x - gamma * x.ln() + beta * y - alpha * y.ln()
};
let v0 = v(10.0, 1.0);
let mut max_v_error = 0.0_f64;
for (_t, y) in result.iter() {
let err = (v(y[0], y[1]) - v0).abs();
max_v_error = max_v_error.max(err);
}
let y_final = result.y_final().unwrap();
println!("Final: prey = {:.4}, predator = {:.4}", y_final[0], y_final[1]);
println!("Max invariant drift: {:.2e}", max_v_error);
println!("Steps: {}", result.stats.n_accept);

Solver: Tsit5 or DoPri5. Non-stiff, moderately nonlinear. Populations oscillate periodically — prey booms lead to predator booms, which crash the prey, which crashes the predators, and the cycle repeats.

Purpose: Testing conservation of nonlinear invariants and handling of oscillatory solutions.

The iconic chaotic system. Three coupled nonlinear ODEs with sensitive dependence on initial conditions.

x˙=σ(yx),y˙=x(ρz)y,z˙=xyβz\dot{x} = \sigma(y - x), \quad \dot{y} = x(\rho - z) - y, \quad \dot{z} = xy - \beta z

With the standard parameters σ=10\sigma = 10, ρ=28\rho = 28, β=8/3\beta = 8/3, the system is chaotic: nearby trajectories diverge exponentially. The Lyapunov exponent is about 0.9, meaning trajectories diverge by a factor of ee roughly every 1.1 time units.

use numra::ode::{DoPri5, Solver, OdeProblem, SolverOptions};
let sigma = 10.0;
let rho = 28.0;
let beta = 8.0 / 3.0;
let problem = OdeProblem::new(
move |_t, y: &[f64], dydt: &mut [f64]| {
dydt[0] = sigma * (y[1] - y[0]);
dydt[1] = y[0] * (rho - y[2]) - y[1];
dydt[2] = y[0] * y[1] - beta * y[2];
},
0.0, 20.0,
vec![1.0, 1.0, 1.0],
);
let options = SolverOptions::default().rtol(1e-10).atol(1e-12);
let result = DoPri5::solve(
&problem, 0.0, 20.0, &[1.0, 1.0, 1.0], &options
).unwrap();
let y_final = result.y_final().unwrap();
println!("Final state: [{:.6}, {:.6}, {:.6}]", y_final[0], y_final[1], y_final[2]);
println!("Steps: {}, Fn calls: {}", result.stats.n_accept, result.stats.n_eval);

Sensitivity to initial conditions: perturb the initial state by 101010^{-10} and the solutions diverge completely after a few Lyapunov times. This is not a solver bug — it is the nature of chaos.

// Perturbed initial conditions
let y0_perturbed = vec![1.0 + 1e-10, 1.0, 1.0];
let result2 = DoPri5::solve(
&problem, 0.0, 20.0, &y0_perturbed, &options
).unwrap();
let y1 = result.y_final().unwrap();
let y2 = result2.y_final().unwrap();
let diff = ((y1[0]-y2[0]).powi(2) + (y1[1]-y2[1]).powi(2) + (y1[2]-y2[2]).powi(2)).sqrt();
println!("Divergence after t=20: {:.6}", diff); // will be O(1) or larger

Solver: DoPri5 with tight tolerances. The Lorenz system is not stiff, but high accuracy is important to delay the onset of trajectory divergence. Vern8 can push the “shadow trajectory” further.

Purpose: Testing adaptive stepping in chaotic regimes, where the solver must take small steps during rapid state changes near the attractor’s lobes.

A nonlinear oscillator that transitions from non-stiff to extremely stiff as the parameter μ\mu increases.

y0=y1,y1=μ(1y02)y1y0y_0' = y_1, \qquad y_1' = \mu(1 - y_0^2) y_1 - y_0

For small μ\mu, the system exhibits gentle limit cycles. For large μ\mu, the solution develops sharp boundary layers — fast transitions separated by slow quasi-static phases — making it severely stiff.

use numra::ode::{Tsit5, Solver, OdeProblem, SolverOptions};
let mu = 1.0;
let problem = OdeProblem::new(
move |_t, y: &[f64], dydt: &mut [f64]| {
dydt[0] = y[1];
dydt[1] = mu * (1.0 - y[0] * y[0]) * y[1] - y[0];
},
0.0, 20.0,
vec![2.0, 0.0],
);
let options = SolverOptions::default().rtol(1e-8).atol(1e-10);
let result = Tsit5::solve(&problem, 0.0, 20.0, &[2.0, 0.0], &options).unwrap();
let y_final = result.y_final().unwrap();
println!("mu=1: y = [{:.6}, {:.6}]", y_final[0], y_final[1]);
println!("Steps: {}, Rejected: {}", result.stats.n_accept, result.stats.n_reject);

With μ=1000\mu = 1000, the solution has sharp transitions every half-period (roughly t1000t \approx 1000). Explicit methods would need millions of steps; an implicit method handles it easily.

use numra::ode::{Radau5, Solver, OdeProblem, SolverOptions};
let mu = 1000.0;
let problem = OdeProblem::new(
move |_t, y: &[f64], dydt: &mut [f64]| {
dydt[0] = y[1];
dydt[1] = mu * (1.0 - y[0] * y[0]) * y[1] - y[0];
},
0.0, 3000.0,
vec![2.0, 0.0],
);
let options = SolverOptions::default()
.rtol(1e-6)
.atol(1e-8)
.max_steps(200_000);
let result = Radau5::solve(
&problem, 0.0, 3000.0, &[2.0, 0.0], &options
).unwrap();
let y_final = result.y_final().unwrap();
println!("mu=1000: y = [{:.6}, {:.6}]", y_final[0], y_final[1]);
println!("Steps: {}, LU: {}", result.stats.n_accept, result.stats.n_lu);

Why is it stiff? When y0>1|y_0| > 1, the μ(1y02)\mu(1 - y_0^2) term creates a large negative damping coefficient, forcing the solution to change on a timescale of 1/μ1/\mu. Explicit methods must resolve this fast scale even during the slow phases, while Radau5 steps right over it.

Solver: Tsit5 for μ10\mu \leq 10, Esdirk54 for μ100\mu \sim 100, Radau5 for μ1000\mu \geq 1000. This problem is the classic benchmark for demonstrating when you need to switch from explicit to implicit methods.

Purpose: The definitive stiffness test. If your solver can handle Van der Pol at μ=1000\mu = 1000 efficiently, it can handle most stiff problems.

An extremely stiff 3-species chemical system with rate constants spanning 11 orders of magnitude.

y0=0.04y0+104y1y2y_0' = -0.04 \, y_0 + 10^4 \, y_1 \, y_2 y1=0.04y0104y1y23×107y12y_1' = 0.04 \, y_0 - 10^4 \, y_1 \, y_2 - 3 \times 10^7 \, y_1^2 y2=3×107y12y_2' = 3 \times 10^7 \, y_1^2

The initial conditions are y(0)=[1,0,0]y(0) = [1, 0, 0] and the system conserves mass: y0+y1+y2=1y_0 + y_1 + y_2 = 1. The component y1y_1 reaches a maximum of about 3.4×1053.4 \times 10^{-5} and then decays, while y2y_2 slowly approaches 1 as y0y_0 decays. Integration to t=1011t = 10^{11} is typical.

use numra::ode::{Radau5, Solver, OdeProblem, SolverOptions};
let problem = OdeProblem::new(
|_t, y: &[f64], dydt: &mut [f64]| {
dydt[0] = -0.04 * y[0] + 1e4 * y[1] * y[2];
dydt[1] = 0.04 * y[0] - 1e4 * y[1] * y[2] - 3e7 * y[1] * y[1];
dydt[2] = 3e7 * y[1] * y[1];
},
0.0, 1e5,
vec![1.0, 0.0, 0.0],
);
let options = SolverOptions::default()
.rtol(1e-6)
.atol(1e-10);
let result = Radau5::solve(
&problem, 0.0, 1e5, &[1.0, 0.0, 0.0], &options
).unwrap();
let y_final = result.y_final().unwrap();
let mass = y_final[0] + y_final[1] + y_final[2];
println!("y = [{:.6e}, {:.6e}, {:.6e}]", y_final[0], y_final[1], y_final[2]);
println!("Mass conservation: {:.2e}", (mass - 1.0).abs());
println!("Steps: {}, LU: {}", result.stats.n_accept, result.stats.n_lu);

Solver: Only Radau5 handles this reliably. The stiffness ratio exceeds 101110^{11}, and the solution spans many orders of magnitude in time. BDF can work at lower tolerances. Explicit methods will fail completely.

Purpose: The acid test for stiff solvers. If a method can integrate Robertson to t=1011t = 10^{11} while maintaining mass conservation, it is production-ready for stiff problems.

ProblemDimensionStiff?Recommended SolverKey Test
Exponential decay1NoDoPri5Basic correctness
Harmonic oscillator2NoDoPri5, Vern6Energy conservation
Lotka-Volterra2NoTsit5Nonlinear invariant
Lorenz3NoDoPri5Chaos, adaptivity
Van der Pol (mu=1)2NoTsit5Limit cycles
Van der Pol (mu=1000)2YesRadau5Stiffness handling
Robertson3ExtremeRadau5Extreme stiffness