Chapter 2 Improving the AccuracyΒΆ

Editor: Weipeng Xu

Last modified: 01/06/2025

Reference: Sundnes, Joakim. Solving Ordinary Differential Equations in Python. Springer Nature, 2024.

For the ODE system: $$ u^{\prime}=f(t,u),\quad u(t_0) = u_0 $$ another perspective to derive the solution scheme is to integrate both sides: $$ \int_{t_n}^{t^{n+1}}\frac{du}{dt}dt = \int_{t_n}^{t^{n+1}}f(t,u(t))dt $$ which gives $$ u(t_{n+1}) = u(t_n) + \int_{t_n}^{t^{n+1}}f(t,u(t))dt $$ where the computattion of the right hand side relies on numerical integration techiniques. The classical approach is to approximate the integrand funcition $f(t,u)$ with a ploynomial, which can be further classified into two categories:

  • Multistep methods: approximate $f(t,u)$ with a ploynomial that interpolates $f$ at previous time points, i.e., $f(t_{n-1},u_{n-1})$, $f(t_{n-2},u_{n-2})$...

  • Runge-Kutta (RK) methods: approximate $f(t,u)$ with a ploynomial that interpolates $f$ at interval time points within $t_n<t<t_{n+1}$

Explicit Runge-Kutta MethodsΒΆ

All RK methods follow the same recipe:

  • Calculate one or more intermediate values (stage derivatives)

  • Advance the solution using a combination of these statge derivatives

A general RK method with $s$ stages can be written as:

$$ \begin{aligned} &k_i = f(t_n + c_i\Delta t, u_n + \Delta t\sum_{j=1}^s a_{ij}k_j),\quad i=1,\cdots,s\\ &u_{n+1} = n_n + \Delta t\sum_{i=1}^sb_ik_i \end{aligned} $$ where $c, b, a$ are coefficients specific to the method. Different RK methods can be uniquely determined by the number of stages $s$ and the values of the coefficients.

Some principles should be satisified for the above coefficients:

  • $\sum_{i=1}^sb_i=1$ (approximmate the integral as a weighted sum)

  • $c_i = \sum_{j=1}^sa_{ij}$ (not necessary but can interpretate $k_i$ approximations of $f(t,u)$ at $t_n + c_i\Delta t$)

The Butcher tableau provides a concise representation of any RK method, which specifies all the method coefficients: $$ \begin{array}{c|ccc} c_1 & a_{11} & \cdots & a_{1s} \\ \vdots & \vdots & \ddots & \vdots \\ c_s & a_{s1} & \cdots & a_{ss} \\ \hline & b_1 & \cdots & b_s \\ \end{array} $$

In this chapter, we exclusively consider explicit RK methods, which means that $a_{ij}=0$ for $j\geq i$. Following are several examples:

  • Forward Euler Method (1-order)

$$ \begin{array}{c|cc} 0 & 0 \\ \hline & 1 \\ \end{array} $$

$$ \begin{aligned} &k_1 = f(t_n, u_n)\\ &u_{n+1} = u_n + \Delta tk_1 \end{aligned} $$

  • Explicit Mid-point Method (2-order)

$$ \begin{array}{c|cc} 0 & 0 & 0 \\ \frac{1}{2} & \frac{1}{2} & 0 \\ \hline & 0 & 1 \\ \end{array} $$

$$ \begin{aligned} &k_1 = f(t_n, u_n)\\ &k_2 = f(t_n+\frac{1}{2}\Delta t, u_n + \frac{1}{2}\Delta tk_1)\\ &u_{n+1} = u_n + \Delta tk_2 \end{aligned} $$

  • Heun's Method/Explicit Trapezoidal Method (2-order)

$$ \begin{array}{c|cc} 0 & 0 & 0 \\ 1 & 1 & 0 \\ \hline & \frac{1}{2} & \frac{1}{2} \\ \end{array} $$

$$ \begin{aligned} &k_1 = f(t_n, u_n)\\ &k_2 = f(t_n + \Delta t, u_n + \Delta tk_1)\\ &u_{n+1} = u_n + \frac{1}{2}\Delta t\left(k_1+k_2\right) \end{aligned} $$

  • Fourth-order & Four-stage RK Method/RK4

