Why ODE Solvers Matters
A cooling model says how quickly temperature changes at the current temperature, but it does not directly give the temperature an hour later. A differential-equation solver starts from a known state and repeatedly uses those rates of change to approximate the future state. The same idea drives motion simulation, chemical kinetics, and dynamical-system models.
This lesson solves a real initial-value problem, $\mathbf y'(t)=\mathbf f(t,\mathbf y)$ with $\mathbf y(t_0)=\mathbf y_0$. It develops forward Euler, two-stage Heun, classical RK4, and an adaptive Runge–Kutta method using the Bogacki–Shampine $3(2)$ pair. You will trace every stage on a small equation, implement vector states in three languages, and distinguish local error estimates, actual solution error, and numerical stability.
The implementations are explicit methods for nonstiff problems. A right-hand-side function supplies rates for all state components; no analytic solution is required. An analytic solution is used in the sample only to check the computed error independently.
An ODE solver advances a known state using sampled rates of change. Higher-order methods combine several carefully located stage evaluations, while an adaptive method also compares two formulas to decide whether to accept a trial and how large the next step should be.
Core Concepts and Terminology
For a scalar example, take $y'=-y$ and $y(0)=1$, whose exact solution is $y(t)=e^{-t}$. At the initial point the slope is $-1$. An Euler step of length $h=1/2$ follows that starting tangent and produces $1-(1/2)=1/2$; the exact value at the new time is $e^{-1/2}\approx0.606531$. Sampling additional slopes can improve the step.
For a system, $\mathbf y$ is a vector of $d$ components and $\mathbf f$ returns a vector of the same length. For example, the oscillator $x''=-x$ becomes $\mathbf y=[x,v]^T$ and $\mathbf f=[v,-x]^T$. All stage-state updates apply componentwise. Explicit methods evaluate known trial states rather than solving a matrix system; they have no pivoting step.
| Term | Example or definition | Why it matters |
|---|---|---|
| Initial-value problem | $y'=-y$, $y(0)=1$ | Specifies a rate law and the state from which to start. |
| Right-hand side | $f(t,y)=-y$ | Returns a derivative, not a state increment. |
| Step | $h=t_{n+1}-t_n$ | Can be positive or negative depending on integration direction. |
| Stage slope | $\mathbf k_i=\mathbf f(t_i,\mathbf Y_i)$ | A rate evaluated at a constructed stage state. |
| Local one-step error | Error from one step started at the exact state | Determines a method's local accuracy order. |
| Global error | $\lVert\mathbf y_n-\mathbf y(t_n)\rVert$ | Includes error propagated from previous steps. |
| Embedded pair | Bogacki–Shampine gives third- and second-order trial values | Supplies a step error diagnostic from shared stages. |
| Accepted step | A trial whose normalized error estimate is at most $1$ | Advances time and commits a state to the trajectory. |
| Rejected trial | Error estimate exceeds the threshold | Counts work but leaves the committed state unchanged. |
| Stability | Bounded propagation of perturbations for the step and equation | Accuracy order alone does not ensure stable behavior. |
| Stiffness | Some modes force tiny explicit steps for stability despite slow behavior of interest | Can motivate an implicit solver. |
Assume the right-hand side defines a sufficiently smooth solution on the interval of interest. Local Lipschitz continuity in the state gives the usual local uniqueness setting; it does not guarantee a solution remains finite for all time. A solver's finite-value checks cannot establish existence, smoothness, or a model's physical validity.
How ODE Solvers Works, Step by Step
1. Validate the interval, state, and method parameters. Require finite endpoints with a finite representable difference, a nonempty finite initial vector, and a fixed right-hand-side dimension. Fixed solvers take a positive step count. The adaptive solver takes a positive initial step magnitude, positive absolute tolerance, nonnegative relative tolerance, and a nonnegative attempt budget. A validated zero-length interval returns the initial state without evaluating the right-hand side.
2. Choose a step that stays inside the requested interval. The fixed code schedules $N$ steps from the original interval and explicitly sets its final endpoint to $t_1$. The adaptive code chooses the direction from $t_1-t_0$ and clips each trial to the remaining interval. Before any evaluations, it checks that the endpoint advances and that required interior stage times are representable. Failure to form the stage times returns stalled at the last committed state.
3. Construct stage states and evaluate slopes. Here $\mathbf k_i$ always means a derivative, without an included factor of $h$.
Forward Euler uses one slope:
$$ \mathbf k_1=\mathbf f(t,\mathbf y),\qquad \mathbf y_{\mathrm{new}}=\mathbf y+h\mathbf k_1. $$
Heun's method here means the explicit trapezoidal method: predict with Euler, then average the starting and predicted endpoint slopes:
$$ \mathbf k_1=\mathbf f(t,\mathbf y),\quad \mathbf k_2=\mathbf f(t+h,\mathbf y+h\mathbf k_1),\quad \mathbf y_{\mathrm{new}}=\mathbf y+\frac h2(\mathbf k_1+\mathbf k_2). $$
This is an explicit predictor-corrector formula, not the implicit trapezoidal rule. Classical RK4 uses four slopes:
$$ \begin{aligned} \mathbf k_1&=\mathbf f(t,\mathbf y),\\ \mathbf k_2&=\mathbf f(t+h/2,\mathbf y+h\mathbf k_1/2),\\ \mathbf k_3&=\mathbf f(t+h/2,\mathbf y+h\mathbf k_2/2),\\ \mathbf k_4&=\mathbf f(t+h,\mathbf y+h\mathbf k_3),\\ \mathbf y_{\mathrm{new}}&=\mathbf y+\frac h6(\mathbf k_1+2\mathbf k_2+2\mathbf k_3+\mathbf k_4). \end{aligned} $$
Stages two and three occur at the same time but generally use different states. Overwriting the committed state between stages would change the method. The code stores stage vectors separately and constructs each stage from the original state for that trial.
4. For adaptivity, form two estimates using the Bogacki–Shampine pair. With four shared evaluations, define
$$ \begin{aligned} \mathbf k_1&=\mathbf f(t,\mathbf y),\\ \mathbf k_2&=\mathbf f(t+h/2,\mathbf y+h\mathbf k_1/2),\\ \mathbf k_3&=\mathbf f(t+3h/4,\mathbf y+3h\mathbf k_2/4),\\ \mathbf y^{[3]}&=\mathbf y+h(2\mathbf k_1/9+\mathbf k_2/3+4\mathbf k_3/9),\\ \mathbf k_4&=\mathbf f(t+h,\mathbf y^{[3]}),\\ \mathbf y^{[2]}&=\mathbf y+h(7\mathbf k_1/24+\mathbf k_2/4+\mathbf k_3/3+\mathbf k_4/8). \end{aligned} $$
The bracketed superscripts denote orders, not powers. If accepted, the code advances with the third-order estimate. The difference between the two formulas is generally $O(h^3)$ and estimates the lower-order formula's local error; it is not a certified bound on the retained third-order state or the final global error.
5. Normalize the error across components and accept or reject. For each component, use
$$ s_j=\text{atol}+\text{rtol}\max(\lvert y_j\rvert,\lvert y_j^{[3]}\rvert),\qquad E=\max_j\frac{\lvert y_j^{[3]}-y_j^{[2]}\rvert}{s_j}. $$
The programs use scalar tolerances and a maximum norm. Positive atol keeps every scale positive near zero; component-specific tolerances are a useful extension when state units differ. If $E\le1$, commit the time and third-order state and append one trajectory row. If $E\gt1$, reject the trial and leave time, state, and stored history unchanged. Either outcome counts the trial's four evaluations.
6. Adjust the next step magnitude and finish with a reason. For nonzero $E$, multiply the last trial's step magnitude by $\operatorname{clip}(0.9E^{-1/3},0.2,5)$. The cube-root exponent comes from the $O(h^3)$ difference. For $E=0$, use factor $5$; after a rejection, also cap the factor at $1$. Clip the result to the interval still to integrate. An overflowing normalized error is treated as an infinite error and rejected; nonfinite function values, stage states, or checked tolerance scales raise an exception.
flowchart TD
A["Validate interval, initial vector, and controls"] --> B{"Already at target?"}
B -->|Yes| R["Return done with committed trajectory"]
B -->|No| C{"Attempt budget remains?"}
C -->|No| L["Return limit with latest committed state"]
C -->|Yes| D["Clip trial endpoint to remaining interval"]
D --> E{"Stage times representable?"}
E -->|No| S["Return stalled"]
E -->|Yes| F["Evaluate four shared stages and both trial states"]
F --> G["Compute scaled maximum error E"]
G --> H{"E at most 1?"}
H -->|Yes| I["Commit time and third-order state; append history"]
H -->|No| J["Count rejection; retain time, state, and history"]
I --> K{"Target reached?"}
K -->|Yes| R
K -->|No| M["Adjust step magnitude"]
J --> M
M --> C
The diagram shows the adaptive driver. Fixed-step methods commit every finite, representable trial and have no embedded error acceptance test. done means the target time was reached, limit means the adaptive attempt budget was exhausted, and stalled means the time grid could no longer support the required stages. None of these statuses by itself certifies a global error bound.
Worked Example with a Full Trace
Solve $y'=-y$, $y(0)=1$ on $[0,1]$. For the readable hand trace, use $h=1/2$, giving two steps for each fixed method. The exact endpoint is $e^{-1}\approx0.3678794412$.
Euler maps a starting value $q$ to $q/2$. Heun's two slopes are $-q$ and $-q/2$, so it maps $q$ to $5q/8$. Both complete trajectories are shown in the final table below.
RK4's complete stage trace is:
| Step | Starting $(t,y)$ | $k_1$ | Stage-two state and $k_2$ | Stage-three state and $k_3$ | Stage-four state and $k_4$ | New state |
|---|---|---|---|---|---|---|
| $1$ | $(0,1)$ | $-1$ | $3/4$, $-3/4$ at $t=1/4$ | $13/16$, $-13/16$ at $t=1/4$ | $19/32$, $-19/32$ at $t=1/2$ | $233/384$ |
| $2$ | $(1/2,233/384)$ | $-233/384$ | $233/512$, $-233/512$ at $t=3/4$ | $3029/6144$, $-3029/6144$ at $t=3/4$ | $4427/12288$, $-4427/12288$ at $t=1$ | $54289/147456$ |
Each RK4 step multiplies its current value by $233/384$, so the final state is $(233/384)^2\approx0.3681708442$. Fractions describe exact-arithmetic stage values; the programs store floating-point approximations.
For a complete short adaptive trace, use initial step magnitude $1/2$, $\text{atol}=10^{-2}$, $\text{rtol}=0$, and attempt budget $10$. Its stages on the first trial are $k_1=-1$, $k_2=-3/4$, $k_3=-23/32$, and $k_4=-29/48$. They produce $y^{[3]}=29/48$ and $y^{[2]}=463/768$.
| Trial | Starting $(t,y)$ | Third-order candidate | Second-order candidate | Absolute difference | Normalized $E$ | Decision |
|---|---|---|---|---|---|---|
| $1$ | $(0,1)$ | $29/48$ | $463/768$ | $1/768$ | $25/192\approx0.1302083$ | Accept |
| $2$ | $(1/2,29/48)$ | $841/2304$ | $13427/36864$ | $29/36864$ | $725/9216\approx0.0786675$ | Accept and finish |
The second trial's slopes are the first trial's slopes multiplied by $29/48$: $-29/48$, $-29/64$, $-667/1536$, and $-841/2304$. After the first acceptance the controller proposes growth, but clipping leaves exactly $1/2$ to reach the target. The run ends with two accepted steps, no rejections, and eight evaluations.
All four hand trajectories, with every committed state, are:
| Time | Euler | Heun | RK4 | Adaptive $3(2)$ with loose tolerance |
|---|---|---|---|---|
| $0$ | $1$ | $1$ | $1$ | $1$ |
| $1/2$ | $1/2$ | $5/8$ | $233/384$ | $29/48$ |
| $1$ | $1/4$ | $25/64$ | $54289/147456$ | $841/2304$ |
For a rejection contrast, change only the adaptive absolute tolerance to $10^{-3}$. The first trial now has $E=125/96\approx1.3020833$ and is rejected. With an attempt budget of one, the returned status is limit, time remains $0$, state remains $1$, history contains only the initial row, and the evaluation count is $4$.
Trace It by Hand
For $y'=-y$ with $h=1/2$, start RK4 from $y=1$. Predict the second stage state and then explain why the third stage uses another state at the same time.
Reveal: $k_1=-1$, so the second state is $1+(1/4)(-1)=3/4$. Its slope is $-3/4$, making the third state $1+(1/4)(-3/4)=13/16$. Both stages occur at $t=1/4$, but the second slope improves the midpoint trial state.
Now suppose an adaptive trial exceeds its error threshold. Which quantities change? Its attempt, rejection, and evaluation counts change, and its next proposed step becomes smaller; the committed time, state, and trajectory do not change.
Implementation in Python, C++, and Java
Each panel is a complete program using ordinary state arrays, nested arrays for Runge–Kutta coefficients and stages, and trajectory rows of the form $[t,y_0,\ldots,y_{d-1}]$. A single tableau-driven step routine implements the four formulas. These explicit methods require no linear-system solve or matrix pivoting; implicit methods discussed later would require a different step routine.
fixed accepts a right-hand-side callback, start and target times, initial vector, positive step count, and method string euler, heun, or rk4. adaptive uses the Bogacki–Shampine pair with the tolerance and attempt controls described above. Backward integration is supported by reversing the time direction; h0 remains a positive magnitude. The callback must return a finite vector of the original dimension and should represent a deterministic rate law. It receives a separate stage-state copy.
The result stores final time, state, full accepted trajectory, accepted and rejected counts, evaluation count, and status. Rejected candidates are not stored. These implementations intentionally do not reuse the last stage as the next first stage, so each adaptive attempt costs exactly four right-hand-side evaluations. A production Bogacki–Shampine implementation can exploit that reuse.
The executable sample uses the same decay problem on $[0,1]$, but four fixed steps rather than the hand trace's two. The adaptive sample uses initial magnitude $0.5$, absolute tolerance $10^{-7}$, relative tolerance $10^{-6}$, and attempt budget $10000$. Its independent check compares with $e^{-1}$; local tolerances are not substituted for actual endpoint error.
The identical sample output is:
euler: y(1) = 0.316406, actual error = 0.051473heun: y(1) = 0.372529, actual error = 0.004650rk4: y(1) = 0.367894, actual error = 0.000015adaptive-bs23: y(1) = 0.367879, actual error = 0.000001adaptive status = doneadaptive accepted = 29, rejected = 2, evaluations = 124check = PASS
The driver checks method-specific actual-error thresholds and requires every run to reach the target with done. Its coarse Euler result is visibly less accurate; the shared success label means each demonstration met its stated threshold. Function-call count matters as well as accepted steps: the adaptive run used $4(29+2)=124$ evaluations, including rejected work. Other equations or library controllers need not produce those counts.
Complexity, Correctness, and Invariants
Let $d$ be the state dimension, $C_f$ the cost of one vector right-hand-side evaluation, $N$ the fixed step count, $A$ the adaptive attempt count, $K$ its attempt budget, and $S$ the number of accepted steps. Orders below assume sufficient smoothness and a stable convergent regime on a fixed finite interval. The local-error column uses the unnormalized one-step error, not that error divided by $h$.
| Method | Local one-step error / global order | Evaluations and runtime | Extra working memory; stored history |
|---|---|---|---|
| Forward Euler | $O(h^2)$ / $O(h)$ | $N$ calls; $\Theta(N(C_f+d))$ | $O(d)$ working; $O((N+1)(d+1))$ history |
| Two-stage Heun | $O(h^3)$ / $O(h^2)$ | $2N$ calls; $\Theta(N(C_f+d))$ | $O(d)$ working; $O((N+1)(d+1))$ history |
| Classical RK4 | $O(h^5)$ / $O(h^4)$ | $4N$ calls; $\Theta(N(C_f+d))$ | $O(d)$ working; $O((N+1)(d+1))$ history |
| Implemented adaptive Bogacki–Shampine $3(2)$ | Retained formula: $O(h^4)$ locally, third order globally for an appropriate shrinking mesh; embedded difference is $O(h^3)$ | $4A$ calls; $O(A(C_f+d))$, with $A\le K$ | $O(d)$ working; $O((S+1)(d+1))$ history |
For a valid nonzero fixed run with representable stages, best, average, and worst loop work at a fixed $N$ are the same. A stalled grid can return earlier. All solvers return immediately without evaluations on a validated zero-length interval. An adaptive nonzero interval can finish in one accepted attempt, but its worst budgeted work includes $K$ unsuccessful or successful attempts; it may return limit. There is no distribution-free average attempt count or unconditional finite count to achieve a tolerance. Stiffness, singularities, rapidly varying derivatives, and evaluator noise can make acceptance difficult.
Consistency and convergence. Euler follows the first Taylor term. Heun and RK4 choose stage locations and weights that match the exact solution's Taylor expansion through higher orders. Under suitable regularity and a Lipschitz stability estimate, errors introduced at each step accumulate to their stated global orders. On well-behaved fixed-interval tests, halving a sufficiently small fixed step reduces global error by factors approaching $2$, $4$, and $16$, respectively. These are asymptotic statements, not universal ratios at coarse steps.
Stage invariant. Every trial starts from a fixed committed $(t,\mathbf y)$. Each explicit stage uses only that original state and already computed stage slopes. The candidate weighted sum does not modify the original state while stages are being evaluated. The tableau arrays in the code encode precisely those dependencies.
Acceptance invariant. The trajectory begins with the initial condition and contains exactly one additional row per accepted step. Its times advance in the requested direction, its last row equals the reported committed state, and a rejected trial never appears in it. Every adaptive accepted trial satisfies its computed normalized-error test. This is a bookkeeping and local diagnostic invariant; it is not a proof of a requested global solution-error bound.
During a trial, keep the committed state fixed while constructing all stage states. Commit the time and candidate only after acceptance; otherwise retain the old state and retry with a smaller step. This preserves a valid accepted trajectory and prevents rejected estimates from contaminating later steps.
Stability is separate from accuracy order. On $y'=\lambda y$, Euler gives $y_{n+1}=(1+h\lambda)y_n$. With negative real $\lambda$, decay requires $\lvert1+h\lambda\rvert\lt1$. Heun and RK4 have different stability polynomials, but their explicit stability regions are still bounded. Reducing a local error diagnostic does not remove stability restrictions or guarantee that a stiff problem is being solved efficiently.
Variants, Pitfalls, and Optimizations
A stable equation can have an unstable numerical trajectory. For $y'=-10y$ and $y(0)=1$, the exact solution decays. Euler with $h=0.3$ has multiplier $-2$, producing $1,-2,4$ at times $0,0.3,0.6$. Euler with $h=0.05$ has multiplier $1/2$ and decays instead. The smaller step is stable for this test, although stability alone does not make its decay quantitatively exact.
For this equation Euler's strict decay interval is $0\lt h\lt0.2$. At $h=0.2$ the multiplier is $-1$, so the numerical solution oscillates without damping. In genuinely stiff systems, slow modes may coexist with much faster decaying modes, forcing explicit methods to take far smaller steps than the behavior of interest suggests. Implicit Euler, implicit Runge–Kutta methods, Radau, or BDF methods can be appropriate alternatives; they require solving algebraic systems and have their own accuracy and stability contracts.
Avoid the common stage bugs. Do not multiply by $h$ twice when stage variables are derivatives. Do not compute Heun's second slope at the old state, reuse RK4's second stage as its third, or update vector components sequentially in place. For a coupled system such as an oscillator, every component of a stage must use the same stage vector. A scalar example alone may fail to expose these errors.
Treat time and tolerances carefully. Shorten the final trial rather than overshooting the endpoint, and use the actual clipped step in every stage and error calculation. Respect backward-integration direction. Very small time steps can collapse to the current time or make interior stage times coincide; the code reports stalled. A finite accepted state does not imply positivity, energy conservation, or another physical invariant. Scalar absolute tolerances may be unsuitable for state components measured in different units.
Know what adaptive control omits. The maximum norm here differs from the weighted RMS norm used by some libraries. The supplied driver has no dense-output interpolant, event locator, discontinuity handling, or automatic stiffness detection. If a rate law changes at a known time, end a step there and restart with the appropriate model. If a simulation stops on a state-dependent event, bracket and locate that event rather than simply taking the first step past it. Finite-time blow-up can exhaust the step budget or precision; it is not repaired by hiding the failure status.
Embedded estimates can agree accidentally. For $y'=-2y$, $y(0)=1$, a single trial with $h=1/2$ gives both Bogacki–Shampine candidates equal to $1/3$ in exact arithmetic. The exact endpoint is $e^{-1}\approx0.367879$, so the actual error is about $0.034546$ despite a zero embedded difference. On the linear test equation, their difference is $-y z^3(1+z)/48$ with $z=h\lambda$, which vanishes at $z=-1$. A tight tolerance cannot detect that particular coincidence; compare smaller initial steps or an independent method when validating a solve. This is another reason the acceptance test is a diagnostic rather than a global certificate.
Choose a method for the problem. Dormand–Prince $5(4)$ is another common adaptive embedded pair; its stage coefficients and controller order differ from the $3(2)$ pair implemented here. Symplectic integrators can better preserve long-term geometric behavior in Hamiltonian systems, while general-purpose RK4 does not conserve energy exactly. Higher-order equations should be converted to first-order systems before using these interfaces.
Improve work without changing the method. Bogacki–Shampine's final stage is the derivative at the accepted endpoint, so a careful implementation can reuse it as the next first stage. Cache or batch other expensive shared calculations when the rate law permits. If only the final state is needed, omit trajectory storage to reduce memory to $O(d)$. Keep accepted and rejected evaluation costs visible when comparing solvers.
Do not confuse an accepted local error estimate with a global accuracy guarantee, or a high-order explicit method with a stiff solver. Keep rejected trials out of the committed state, validate vector dimensions and stage times, and inspect the termination status before using a final value.
Practice Problems and Real-World Use
Start with exact solutions and step-refinement experiments, then add coupled states, rejection tests, and difficult dynamics. Record actual error when known, accepted and rejected counts, evaluation counts, endpoint time, and any invariant relevant to the model.
| Problem or system | Concrete task | Idea tested | Difficulty |
|---|---|---|---|
| Exponential decay | Solve $y'=-y$, $y(0)=1$ on $[0,1]$ | Reproduce stage traces and compare with $e^{-t}$ | Easy |
| Time-dependent rate | Solve $y'=t^2$, $y(0)=0$ and compare with $t^3/3$ | Correct stage times and endpoint clipping | Easy |
| Harmonic oscillator | Solve $x'=v$, $v'=-x$ | Simultaneous vector stages, phase error, and energy drift | Medium |
| Adaptive rejection audit | Start decay with a deliberately large trial and tight tolerance | Rejected work must not change state or history | Medium |
| Backward integration | Start from a known exact state at $t=1$ and integrate to $0$ | Direction handling and reversed stage times | Medium |
| Fast decay and stiffness | Compare explicit runs for $y'=-10y$ and a system with widely separated rates | Stability restrictions versus local accuracy | Medium |
| Cooling with a model switch | Integrate a rate law that changes at a known time | Split at discontinuities and restart consistently | Hard |
| Long-term orbital or mechanical simulation | Compare RK methods with a suitable geometric integrator | Qualitative behavior, conserved quantities, and method choice | Hard |
For further reading, the authors' Fundamentals of Numerical Computation chapter on Runge–Kutta methods explains stage construction and order. SciPy's RK23 documentation identifies the Bogacki–Shampine $3(2)$ pair, and its initial-value solver documentation distinguishes explicit and implicit solver choices. Library norms, derivative reuse, interpolation, and stopping controls can differ from this explicit teaching implementation.