$$ \begin{array}{c|cccc} 0 & 0 & 0 &0 & 0\\ \frac{1}{2} & \frac{1}{2} & 0 & 0 &0 \\ \frac{1}{2} & 0 & \frac{1}{2} & 0 & 0\\ 1 & 0 & 0 & 1 & 0\\ \hline & \frac{1}{6} & \frac{1}{3} &\frac{1}{3} &\frac{1}{6} \\ \end{array} $$

$$ \begin{aligned} &k_1 = f(t_n,u_n)\\ &k_2 = f(t_n + \frac{1}{2}\Delta t, u_n + \frac{1}{2}\Delta tk_1)\\ &k_3 = f(t_n+\frac{1}{2}\Delta t, u_n+\frac{1}{2}\Delta tk_2)\\ &k_4 = f(t_n + \Delta t, u_n + \Delta tk_3)\\ & u_{n+1} = u_n + \frac{1}{6}\Delta t\left(k_1 + 2k_2 + 2k_3 + k_4\right) \end{aligned} $$

A Class Hierarchy of Runge-Kutta MethodsΒΆ

The base class (superclass) can be written as:

import numpy as onp

class ODESolver:

    def __init__(self,f):
        self.model = f
        self.f = lambda t,u: onp.asarray(f(t,u), dtype=float)

    def set_initial_condition(self, u0):
        self.u0 = onp.asarray(u0, dtype=float)

    def solve(self, t_span, N):
        t0, T = t_span
        self.dt = (T-t0)/N
        self.t = onp.zeros((N+1, 1))
        self.u = onp.zeros((N+1, self.u0.size))

        msg = "Please set initial condition before calling solve"
        assert hasattr(self, "u0"), msg

        self.t[0] = t0
        self.u[0] = self.u0

        for n in range(N):
            self.n = n
            self.t[n+1] = self.t[n] + self.dt
            self.u[n+1] = self.advance()
        
        return self.t, self.u
    
    def advance(self):
        raise NotImplementedError("Advance method is not implemented in the based class")

The implementation of FE, explicit midpoint method, and RK4 can be very straightforward:

class ForwardEuler(ODESolver):
    def advance(self):
        u, f, n, t = self.u, self.f, self.n, self.t
        dt = self.dt
        return u[n] + dt * f(t[n], u[n])
class ExpliictMidpoint(ODESolver):
    def advance(self):
        u, f, n, t = self.u, self.f, self.n, self.t
        dt = self.dt
        k1 = f(t[n], u[n])
        k2 = f(t[n] + dt/2., u[n] + dt/2.*k1)
        return u[n] + dt * k2
class Henu(ODESolver):
    def advance(self):
        u, f, n, t = self.u, self.f, self.n, self.t
        dt = self.dt
        k1 = f(t[n], u[n])
        k2 = f(t[n] + dt, u[n] + dt * k1)
        return u[n] + dt/2. * (k1 + k2)
class RungeKutta4(ODESolver):
    def advance(self):
        u, f, n, t = self.u, self.f, self.n, self.t
        dt = self.dt
        k1 = f(t[n], u[n])
        k2 = f(t[n] + dt/2., u[n] + dt/2.*k1)
        k3 = f(t[n] + dt/2., u[n] + dt/2.*k2)
        k4 = f(t[n] + dt, u[n] + dt*k3)
        return u[n] + 1/6. * dt * (k1 + 2*k2 + 2*k3 + k4)

Consider the simple ODE: $$ \begin{aligned} &u^{\prime} = u\\ &u(0) = 1 \end{aligned} $$

import numpy as onp
import matplotlib.pyplot as plt

# ODE
f = lambda t,u: u

# analytical
def exact(t):
    return onp.exp(t)

# time steps
t_span = (0, 3)
N = 6

# initialize the solver
fe = ForwardEuler(f)
em = ExpliictMidpoint(f)
he = Henu(f)
rk4 = RungeKutta4(f)

# solve & postprocess
for solver in [fe, em, rk4]:
    solver.set_initial_condition(u0=1)
    t, u = solver.solve(t_span, N)
    plt.plot(t, u , label=solver.__class__.__name__)
t_exact = onp.linspace(t_span[0], t_span[1], 301)
plt.plot(t_exact, exact(t_exact), label='Exact')
plt.title('RK solvers for exponential growth, $\\Delta t=0.5$')
plt.xlabel('$t$')
plt.ylabel('$u(t)$')
plt.legend()
plt.show()
No description has been provided for this image

Testing the SolversΒΆ

solver_classes = [ForwardEuler, ExpliictMidpoint, Henu, RungeKutta4]
orders = [1,2,2,4]
for solver_class, order in zip(solver_classes, orders):

    solver = solver_class(f)
    solver.set_initial_condition(u0=1.0)

    print(f'{solver_class.__name__}, order={order}')
    print(f'Time step (dt)  Error(e)      e/dt**{order}')

    N = 30
    for _ in range(10):
        t, u = solver.solve(t_span, N)
        dt = (t_span[1]-t_span[0]) / N
        e = abs(u[-1] - exact(t_span[-1])).item()
        # break if error is close to machine precision
        if e < 1e-13:
            break
        print(f'{dt:<14.7f}  {e:<12.7f}  {e/dt**order:5.4f}')
        N = N * 2
ForwardEuler, order=1
Time step (dt)  Error(e)      e/dt**1
0.1000000       2.6361347     26.3613
0.0500000       1.4063510     28.1270
0.0250000       0.7273871     29.0955
0.0125000       0.3700434     29.6035
0.0062500       0.1866483     29.8637
0.0031250       0.0937359     29.9955
0.0015625       0.0469715     30.0618
0.0007813       0.0235117     30.0950
0.0003906       0.0117624     30.1116
0.0001953       0.0058828     30.1200
ExpliictMidpoint, order=2
Time step (dt)  Error(e)      e/dt**2
0.1000000       0.0929800     9.2980
0.0500000       0.0241697     9.6679
0.0250000       0.0061593     9.8548
0.0125000       0.0015545     9.9487
0.0062500       0.0003905     9.9957
0.0031250       0.0000978     10.0192
0.0015625       0.0000245     10.0310
0.0007813       0.0000061     10.0369
0.0003906       0.0000015     10.0398
0.0001953       0.0000004     10.0413
Henu, order=2
Time step (dt)  Error(e)      e/dt**2
0.1000000       0.0929800     9.2980
0.0500000       0.0241697     9.6679
0.0250000       0.0061593     9.8548
0.0125000       0.0015545     9.9487
0.0062500       0.0003905     9.9957
0.0031250       0.0000978     10.0192
0.0015625       0.0000245     10.0310
0.0007813       0.0000061     10.0369
0.0003906       0.0000015     10.0398
0.0001953       0.0000004     10.0413
RungeKutta4, order=4
Time step (dt)  Error(e)      e/dt**4
0.1000000       0.0000462     0.4620
0.0500000       0.0000030     0.4817
0.0250000       0.0000002     0.4918
0.0125000       0.0000000     0.4969
0.0062500       0.0000000     0.4995
0.0031250       0.0000000     0.5006
0.0015625       0.0000000     0.5025
0.0007813       0.0000000     0.5436
0.0003906       0.0000000     5.1880
0.0001953       0.0000000     102.5391

We can also consider an even simpler ODE: $$ u^{\prime}(t) = f(t,u) = C $$ with analytical solution as: $$ u(t) = Ct + u_0 $$ where $u_0$ is the initial solution. All the methods discussed will capture this solution to machine precison.

# parameters
C = 0.2
u0 = 3

# ODE
f = lambda t,u: C

# analytical
def exact(t):
    return C * t + u0

# test
u0 = exact(0)
t_span = (0, 8)
N = 10
tol = 1e-14
for solver_class in solver_classes:
    solver = solver_class(f)
    solver.set_initial_condition(u0)
    t, u = solver.solve(t_span, N)
    ue = exact(t)
    max_error = abs((ue-u)).max()
    msg = f'{solver_class.__name__} failed, error={max_error}'
    assert max_error < tol, msg
print("All solvers pass the test!")
All solvers pass the test!