Ode Book
Ode Book
∗
Simula Research Laboratory.
Preface
This book was based on a set of lecture notes written for the book A Primer
on Scientific Programming with Python by Hans Petter Langtangen [14],
mainly covering topics from Appendix A, C, and E. The original notes have
been extended with more material on implicit solvers and automatic time-
stepping methods, to provide a more complete and balanced overview of
state-of-the-art solvers for ordinary differential equations (ODEs). The main
purpose of the notes is to serve as a brief and gentle introduction to solving
differential equations in Python, for use in the course Introduction to program-
ming for scientific applications (IN1900, 10 ETCS credits) at the University
of Oslo. To read these notes one should have a basic knowledge of Python
and NumPy, see for instance [16], and it is also useful to have a fundamental
understanding of ODEs.
One may ask why this is useful to learn how to write your own ODE
solvers in Python, when there are already multiple such solvers available,
for instance in the SciPy library. However, no single ODE solver is the best
and most efficient tool for all possible ODE problems, and the choice of
solver should always be based on the characteristics of the problem. To make
such choices, it is extremely useful to know the strengths and weaknesses
of the different solvers, and the best way to obtain this knowledge is to
program your own collection of ODE solvers. Different ODE solvers are also
conveniently grouped into families and hierarchies of solvers, and provide an
excellent example of how object-oriented programming (OOP) can be used
to maximize code reuse and minimize duplication.
The presentation style of the book is compact and pragmatic, and includes
a large number of code examples to illustrate how the various ODE solvers
can be implemented and applied in practice. The complete source code for
all examples, as well as Jupyter notebooks for all the chapters, is provided
in the online resources accompanying this book. All the programs and code
examples are written in a simple and compact Python style, and generally
avoid the use of advanced tools and features. Experienced Python program-
mers will therefore surely find more elegant and modern solutions to many of
v
vi
the examples, including, for instance, abstract base classes, type hints, and
data classes, to mention a few. However, the main goal of the book is to intro-
duce the fundamentals of ODE solvers and OOP as part of an introductory
programming course, and we believe this purpose is best served by focus-
ing on the basics. Readers familiar with scientific computing or numerical
software will probably also miss a discussion of computational performance.
While performance is clearly relevant when solving ODEs, optimizing the
performance of a Python based solver easily becomes quite technical, and
requires features like just-in-time compilers (e.g., Numba) or mixed-language
programming. The solvers in this book only use fairly basic features of Python
and NumPy, which sacrifices some performance but enhances understanding
of the solver properties and their implementation.1
The book is organized as follows. Chapter 1 introduces the forward Euler
method, and uses this simple method to introduce the fundamental ideas and
principles that underpin all the methods considered later. The chapter intro-
duces the notation and general mathematical formulation used throughout
the book, both for scalar ODEs and systems of ODEs, and is essential read-
ing for everyone with little prior experience with ODEs and ODE solvers.
The chapter also briefly explains how to use the ODE solvers from the SciPy
library. Readers already familiar with the fundamentals of the forward Euler
method and its implementation may consider moving straight to Chapter 2,
which presents explicit Runge-Kutta methods. The fundamental ideas of the
methods are introduced, and the main focus of the chapter is how a collection
of ODE solvers can be implemented as a class hierarchy with minimal code
duplication. Chapter 3 introduces so-called stiff ODEs, and presents tech-
niques for simple stability analysis of Runge-Kutta methods. The bulk of the
chapter is then devoted to programming of implicit Runge-Kutta methods,
which have better stability properties than explicit methods and therefore
perform better for stiff ODEs. Chapter 4 then concludes the presentation of
ODE solvers by introducing methods for adaptive time step control, which
is an essential component of all modern ODE software. Chapter 5 is quite
different from the preceding ones, since the focus is on a specific class of ODE
models rather than a set of solvers. The simpler ODE problems considered in
earlier chapters are useful for introducing and testing the solvers, but in order
to appreciate both the potential and the challenges of modelling with ODEs
it is useful to step beyond this. As an example of a real-world application
of ODEs we have chosen the famous Kermack-McKendrick SIR (Susceptible-
Infected-Recovered) model from epidemiology. These classic models were first
developed in the early 1900s (see [12]), and have received quite some atten-
tion in recent years, for obvious reasons. We derive the models from a set
of fundamental assumptions, and discuss the implications and limitations re-
sulting from these assumptions. The main focus of the chapter is then on
how the models can be modified and extended to capture new phenomena,
1
Complete source code for all the solvers and examples can be found here:
https://sundnes.github.io/solving_odes_in_python/
vii
and how these changes can be implemented and explored using the solvers
developed in preceding chapters.
Although the focus of the text is on differential equations, Appendix A is
devoted to the related topic of difference equations. The motivation for in-
cluding this chapter is that difference equations are closely related to ODEs,
they have many important applications on their own, and numerical meth-
ods for ODEs are essentially methods for turning differential equations into
difference equations. Solving difference equations can therefore be seen as a
natural step on the way towards solving ODEs, and the standard formulation
of difference equations in mathematical textbooks is already in a "computer-
friendly" form, which is very easy to translate into a Python program using
for-loops and arrays. Some students find difference equations easier to under-
stand than differential equations, and may benefit from reading Appendix A
first, while others find it easier to go straight to the ODEs and leave Ap-
pendix A for later.
Preface . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . v
ix
x Contents
References . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
Chapter 1
Programming a simple ODE solver
Ordinary differential equations (ODEs) are widely used in science and engi-
neering, in particular for modeling dynamic processes. While simple ODEs
can be solved with analytical methods, non-linear ODEs are generally not
possible to solve in this way, and we need to apply numerical methods. In
this chapter we will see how we can program general numerical solvers that
can be applied to any ODE. We will first consider scalar ODEs, i.e., ODEs
with a single equation and a single unknown, and in Section 1.3 we will ex-
tend the ideas to systems of coupled ODEs. Understanding the concepts of
this chapter is useful not only for programming your own ODE solvers, but
also for using a wide variety of general-purpose ODE solvers available both
in Python and other programming languages.
When solving ODEs analytically one will typically consider a specific ODE or
a class of ODEs, and try to derive a formula for the solution. In this chapter
we want to implement numerical solvers that can be applied to any ODE,
not restricted to a single example or a particular class of equations. For this
purpose, we need a general abstract notation for an arbitrary ODE. We will
write the ODEs on the following form:
which means that the ODE is fully specified by the definition of the right-
hand side function f (t, u). Examples of this function may be:
1
2 1 Programming a simple ODE solver
Notice that, for generality, we write all these right-hand sides as functions of
both t and u, although the mathematical formulations only involve u. This
general formulation is not strictly needed in the mathematical equations, but
it is very convenient when we start programming, and want to use the same
solver for a wide range of ODE models. We will discuss this in more detail
later. Our aim is now to write functions and classes that take f as input, and
solve the corresponding ODE to produce u as output.
In order for (1.1) to have a unique solution we need to specify the initial
condition for u, which is the value of the solution at time t = t0 . The resulting
mathematical problem is written as
u0 = u.
This equation has the general solution u(t) = Cet for any constant C, so it
has an infinite number of solutions. Specifying an initial condition u(t0 ) = u0
gives C = u0 , and we get the unique solution u(t) = u0 et . We shall see that,
when solving the equation numerically, we need to define u0 in order to start
our method and compute a solution at all.
A simple and general solver; the Forward Euler method. A numerical
method for (1.1) can be derived by simply approximating the derivative in
the equation u0 = f (t, u) by a finite difference. To introduce the idea, assume
that we have already computed u at discrete time points t0 , t1 , . . . , tn . At time
tn we have the ODE
u0 (tn ) = f (tn , u(tn )),
and we can now approximate u0 (tn ) by a forward finite difference;
u(tn+1 ) − u(tn )
u0 (tn ) ≈ .
∆t
Inserting this approximation into the ODE at t = tn yields the following
equation
u(tn+1 ) − u(tn )
= f (tn , u(tn )),
∆t
1.1 Creating a general-purpose ODE solver 3
and we can rearrange the terms to obtain an explicit formula for u(tn+1 ):
This method is known as the Forward Euler (FE) method or the Explicit
Euler Method, and is the simplest numerical method for solving an ODE. The
classification as a forward or explicit method refer to the fact that we have
an explicit update formula for u(tn+1 ), which only involves known quantities
at time tn . In contrast, for an implicit ODE solver the update formula will
include terms on the form f (tn+1 , u(tn+1 )), and we need to solve a generally
nonlinear equation to determine the unknown u(tn+1 ). We will visit other
explicit ODE solvers in Chapter 2 and implicit solvers in Chapter 3.
To simplify the formula a bit we introduce the notation un = u(tn ), i.e.,
we let un denote the numerical approximation to the exact solution u(t) at
t = tn . The update formula now reads
u1 = u0 + ∆tu0 ,
u2 = u1 + ∆tu1 ,
u3 = u2 + . . . ,
1
For N time steps, the length of the arrays needs to be N + 1 since we need to store
both end points, i.e., t0 , t1 , . . . , tn and u0 , u1 , . . . , un .
4 1 Programming a simple ODE solver
N = 20
T = 4
dt = T/N
u0 = 1
t = np.zeros(N + 1)
u = np.zeros(N + 1)
u[0] = u0
for n in range(N):
t[n + 1] = t[n] + dt
u[n + 1] = (1 + dt) * u[n]
plt.plot(t, u)
plt.show()
Notice that there is no need to set t[0]= 0 when t is created in this way, but
updating u[0] is important. In fact, forgetting to do so is a very common
error in ODE programming, so it is worth taking note of the line u[0] =
u0. The solution is shown in Figure 1.1, for two different choices of the time
step ∆t. We see that the approximate solution improves as ∆t is reduced,
although both the solutions are quite inaccurate. However, reducing the time
step further will easily create a solution that cannot be distinguished from
the exact solution.
The for-loop in the example above could also be implemented differently,
for instance
for n in range(1, N+1):
t[n] = t[n - 1] + dt
u[n] = (1 + dt) * u[n - 1]
Here n runs from 1 to N, and all the indices inside the loop have been decreased
by one so that the end result is the same. In this simple case it is easy to verify
that the two loops give the same result, but mixing up the two formulations
will easily lead to a loop that runs out of bounds (an IndexError), or a loop
where the last elements of t and u are never computed. While seemingly
trivial, such errors are very common when programming with for-loops, and
it is a good habit to always examine the loop formulation carefully.
Extending the solver to the general ODE. As stated above, the purpose
of this chapter is to create general-purpose ODE solvers, that can solve any
ODE written on the form u0 = f (t, u). This requires a very small modification
of the algorithm above;
1. Create arrays t and u of length N + 1
1.1 Creating a general-purpose ODE solver 5
40
30
u
20
10
0
0.0 0.5 1.0 1.5 2.0 2.5 3.0 3.5 4.0
t
Fig. 1.1 Solution of u0 = u, u(0) = 1 with ∆t = 0.4 (N = 10) and ∆t = 0.2 (N = 20).
The only change of the algorithm is in the formula for computing u[n+1] from
u[n]. In the previous case we had f (t, u) = u, and to create a general-purpose
ODE solver we simply replace u[n] with the more general f(t[n],u[n]). The
following Python function implements this generic version of the FE method:
import numpy as np
u[0] = u0
dt = T / N
for n in range(N):
t[n + 1] = t[n] + dt
u[n + 1] = u[n] + dt * f(t[n], u[n])
6 1 Programming a simple ODE solver
return t, u
This simple function can solve any ODE written on the form (1.1). The right-
hand side function f (t, u) needs to be implemented as a Python function,
which is then passed as an argument to forward_euler together with the
initial condition u0, the stop time T and the number of time steps N. The
two latter arguments are then used to calculate the time step dt inside the
function.2
To illustrate how the function is used, let us apply it to solve the same
problem as above; u0 = u, u(0) = 1, for t ∈ [0, 4]. The following code uses the
forward_euler function to solve this problem:
def f(t, u):
return u
u0 = 1
T = 4
N = 30
t, u = forward_euler(f, u0, T, N)
The forward_euler function returns the two arrays u and t, which we can
plot or process further as we want. One thing worth noticing in this code is
the definition of the right-hand side function f. As we mentioned above, this
function should always be written with two arguments t and u, although in
this case only u is used inside the function. The two arguments are needed
because we want our solver to work for all ODEs on the form u0 = f (t, u), and
the function is therefore called as f(t[n], u[n]) inside the forward_euler
function. If our right hand side function was defined as a function of u only,
i.e., using def f(u):, we would get an error message when the function was
called inside forward_euler. This problem is solved by simply writing def
f(t,u): even if t is never used inside the function.3
For being only 15 lines of Python code, the capabilities of the forward_euler
function above are quite remarkable. Using this function, we can solve any
kind of linear or nonlinear ODE, most of which would be impossible to solve
using analytical techniques. The general recipe for using this function can be
summarized as follows:
1. Identify f (t, u) in your ODE
2. Make sure you have an initial condition u0
3. Implement the f (t, u) formula in a Python function f(t, u)
2
The source code for this function, as well as all subsequent solvers and examples,
can be found here: https://sundnes.github.io/solving_odes_in_python/
3
This way of defining the right-hand side is a standard used by most available ODE
solver libraries, both in Python and other languages. The right-hand side function always
takes two arguments t and u, but, annoyingly, the order of the two arguments varies
between different solver libraries. Some expect the t argument first, while others expect
u first.
1.2 The ODE solver implemented as a class 7
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):
"""Advance the solution one time step."""
# Create local variables to get rid of "self." in
# the numerical formula
u, dt, f, n, t = self.u, self.dt, self.f, self.n, self.t
This class does essentially the same tasks as the forward_euler function
above, and the main advantage of the class implementation is the increased
flexibility that comes with the advance method. As we shall see later, imple-
menting a different numerical method typically only requires implementing a
new version of this method, while all other code can be left unchanged. No-
tice also that we have added an assert statement inside the solve method,
which checks that the user has called set_initial_condition before calling
solve. Forgetting to do so is a very likely error for users of the code, and this
assert statement ensures that we get a useful error message rather than a
less informative AttributeError.
We can also use a class to hold the right-hand side f (t, u), which is par-
ticularly convenient for functions with parameters. Consider for instance the
1.3 Systems of ODEs 9
The main program for solving the logistic growth problem may now look like:
problem = Logistic(alpha=0.2, R=1.0)
solver = ForwardEuler_v0(problem)
u0 = 0.1
solver.set_initial_condition(u0)
T = 40
t, u = solver.solve(t_span=(0, T), N=400)
So far we have only considered ODEs with a single solution component, often
called scalar ODEs. Many interesting processes can be described by systems of
ODEs, i.e., multiple ODEs where the right-hand side of one equation depends
on the solution of the others. Such equation systems are also referred to as
vector ODEs. One simple example is
u0 = v, u(0) = 1
0
v = −u, v(0) = 0.
4
Recall that if we equip a class with a special method named __call__, instances
of the class will be callable and behave like regular Python functions. See, for instance,
Chapter 8 of [16] for a brief introduction to __call__ and other special methods.
10 1 Programming a simple ODE solver
0.9
0.8
0.7
0.6
u
0.5
0.4
0.3
0.2
0.10 5 10 15 20 25 30 35 40 45
t
The solution of this system is u = cos t, v = sin t, which can easily be verified by
inserting the solution into the equations and the initial conditions. For more
general cases, it is usually even more difficult to find analytical solutions
of ODE systems than of scalar ODEs, and numerical methods are usually
required. In this section we will extend the solvers introduced in sections 1.1-
1.2 to be able to solve systems of ODEs. We shall see that such an extension
requires relatively small modifications of the code.
We want to develop general software that can be applied to any vector
ODE or scalar ODE, and for this purpose it is useful to introduce some
general mathematical notation. We have m unknowns
in a system of m ODEs:
1.4 A ForwardEuler class for systems of ODEs 11
d (0)
u = f (0) (t, u(0) , u(1) , . . . , u(m−1) ),
dt
d (1)
u = f (1) (t, u(0) , u(1) , . . . , u(m−1) ),
dt
.. ..
.=.
d (m−1)
u = f (m−1) (t, u(0) , u(1) , . . . , u(m−1) ).
dt
To simplify the notation (and later the implementation), we collect both the
solutions u(i) (t) and right-hand side functions f (i) into vectors;
and
f = (f (0) , f (1) , . . . , f (m−1) ).
Note that f is now a vector-valued function. It takes m + 1 input arguments
(t and the m components of u) and returns a vector of m values. Using this
notation, the ODE system can be written
The ForwardEuler_v0 class above was written for scalar ODEs, and we now
want to make it work for a system u0 = f , u(0) = u0 , where u, f and u0
are vectors (arrays). To identify how the code needs to be changed, let us
first revisit the underlying numerical method. Using the general notation
introduced above, applying the forward Euler method to a system of ODEs
yields an update formula that looks exactly as for the scalar case, but where
all the terms are vectors:
with the important difference that both u[k], u[k+1], and f(t[k], u[k])
are now arrays.5 Since these are arrays, the solution u must be a two-
dimensional array, and u[k],u[k+1], etc. are the rows of this array. The
function f expects an array as its second argument, and must return a one-
dimensional array, containing all the right-hand sides f (0) , . . . , f (n−1) . To get
a better feel for how these arrays look and how they are used, we may com-
pare the array holding the solution of a scalar ODE to that of a system of two
ODEs. For the scalar equation, both t and u are one-dimensional NumPy ar-
rays, and indexing into u gives us numbers, representing the solution at each
time step. For instance, in an interactive Python session we may have arrays
t and u with the following contents:
>>> t
array([0. , 0.4, 0.8, 1.2, ... ])
>>> u
array([1. , 1.4, 1.96, 2.744, ... ])
In the case of a system of two ODEs, t is still a one-dimensional array, but the
solution array u is now two-dimensional, with one column for each solution
component. We can index it exactly as shown above, and the result is a one-
dimensional array of length two, which holds the two solution components at
a single time step:
>>> u
array([[1.0, 0.8],
[1.4, 1.1],
[1.9, 2.7],
... ])
>>> u[0]
5
This compact notation requires that the solution vector u is represented by a NumPy
array. We could, in principle, use lists to hold the solution components, but the resulting
code would need to loop over the components and would be far less elegant and readable.
1.4 A ForwardEuler class for systems of ODEs 13
array([1.0, 0.8])
>>> u[1]
array([1.4, 1.1])
to make explicit which of the two array dimensions (or axes) that we are
indexing into.
The similarity of the generic mathematical notation for vector and scalar
ODEs, and the convenient algebra of NumPy arrays, indicate that the solver
implementation for scalar and system ODEs can also be very similar. This is
indeed true, and the ForwardEuler_v0 class from the previous chapter can
be made to work for ODE systems by a few minor modifications:
• Ensure that f(t,u) always returns an array.
• Inspect the initial condition u0 to see if it is a single number (scalar)
or a list/array/tuple, and make the array u either a one-dimensional or
two-dimensional array.6
If these two items are handled and initialized correctly, the rest of the code
from Section 1.2 will in fact work with no modifications.
The extended class implementation may look like:
import numpy as np
class ForwardEuler:
def __init__(self, f):
self.f = lambda t, u: np.asarray(f(t, u), float)
6
This step is not strictly needed, since we could use a two-dimensional array with
shape (N + 1, 1) for scalar ODEs. However, using a one-dimensional array for scalar
ODEs gives simpler and more intuitive indexing.
14 1 Programming a simple ODE solver
self.t = np.zeros(N + 1)
if self.neq == 1:
self.u = np.zeros(N + 1)
else:
self.u = np.zeros((N + 1, self.neq))
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):
"""Advance the solution one time step."""
u, dt, f, n, t = self.u, self.dt, self.f, self.n, self.t
return u[n] + dt * f(t[n], u[n])
It is worth commenting on some parts of this code. First, the constructor looks
almost identical to the scalar case, but we use a lambda function and the con-
venient np.asarray function to convert any f that returns a list or tuple to a
function returning a NumPy array. If f already returns an array, np.asarray
will simply return this array with no changes. This modification is not strictly
needed, since we could just assume that the user implements f to return an
array, but it makes the class more robust and flexible. We have also used the
function isscalar from NumPy in the set_initial_condition method, to
check if u0 is a single number or a NumPy array, and define the attribute
self.neq to hold the number of equations. The final modification is found
in the method solve, where the self.neq attribute is inspected and u is
initialized to a one- or two-dimensional array of the correct size. The actual
for-loop and the advance method are both identical to the previous version
of the class.
Example: ODE model for a pendulum. To demonstrate the use of the
updated ForwardEuler class, we consider a system of ODEs describing the
motion of a simple pendulum, as illustrated in Figure 1.3. This nonlinear
system is a classic physics problem, and despite its simplicity it is not possible
to find an exact analytical solution. We will formulate the system in terms
of two main variables; the angle θ and the angular velocity ω, see Figure 1.3.
For a simple pendulum with no friction, the dynamics of these two variables
is governed by
1.4 A ForwardEuler class for systems of ODEs 15
dθ
= ω, (1.3)
dt
dω g
= − sin(θ), (1.4)
dt L
where L is the length of the pendulum and g is the gravitational constant.
Eq. (1.3) follows directly from the definition of the angular velocity, while
(1.4) follows from Newton’s second law, where dω/dt is the acceleration and
the right-hand side is the tangential component of the gravitational force
acting on the pendulum, divided by its mass. To solve the system we need to
define initial conditions for both unknowns, i.e., we need to know the initial
position and velocity of the pendulum.
Fig. 1.3 Illustration of the pendulum problem. The main variables of interest are the
angle θ and its derivative ω (the angular velocity).
class Pendulum:
def __init__(self, L, g=9.81):
self.L = L
self.g = g
We see that the function returns a list, but this will automatically be wrapped
into a function returning an array by the solver class’ constructor, as men-
tioned above. The main program is not very different from the examples of the
previous chapter, except that we need to define an initial condition with two
16 1 Programming a simple ODE solver
problem = Pendulum(L=1)
solver = ForwardEuler(problem)
solver.set_initial_condition([np.pi / 4, 0])
T = 10
N = 1000
t, u = solver.solve(t_span=(0, T), N=N)
Notice that to extract each solution component we need to index into the sec-
ond index of u, using array slicing. Indexing with the first index, for instance
using u[0] or u[0,:], would give us an array of length two that contains the
solution components at the first time point. In this specific example a call
like plt.plot(t, u) would also work, and would plot both solution compo-
nents. However, we are often interested in plotting selected components of
the solution, and in this case the array slicing is needed. The resulting plot is
shown in Figure 1.4. Another minor detail worth noticing is use of Python’s
raw string format for the labels, indicated by the r in front of the string.
Raw strings will treat the backslash (\) as a regular character, and is often
needed when using Latex encoding of mathematical symbols. The observant
reader may also notice that the amplitude of the pendulum motion appears
to increase over time, which is clearly not physically correct. In fact, for an
undamped pendulum problem defined by (1.3)-(1.4), the energy is conserved,
and the amplitude should therefore be constant. The increasing amplitude is
a numerical artefact introduced by the forward Euler method, and the solu-
tion may be improved by reducing the time step or replacing the numerical
method.
u(tn+1 ) − u(tn )
u0 (tn ) ≈ . (1.5)
∆t
1.5 Checking the error in the numerical solution 17
0 2 4 6 8 10
t
Fig. 1.4 Solution of the simple pendulum problem, computed with the forward Euler
method.
(1.5), was
un+1 = u(tn ) + ∆tu0 (tn ),
which we may recognize as a Taylor series truncated after the first order term,
and we expect the error |un+1 − ûn+1 | to be proportional to ∆x2 . Since this is
the error for a single time step, the accumulated error after N ∼ 1/∆t steps is
proportional to ∆t, and the FE method is hence a first order method. As we
will see in Chapter 2, more accurate methods can be constructed by deriving
update formulas that make more terms in the Taylor expansion of the error
cancel. This process is fairly straightforward for low-order methods, e.g., of
second or third order, but it quickly gets complicated for high order solvers,
see, for instance [8] for details.
Knowing the theoretical accuracy of an ODE solver is important for a
number of reasons, and one of them is that it provides a method for verify-
ing our implementation of the solver. If we can solve a given problem and
demonstrate that the error behaves as predicted by the theory, it gives a
good indication that the solver is implemented correctly. We can illustrate
this procedure using the simple initial value problem introduced earlier;
u0 = u, u(0) = 1.
As stated above, this problem has the analytical solution u = et , and we can
use this to compute the error in our numerical solution. But how should the
error be defined? There is no unique answer to this question. For practical
applications, the so-called root-mean-square (RMS) or relative-root-mean-
square (RRMS) are commonly used error measures, defined by
v
u
u1 X N
RM SE = t (un − û(tn ))2 ,
N
n=0
v
u N
u1 X (un − û(tn ))2
RRM SE = t ,
N û(tn )2
n=0
N
X
el1 = (|ui − û(ti )|),
i=0
XN
el2 = (ui − û(ti )2 ),
i=0
N
el∞ = max(ûi − u(ti )).
i=0
While the choice of error norm may be important for certain cases, for prac-
tical applications it is usually not very important, and all the different error
measures can be expected to behave as predicted by the theory. For simplic-
ity, we will apply an even simpler error measure for our example, where we
simply compute the error at the final time T , given by e = |uN − û(tN )|. Us-
ing the ForwardEuler class introduced above, the complete code for checking
the convergence may look as follows:
from forward_euler_class_v1 import ForwardEuler
import numpy as np
def exact(t):
return np.exp(t)
solver = ForwardEuler(rhs)
solver.set_initial_condition(1.0)
T = 3.0
t_span = (0,T)
N = 30
Most of the lines are identical to the previous programs, but we have put the
call to the solve method inside a for loop, and the last line ensures that the
number of time steps N is doubled for each iteration of the loop. Notice also
the f-string format specifiers, for instance {dt:<14.7f}, which sets the output
to be a left aligned decimal number with seven decimals, and occupying 14
characters in total. The purpose of these specifiers is to output the numbers as
vertically aligned columns, which improves readability and may be important
for visually inspecting the convergence. See, for instance, [16] for a brief
20 1 Programming a simple ODE solver
introduction to f-strings and format specifiers. The program will produce the
following output:
Time step (dt) Error (e) e/dt
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
In the rightmost column we see that the error divided by the time step is
approximately constant, which supports the theoretical result that the error
is proportional to ∆t. In subsequent chapters we will perform similar calcu-
lations for higher order methods, to confirm that the error is proportional to
∆tr , where r is the theoretical order of convergence for the method.
In order to compute the error in our numerical solution we obviously need
to know the true solution to our initial value problem. This part was easy
for the simple example above, since we knew the analytical solution to the
equation, but this solution is only available for very simple ODE problems.
It is, however, often interesting to estimate the error and the order of con-
vergence for more complex problems, and for this task we need to take a dif-
ferent approach. Several alternatives exist, including for instance the method
of manufactured solutions, where one simply choses a solution function u(t)
and computes its derivative analytically to determine the right-hand side of
the ODE. An even simpler approach, which usually works well, is to compute
a very accurate numerical solution using a high-order solver and small time
steps, and use this solution as the reference for computing the error. For ac-
curate error estimates it is of course essential that the reference solution is
considerably more accurate than the numerical solution we want to evaluate.
The reference solution therefore typically requires very small time steps and
can take some time to compute, but in most cases the computation time will
not be a problem.
problem = Pendulum(L = 1)
t_span = (0, 10.0)
u0 = (np.pi/4, 0)
plt.plot(solution.t, solution.y[0,:])
plt.plot(solution.t, solution.y[1,:])
plt.legend([r’$\theta$’,r’$\omega$’])
plt.show()
Running this code will result in a plot similar to Figure 1.5, and we ob-
serve that the solution does not look nearly as nice as the one we got from
the ForwardEuler solver above. The reason for this apparent error is that
solve_ivp is an adaptive solver, which chooses the time step automatically to
satisfy a given error tolerance. The default value of this tolerance is relatively
large, which results in the solver using very few time steps and the solution
plots looking jagged. If we compare the plot with a very accurate numerical
solution, indicated by the two dotted curves in Figure 1.5, we see that the
solution at the time points tn is quite accurate, but the linear interpolation
between the time points completely destroys the visual appearance. A more
visually appealing solution can be obtained in several ways. We may, for in-
stance, pass the function an additional argument t_eval, which is a NumPy
array containing the time points where we want to evaluate the solution:
t_eval = np.linspace(0, 10.0, 1001)
solution = solve_ivp(problem, t_span, u0,t_eval=t_eval)
Alternatively, we can reduce the error tolerance of the solver, for instance
setting
rtol = 1e-6
solution = solve_ivp(problem, t_span, u0, rtol=rtol)
7
See https://scipy.org/
22 1 Programming a simple ODE solver
0 2 4 6 8 10
t
Fig. 1.5 Solution of the simple pendulum problem, computed with the SciPy
solve_ivp function and the default tolerance.
This latter call will reduce the relative tolerance rtol from its default value of
1e-3 (0.001). We could also adjust the absolute tolerance using the parameter
atol. While we will not consider all the possible arguments and options to
solve_ivp here, we mention that we can also change the numerical method
used by the function, by passing in a parameter named method. For instance,
a call like
rtol = 1e-6
solution = solve_ivp(problem, t_span, u0, method=’Radau’)
will replace the default solver (called rk45) with an implicit Radau ODE
solver, which we will introduce and explain in Chapter 3. For a complete
description of parameters accepted by the solve_ivp function we refer to
the online SciPy documentation.
Chapter 2
Improving the accuracy
23
24 2 Improving the accuracy
un+1 = un + ∆t f (tn , un ),
k1 = f (tn , un ),
un+1 = un + ∆tk1 .
It can easily be verified that this is the same formula as introduced above,
and there is no real benefit from writing the formula in two lines rather than
one. However, this second formulation is more in line with how Runge-Kutta
methods are usually written, and it makes it easy to see the relation between
the FE method and more advanced solvers. The intermediate value k1 is often
referred to as a stage derivative in the ODE literature.
We can easily improve the accuracy of the FE method to second order,
i.e., error proportional to ∆t2 , by introducing more accurate approximations
of the integral in (2.1). One option is to keep the assumption that f (t, u(t))
is constant over tn ≤ t∗ ≤ tn+1 , but to approximate it at the middle of the
interval rather than the left end. This approach requires one additional stage:
k1 = f (tn , un ), (2.2)
∆t ∆t
k2 = f (tn + , un + k1 ), (2.3)
2 2
un+1 = un + ∆t k2 . (2.4)
This method is known as the explicit midpoint method or the modified Euler
method. The first step is identical to that of the FE method, but instead of
using the stage derivate k1 to advance the solution to the next step, we use
it to compute an intermediate midpoint solution
∆t
un+1/2 = un + k1 .
2
This solution is then used to compute the corresponding stage derivative k2 ,
which becomes an approximation to the derivative of u at time tn + ∆t/2.
Finally, we use this midpoint derivative to advance the solution to tn+1 .
An alternative second order method is Heun’s method, also referred to as
the explicit trapezoidal method, which can be derived by approximating the
integral in (2.1) by a trapezoidal rule:
k1 = f (tn , un ), (2.5)
k2 = f (tn + ∆t, un + ∆tk1 ), (2.6)
∆t
un+1 = un + (k1 + k2 ). (2.7)
2
This method also computes two stage derivatives k1 and k2 , but from the
formula for k2 we see that it approximates the derivative at tn+1 rather than
26 2 Improving the accuracy
ci a11 · · · a1s
.. .. ..
. . .
cs as1 · · · ass
b1 · · · bs
The Butcher tableaus of the three methods considered above; FE, explicit
midpoint, and Heun’s method, are
0 0 0 0 0 0
00
, 1/2 1/2 0 , 1 1 0 ,
1
0 1 1/2 1/2
0 0 0 0 0
1/2 1/2 0 0 0
1/2 0 1/2 0 0 ,
1 0 0 1 0
1/6 1/3 1/3 1/6
k1 = f (tn , un ), (2.10)
∆t ∆t
k2 = f (tn + , un + k1 ), (2.11)
2 2
∆t ∆t
k3 = f (tn + , un + k2 ), (2.12)
2 2
k4 = f (tn + ∆t, un + ∆tk3 ), (2.13)
∆t
un+1 = un + (k1 + 2k2 + 2k3 + k4 ) . (2.14)
6
As mentioned above, all the methods considered in this chapter are explicit
methods, which means that aij = 0 for j ≥ i. As may be observed from (2.10)-
(2.14), or from a more careful inspection of the general formula (2.8), this
means that the expression for computing each stage derivative ki only in-
cludes previously computed stage derivatives. Therefore, all ki can be com-
puted sequentially using explicit formulae. For implicit Runge-Kutta meth-
ods, on the other hand, we have aij 6= 0 for some j ≥ i. We see from (2.8) that
the formula for computing ki will then include ki on the right-hand side, as
28 2 Improving the accuracy
class ODESolver:
def __init__(self, f):
# Wrap user’s f in a new function that always
# converts list/tuple to array (or let array be array)
self.model = f
self.f = lambda t, u: np.asarray(f(t, u), float)
self.neq = 1 # no of equations
u0 = float(u0)
else: # system of ODEs
u0 = np.asarray(u0)
self.neq = u0.size # no of equations
self.u0 = u0
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 base class")
Notice that the ODESolver is meant to be a pure superclass, and the im-
plementation of the advance method is left for subclasses. In order to make
this abstract nature of the class explicit, we have implemented an advance
method that will simply raise a NotImplementedError when it is called. If
we try to make an instance of ODESolver and use it as a stand-alone solver,
we will get an error in the line self.u[n + 1] = self.advance(). If we had
left out the definition of advance completely we would get an error from the
same line, but it would be a less informative AttributeError. Raising the
NotImplementedError makes it clear to anyone reading or using the code
that this behavior is intentional, and that the functionality is to be imple-
mented in subclasses. It should be noted that there are alternative ways in
Python to make explicit the abstract nature of the ODESolver class, for in-
stance using the module abc, for "Abstract Base Class". However, while this
solution may be considered more modern, we have decided to not use it here,
in the interest of keeping the code simple and compact.
It is also worth commenting on the solve method,
30 2 Improving the accuracy
class RungeKutta4(ODESolver):
def advance(self):
u, f, n, t = self.u, self.f, self.n, self.t
dt = self.dt
dt2 = dt / 2.0
k1 = f(t[n], u[n],)
k2 = f(t[n] + dt2, u[n] + dt2 * k1, )
k3 = f(t[n] + dt2, u[n] + dt2 * k2, )
k4 = f(t[n] + dt, u[n] + dt * k3, )
return u[n] + (dt / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
t_span = (0, 3)
N = 6
fe = ForwardEuler(f)
fe.set_initial_condition(u0=1)
t1, u1 = fe.solve(t_span, N)
plt.plot(t1, u1, label=’Forward Euler’)
em = ExplicitMidpoint(f)
em.set_initial_condition(u0=1)
t2, u2 = em.solve(t_span, N)
2.2 A class hierarchy of Runge-Kutta methods 31
rk4 = RungeKutta4(f)
rk4.set_initial_condition(u0=1)
t3, u3 = rk4.solve(t_span, N)
plt.plot(t3, u3, label=’Runge-Kutta 4’)
This code will solve the same simple equation using three different methods,
and plot the solutions in the same window, as shown in Figure 2.1. We set N
= 6, which corresponds to a very large time step (∆t = 0.5), to highlight the
difference in accuracy between the methods.
10.0
7.5
5.0
2.5
def exact(t):
return np.exp(t)
T = 3.0
t_span = (0, T)
N = 30
print(f’{solver_class.__name__}, order = {order}’)
print(f’Time step (dt) Error (e) e/dt**{order}’)
for _ in range(10):
t, u = solver.solve(t_span, N)
dt = T / N
e = abs(u[-1] - exact(T))
if e < 1e-13: # break if error is close to machine precision
break
print(f’{dt:<14.7f} {e:<12.7f} {e/dt**order:5.4f}’)
N = N * 2
The code is nearly identical to the FE convergence test in Section 1.5, ex-
cept that we loop over a list of tuples that contain the four method classes
and their corresponding order. The output is also nearly identical to the
previous version, but repeated for all four solvers, and we use the built-in
class attribute __name__ to extract and print the name of each solver. Three
columns are written to the screen, containing, respectively, the time step ∆t,
the error e at time t = 3.0, and finally e/∆tp , where p is the order of the
method. For the two first methods the output is exactly as expected, and
2.3 Testing the solvers 33
it is therefore not shown here. The numbers in the rightmost column are
approximately constant, confirming that the error is in fact proportional to
∆tp . However, the last part of the output, for the forth order Runge-Kutta
method, looks like this:
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 see that the e/∆tp numbers are close to constant for a while, in accordance
with the convergence order of the method, but then increase for the smallest
values of ∆t. This behavior is not uncommon to observe in convergence tests
like this, in particular for high-order methods, and it is caused by the finite
accuracy of number representation on a computer. As the numerical errors
become smaller and approach the machine precision (≈ 10−16 ), roundoff error
start to dominate the overall error and convergence is lost.
There are many alternative ways to check the implementation of ODE
solvers. One option is to consider an even simpler ODE, where the right
hand side is a constant, i.e., u0 (t) = f (t, u) = C. The solution to this simple
ODE is of course u(t) = Ct + u0 , where u0 is the initial condition. All the nu-
merical methods considered in this book will capture this solution to machine
precision, and we can write a general test function which takes advantage of
this:
def test_exact_numerical_solution():
solver_classes = [ForwardEuler, Heun,
ExplicitMidpoint, RungeKutta4]
a = 0.2
b = 3
def u_exact(t):
"""Exact u(t) corresponding to f above."""
return a * t + b
u0 = u_exact(0)
T = 8
N = 10
tol = 1E-14
t_span = (0, T)
34 2 Improving the accuracy
Similar to the convergence check illustrated below, this code will loop through
all the solver classes, solve the simple ODE, and check that the resulting error
is within the tolerance.
Both of the methods shown here to verify the implementation of our solvers
have some limitations. The most important one is that they both solve very
simple ODEs, and it is entirely possible to introduce errors in the code that
will only present themselves for more complex problems. However, they have
the advantage of being simple and completely general, and can easily be ap-
plied to any newly implemented ODE solver class. Many common implemen-
tation errors, for instance getting a single parameter wrong in Runge-Kutta
method, will often show up even for this simple problems. They can therefore
provide a good initial indication that the implementation is correct, which
can be followed by more extensive tests if needed.
Chapter 3
Stable solvers for stiff ODE systems
One very famous example of a stiff ODE system is the Van der Pol equation,
which can be written as an initial value problem on the form
35
36 3 Stable solvers for stiff ODE systems
this system and solves it with the ForwardEuler subclass of the ODESolver
class hierarchy.
from ODESolver import *
import numpy as np
import matplotlib.pyplot as plt
class VanderPol:
def __init__(self,mu):
self.mu = mu
def __call__(self,t,u):
du1 = u[1]
du2 = self.mu*(1-u[0]**2)*u[1]-u[0]
return du1,du2
model = VanderPol(mu=1)
solver = ForwardEuler(model)
solver.set_initial_condition([1,0])
t,u = solver.solve(t_span=(0,20),N=1000)
plt.plot(t,u)
plt.show()
Figure 3.1 shows the solutions for µ = 0, 1 and 5. Setting µ even higher, for
instance µ = 50, leads to a divergent (unstable) solution with the time step
used here (∆t = 0.02). Replacing the FE method with one of the more ac-
curate ERK method may help a little, but not much. It does help to reduce
the time step dramatically, but the resulting computation time may be sub-
stantial. The time step for this problem is dictated by stability requirements
rather than our desired accuracy, and there may be significant gains from
choosing a solver that is more stable than the ERK methods considered so
far.
Before introducing more stable solvers, it is useful to examine the observed
stability problems in a bit more detail. Why does the solution of the Van der
Pol model fail so badly for large values of µ? And, more generally, what
are the properties of an ODE system that makes it stiff? To answer these
questions, it is useful to start with a simpler problem than the Van der Pol
model. Consider, for instance, a simple IVP known as the Dahlquist test
equation;
=0
1
0
1
=1
2
0
2
=5
5
0
5
0.0 2.5 5.0 7.5 10.0 12.5 15.0 17.5 20.0
Fig. 3.1 Solutions of the Van der Pol model for different values of µ.
b<(λ) −1.
For more general non-linear problems, such as the Van der Pol model in (3.1)-
(3.2), the system’s stiffness is characterized by the eigenvalues λi of the local
Jacobian matrix J of the right-hand side function f . The Jacobian is defined
by
∂fi (t, y)
Jij = ,
∂yj
1
Note that the implementation of the solvers in this book does not support solving
this ODE for complex λ. Allowing complex values in the stability analysis is still relevant,
since for systems of ODEs the relevant values are the eigenvalues of the right-hand side,
and these may be complex.
38 3 Stable solvers for stiff ODE systems
These definitions show that the stiffness of a problem is not only a function
of the ODE itself, but also of the length of the solution interval (b), which
may be a bit surprising. We can get an understanding of why the interval
of interest is important by looking at (3.3). If λ is large and negative, we
need to choose a small ∆t to maintain stability of explicit solvers, as will be
discussed in detail below. However, if we are only interested in solving the
equation over a very small time interval, i.e., b is small, using a small ∆t is
not really a problem, and by the definition above the problem will no longer
be stiff. In the ODE literature one will also find more pragmatic definitions
of stiffness, for instance that an equation is stiff if the time step needed to
maintain stability of an explicit method is much smaller than the time step
dictated by the accuracy requirements [1, 2]. A detailed discussion of stiff
ODE systems can be found in, for instance, [1, 9].
Eq. (3.3) is the foundation for linear stability analysis, which is a very
useful technique for analyzing and understanding the stability of ODE solvers.
The solution to the equation is u(t) = eλt , which obviously grows very rapidly
if λ has a positive real part. We are therefore primarily interested in the case
<(λ) < 0, for which the analytical solution is stable, but our choice of solver
may introduce numerical instabilities. The Forward Euler method applied to
(3.3) gives the update formula
u1 = 1 + ∆tλ. (3.4)
The analytical solution decays exponentially for <(λ) < 0, and it is natu-
ral to require the same behavior of the numerical solution, and this gives
the requirement that |1 + ∆tλ| ≤ 1. If λ is real and negative, the time step
must be chosen to satisfy ∆t ≤ −2/λ to ensure stability. Keep in mind that
this criterion does not necessarily give a very accurate solution, and it may
even oscillate and look completely different from the exact solution. However,
choosing ∆t to satisfy the stability criterion ensures that the solution, as well
as any spurious oscillations and other numerical artefacts, decay with time.
We have observed that the right-hand side of (3.4) contains critical infor-
mation about the stability of the FE method. This expression is often called
the stability function or the amplification factor of the method, and is written
on the general form
R(z) = 1 + z.
3.1 Stiff ODE systems and stability 39
The FE method is stable for all values λ∆t which give |R(λ∆t)| < 1, and
this region of λ∆t values in the complex plane is referred to as the method’s
region of absolute stability, or simply its stability region. The stability region
for the FE method is shown in the left panel of Figure 3.2. The stability
domain is a circle with center (−1, 1) and radius one. Obviously, if λ 0,
require λ∆t to lie within this circle is quite restrictive for the choice of ∆t.
0
1
2
3
2 1 0 1 2 1 0 1 2 1 0 1
Re(z)
Fig. 3.2 Stability regions for explicit Runge-Kutta methods. From left: forward Euler,
explicit midpoint, and the fourth order method given by (2.10)-(2.14).
We can easily extend the linear stability analysis to the other explicit RK
methods introduced in Chapter 2. For instance, applying a single step of the
explicit midpoint method given by (2.2)-(2.4) to (3.3) gives
(∆tλ)2
u(∆t) = 1 + λ∆t + ,
2
and we identify the stability function for this method as
z2
R(z) = 1 + z + .
2
The corresponding stability region is shown in the middle panel of Figure 3.2.
For the fourth order RK method defined in (2.10)-(2.14), the same steps reveal
40 3 Stable solvers for stiff ODE systems
z2 z3 z4
R(z) = 1 + z + + + ,
2 6 24
and the stability region is shown in the right panel of Figure 3.2. We observe
that the stability regions of the two higher-order RK methods are larger than
that of the FE method, but not much. In fact, if we consider the computa-
tional cost of each time step for these methods, the FE method is usually
superior for problems where the time step is governed by stability.
It can be shown that the stability function for an s-stage explicit RK
method is always a polynomial of degree ≤ s, and it can easily be verified
that the stability region defined by such a polynomial will never be very large.
To obtain a significant improvement of this situation, we typically need to
replace the explicit methods considered so far with implicit RK methods.
right-hand side function f (t, u). Therefore, for nonlinear f , (3.5) is a nonlin-
ear algebraic equation that must be solved for the unknown un+1 , instead
of the explicit update formula we had for the FE method. This requirement
makes implicit methods more complex to implement than explicit methods,
and they tend to require far more computations per time step. Still, as we
will demonstrate later, the superior stability properties still make implicit
solvers better suited for stiff problems.
We will consider the implementation of implicit solvers in Section 3.3 be-
low, but let us first study the stability of the BE method and other implicit
RK solvers using the linear stability analysis introduced above. Applying the
BE method to (3.3) yields
un+1 (1 − ∆tλ) = un ,
The explicit midpoint and trapezoidal methods mentioned above also have
their implicit counterparts. The implicit midpoint method is given by idxmid-
point method !implicit
0
1
2
3
2 1 0 1 2 2 1 0 1 2
Re(z) Re(z)
Fig. 3.3 Stability regions for the backward Euler method (left) and the implicit mid-
point method and trapezoidal method (right).
k1 = f (tn , un ), (3.10)
k2 = f (tn + ∆t, un + ∆tk2 ), (3.11)
∆t
un+1 = un + (k1 + k2 ). (3.12)
2
Note that this formulation of the Crank-Nicolson is not very common, and can
be simplified considerably by eliminating the stage derivatives and defining
the method in terms of un and un+1 . However, the formulation in (3.10)-
(3.12) is convenient for highlighting that it is in fact an implicit RK method.
The implicit nature of the simple methods above is apparent from the for-
mulas; one of the stage derivatives must be computed by solving an equation
involving the non-linear function f rather than from an explicit update for-
mula. The Butcher tableaus of the three methods are given by
0
11 1/2 1/2
, , 1 0 1 , (3.13)
1 1
1/2 1/2
from left to right for backward Euler, implicit midpoint and the implicit
trapezoidal method.
3.3 Implementing implicit Runge-Kutta methods 43
The implicit midpoint method is and the implicit trapezoidal method have
the same stability function, given by R(z) = (2 + z)/(2 − z). The correspond-
ing stability domain covers the entire left half-plane of the complex plane,
shown in the right panel of Figure 3.3. Both the implicit midpoint method
and the trapezoidal method are therefore A-stable methods. However, we
have R(z) → 1 as z → −∞, so the methods do not have stiff decay and
are therefore not L-stable. In general, the stability functions of implicit RK
methods are always rational functions, i.e., given by
P (z)
R(z) = ,
Q(z)
where P, Q are polynomials of degree at most s. (Recall from Section 3.1 above
that the stability functions for the explicit methods are always polynomials
of degree at most s.)
The accuracy of the implicit methods considered above can easily be calcu-
lated using a Taylor series expansion as outlined in Section 1.5, and confirms
that the backward Euler method is first order accurate while the two oth-
ers are second order methods. We mentioned above that an explicit Runge-
Kutta method with s stages has order p ≤ s, but with implicit methods we
have greater freedom in choosing the coefficients aij and therefore poten-
tially higher accuracy for a given number of stages. In fact, the maximum
order for an implicit RK method is p = 2s, which is precisely the case for
the implicit midpoint method, having s = 1 and p = 2. We will consider more
advanced implicit RK methods later, but let us first have a look at how we
can implement the methods introduced so far.
class BackwardEuler(ODESolver):
def stage_eq(self,k):
u, f, n, t = self.u, self.f, self.n, self.t
dt = self.dt
return k - f(t[n]+dt,u[n]+dt*k)
def solve_stage(self):
u, f, n, t = self.u, self.f, self.n, self.t
k0 = f(t[n],u[n])
sol = root(self.stage_eq,k0)
return sol.x
def advance(self):
u, f, n, t = self.u, self.f, self.n, self.t
dt = self.dt
k1 = self.solve_stage()
return u[n]+dt*k1
3.3 Implementing implicit Runge-Kutta methods 45
Reference solution
10
0
10
ForwardEuler, t = 0.04
10
0
10
BackwardEuler, t = 0.04
10
0
10
0.0 2.5 5.0 7.5 10.0 12.5 15.0 17.5 20.0
Fig. 3.4 Solutions of the Van der Pol model for µ = 10, using the forward and backward
Euler methods with ∆t = 0.04.
Euler method gives the plots shown in Figure 3.4. The top panel shows a
reference solution computed with the SciPy solve_ivp solver and very low
tolerance (rtol=1e-10). The middle panel shows the solution produced by
forward Euler with ∆t = 0.04, showing visible oscillations in one of the solu-
tion components. Increasing the time step further leads to a divergent solu-
tion. The lower panel shows the solution from backward Euler with ∆t = 0.04,
which is obviously a lot more stable, but still quite different from the reference
solution in the top panel. With the backward Euler method, increasing the
time step further will still give a stable solution, but it does not look like the
exact solution at all. This little experiment illustrates the need to consider
both accuracy and stability when solving challenging ODEs systems.2
Just as we did for the explicit methods in Chapter 2, it is possible to reuse
code from the BackwardEuler class to implement other solvers. Extensive
code reuse for a large group of implicit solvers requires a small rewrite of
the code above to a more general form, which will be presented in the next
section. However, we may observe that a simple solver like the Crank-Nicolson
method can be realized as a very small modification of our BackwardEuler
class. A class implementation may look like
class CrankNicolson(BackwardEuler):
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 = self.solve_stage()
return u[n]+dt/2*(k1+k2)
Here, we utilize the fact that the stage k1 in the Crank-Nicolson is explicit
and does not require solving an equation, while the definition of k2 is identi-
cal to the definition of k1 in the backward Euler method. We can therefore
reuse both the stage_eq and solve_stage methods directly and only the
advance method needs to be reimplemented. This compact implementation
of the Crank-Nicolson method is convenient for code reuse, but it may be
argued that it violates a common principle of object-oriented programming.
Subclassing and inheritance is considered an "is-a" relationship, so this class
implementation implies that an instance of the Crank-Nicolson class is also
an instance of the BackwardEuler class. While this works fine in the program,
and is convenient for code reuse, it is not a correct representation of the rela-
tionship between the two numerical methods. The Crank-Nicolson method is
not a special case of the backward Euler, but, as noted above, both methods
belong to the group of implicit RK solvers. In the following sections we will
describe an alternative class hierarchy which is based on this relationship, and
2
Note that the accompanying source code for the book includes a script to produce
Figure 3.4, as well as many of the other figures in the book. It is a good exercise to run
these scripts on your own and adjust the time step and other parameters, to get a better
understanding of how the solvers work.
3.4 Implicit methods of higher order 47
Just as for the ERK methods considered in Chapter 2, the accuracy of IRK
methods can be increased by adding more stages. However, for implicit meth-
ods we have even more freedom in choosing the parameters aij , and the choice
of these impacts both the accuracy and the computational complexity of the
methods. We will here consider two main branches of IRK methods; the
so-called fully implicit methods and the diagonally implicit methods. Both
classes of methods are quite popular and commonly used, and both have their
advantages and drawbacks.
The most general form of RK methods are the fully implicit methods, often
referred to as FIRK methods. These solvers are simply defined by (2.8)-(2.9),
with all coefficients aij (potentially) non-zero. For a method with more than
one stage, this formulation implies that all stage derivatives depend on all
other stage derivatives, so we need to determine them all at once by solving a
single system of non-linear equations. This operation is quite expensive, but
the reward is that the FIRK methods have superior stability and accuracy
for a given number of stages. A FIRK method with s stages can have order at
most 2s, which was the case for the implicit midpoint method in (3.8)-(3.9).
Many of the most popular FIRK methods are based on combining standard
quadrature methods for numerical integration with the idea of collocation. We
present the basic idea of the derivation here, since many important methods
are based on the same foundation. For the complete details we refer to, for
instance, [9]. Recall from Chapter 2 that all Runge-Kutta methods can be
viewed as approximations of (2.1), where the integral is approximated by a
weighted sum. We set
Z tn+1 s
X
u(tn+1 ) = u(tn ) + f (t, u(t)) ≈ u(tn ) + bi ki ,
tn i=1
where bi are the weights and ki are the stage derivatives, which could be in-
terpreted as approximations of the right-hand side function f (t, u) at distinct
time points tn + ∆tci .
Numerical integration is a very well studied branch of numerical analysis,
and it is natural to choose the integration points ci and weights bi based on
48 3 Stable solvers for stiff ODE systems
The Radau IIA methods have order 2s − 1, and their stability functions are
(s − 1, s) Padé approximations to the exponential function, see [9] for details.
For the two- and three-stage methods above, the stability functions are
3.4 Implicit methods of higher order 49
1 + z/3
R(z) = ,
1 − 2z/3 + z 2 /6
1 + 2z/5 + z 2 /20
R(z) = ,
1 − 3z/5 + 3z 2 /20 − z 2 /60
respectively, with stability domains shown in Figure 3.5. The methods are
L-stable, which makes them a popular choice for solving stiff ODE systems.
However, as noted above, the fact that all aij 6= 0 complicates the implemen-
tation of the methods and makes each time step computationally expensive.
All the s equations of (2.8) become fully coupled and need to be solved si-
multaneously. For an ODE system consisting of m ODEs, we need to solve a
system of ms non-linear equations for each time step. We will come back the
implementation of FIRK methods in Section 3.5, but let us first introduce a
slightly simpler class of implicit RK solvers.
Radau s = 2 Radau s = 3
8
6
4
2
Im(z)
0
2
4
6
8
2 0 2 4 6 8 10 2 0 2 4 6 8 10
Re(z) Re(z)
Fig. 3.5 The shaded area is the stability region for two of the RadauIIA methods,
with s = 2 (left) and s = 3 (right).
50 3 Stable solvers for stiff ODE systems
Diagonally implicit RK methods, or DIRK methods for short, are also some-
times also referred to as semi-explicit methods. For these methods, we have
aij = 0 for all j > i. (Notice the small but important difference from the
explicit methods, where we have aij = 0 for j ≥ i.) The consequence of this
choice is that the equation for a single stage derivative ki does not involve
stages ki+1 , ki+2 , and so forth, and we can therefore solve for the stage deriva-
tives one by one sequentially. We still need to solve non-linear equations to
determine each ki , but we can solve s systems of m equations rather than
one large system to compute all the stages at once. This property simplifies
the implementation and reduces the computational expense per time step.
However, as expected, the restriction on the method coefficients reduces the
accuracy and stability compared with the FIRK methods. A general DIRK
method with s stages has maximum order s + 1, and methods optimized for
stability typically have even lower order.
From the definition of the DIRK methods, we may observe that the im-
plicit midpoint method introduced above is, technically, a DIRK method.
However, this method is also a fully implicit Gauss method, and is not com-
monly referred to as a DIRK method. The distinction between FIRK and
DIRK methods is only meaningful for s > 1. The Crank-Nicolson (implicit
trapezoidal) method given by (3.10)-(3.12) is also a DIRK method, which is
evident from the rightmost Butcher tableau in (3.13). These methods are,
however, only A-stable, and it is possible to derive DIRK methods with bet-
ter stability properties. An example of an L-stable, two-stage DIRK method
of order two is given by
γ γ 0
1 1−γ γ , (3.15)
1−γ γ
with stability function
1 + z(1 − 2γ)
R(z) = .
(1 − zγ)2
√
The method is A-stable for γ > 1/4, and for γ = 1 ± 2/2 the method is
L-stable and second order accurate. Note that choosing γ > 1 means that
we estimate the stage derivatives outside the interval (tn , tn+1 ), and for the
last step outside the time interval of interest. While this does not affect
the stability or accuracy of the method it may not make sense √ for all ODE
problems, and the most popular choice is therefore γ = 1 − 2/2 (≈ 0.293).
Notice also that in this method the two diagonal entries of aij are identical,
we have a11 = a22 = γ. This choice is very common in DIRK methods, and
methods of this kind are usually referred to as singly diagonally implicit
RK (SDIRK) methods. The main benefit of this structure is that the non-
linear equations for each stage derivative become very similar, which can
be utilized when solving the equations with quasi-Newton methods. This
3.4 Implicit methods of higher order 51
benefit may not be very obvious for the examples in this book, since we rely
on the generic root function from scipy.optimize to solve all the non-linear
equations. However, if we wanted to improve the computational performance
of the solvers, a natural place to start would be to implement our own quasi-
Newton solver, which takes advantage of the particular structure of the non-
linear equations. We will not go into the details of such an implementation
here, but it is worth commenting on some aspects in order to understand the
popularity of the SDIRK methods. The central point is that when applying
Newton’s method to solve a general non-linear system g(u) = 0, each iteration
involves solving linear systems on the form Jg ∆u = −g(uk ), where ∆u is the
increment to the solution, uk is the solution value at the previous iteration,
and Jg is the Jacobian matrix of g, defined by
∂gi
Jg = .
∂uj
For a general DIRK method, the non-linear equation to compute stage deriva-
tive ki is given by
i
X
ki = f (tn + ci ∆t, un + ∆ aij kj ),
j=1
Note that we have split the sum over the stage derivatives, to highlight the
fact that when solving for ki , the values kj for j < i are known. The Jacobian
Jg is found by differentiating g with respect to ki , to get
Jg = I − ∆taii Jf ,
as initial guess for the next one, which will usually provide a good initial guess.
This approach is obviously not possible for the first stage, but an explicit for-
mula for the first stage solves this problem. The simplest ESDIRK method
is the implicit trapezoidal (Crank-Nicolson) method introduced above, and a
popular extension of this method is given by
0 0
2γ γ γ 0
, (3.16)
1 ββγ
ββγ
√ √
with γ = 1 − 2/2 and β = 2/4. The resulting equations for each time step
are
k1 = f (tn , un ),
k2 = f (tn + 2γ∆t, un + ∆t(γk1 + γk2 )),
k3 = f (tn + ∆t, un + ∆t(βk1 + βk2 + γk3 )),
un+1 = un + ∆t(βk1 + βk2 + γk3 ).
class ImplicitRK(ODESolver):
def solve_stages(self):
u, f, n, t = self.u, self.f, self.n, self.t
s = self.stages
k0 = f(t[n], u[n])
k0 = np.tile(k0,s)
return np.split(sol.x, s)
res = np.zeros_like(k_all)
k = np.split(k_all, s)
for i in range(s):
fi = f(t[n] + c[i] * dt, u[n] + dt *
sum([a[i, j] * k[j] for j in range(s)]))
res[i * neq:(i + 1) * neq] = k[i] - fi
return res
def advance(self):
b = self.b
u, n, t = self.u, self.n, self.t
dt = self.dt
k = self.solve_stages()
Note that we assume that the method parameters are assumed to be held
in NumPy arrays self.a, self.b, self.c, which need to be defined in
subclasses. The ImplicitRK class is meant to be a pure base class for holding
common code, and is not intended to be a usable solver class in itself. As
described in Section 2.2, we could make explicit this abstract nature by using
the abc module, but for the present text we focus on the fundamentals of the
solvers and the class structure, and keep the code as simple and compact as
possible.
The three methods are generalizations of the same methods in BackwardEuler
class, and perform the same tasks, but the abstraction level is higher and the
methods rely on a bit of NumPy magic:
• The solve_stages method is obviously a generalization of the solve_stage
method above, and most of the lines are quite similar and should be self-
explanatory. However, be aware that we are now implementing a general
IRK method with s stages, and we solve a single system of non-linear
equations to determine all s stage derivatives at once. The solution of this
system is a one-dimensional array of length self.stages * self.neq,
which contains all the stage derivatives. The line k0 = np.tile(k0,s)
takes an initial guess k0 for a single stage, and simply stacks it after itself
s times to create the initial guess for all the stages, using NumPy’s tile
function.
• The stage_eq method is also a pure generalization of the BackwardEuler
version, and performs the same tasks. The first few lines should be self-
explanatory, while the res = np.zeros_like(k_all) defines an array of
the correct length to hold the residual of the equation. Then, for conve-
nience, the line k = np.split(k_all,s) splits the array k_all into a list
k containing the individual stage derivatives, which is used inside the for
loop on the next four lines. This loop forms the core of the method, and is
essentially just (2.8) implemented in Python code, split over several lines
for improved readability. The residual is returned as a single array of length
self.stages * self.neq, as expected by the SciPy root function.
• Finally, the advance method calls the solve_stages to compute all the
stage derivatives, and then advances the solution using a general imple-
mentation of (2.9).
With the general base class at hand, we can easily implement new solvers,
simply by writing the constructors that define the method coefficients. The
following code implements the implicit midpoint and the two- and three-stage
Radau methods:
class ImplicitMidpoint(ImplicitRK):
def __init__(self, f):
super().__init__(f)
self.stages = 1
self.a = np.array([[1 / 2]])
self.c = np.array([1 / 2])
self.b = np.array([1])
3.5 Implementing higher order IRK methods 55
class Radau2(ImplicitRK):
def __init__(self, f):
super().__init__(f)
self.stages = 2
self.a = np.array([[5 / 12, -1 / 12], [3 / 4, 1 / 4]])
self.c = np.array([1 / 3, 1])
self.b = np.array([3 / 4, 1 / 4])
class Radau3(ImplicitRK):
def __init__(self, f):
super().__init__(f)
self.stages = 3
sq6 = np.sqrt(6)
self.a = np.array([[(88 - 7 * sq6) / 360,
(296 - 169 * sq6) / 1800,
(-2 + 3 * sq6) / (225)],
[(296 + 169 * sq6) / 1800,
(88 + 7 * sq6) / 360,
(-2 - 3 * sq6) / (225)],
[(16 - sq6) / 36, (16 + sq6) / 36, 1 / 9]])
self.c = np.array([(4 - sq6) / 10, (4 + sq6) / 10, 1])
self.b = np.array([(16 - sq6) / 36, (16 + sq6) / 36, 1 / 9])
Notice that we always define the method coefficients as NumPy arrays, even
for the implicit midpoint method where they all contain a single number.
This definition is necessary for the generic methods of the ImplicitRK class
to work.
Here, (3.17) is nearly identical to the equation defining the stage derivative
in the backward Euler method, the only difference being the factor γ in front
of the arguments inside the function call. Furthermore, the only difference
between (3.17) and (3.18) is the additional term ∆t(1 − γ)k1 inside the func-
tion call. In general, any stage equation for any DIRK method can be written
as
i−1
X
ki = f (tn + ci ∆t, un + ∆t( aij kj + γki )), (3.20)
j=0
where the sum inside the function call only includes previously computed
stages.
Given the similarity of (3.20) with the stage equation from the backward
Euler method, it is natural to implement the SDIRK stage equation as a gen-
eralization of the stage_eq method from the BackwardEuler class. It is also
convenient to place this method in an SDIRK base class, from which we may
derive all specific SDIRK solver classes. Furthermore, since the stage equa-
tions can be written on this general form, it is not difficult to generalize the
algorithm for looping through the stages and computing the individual stage
derivatives. The base class can, therefore, contain general SDIRK versions of
both the stage_eq and solve_stages, and the only task left in individual
solver classes is to define the number of stages and the method coefficients.
The complete base class implementation may look as follows.
class SDIRK(ImplicitRK):
def stage_eq(self,k,c_i, k_sum):
u, f, n, t = self.u, self.f, self.n, self.t
dt = self.dt
gamma = self.gamma
return k - f(t[n]+c_i*dt,u[n]+dt*(k_sum+gamma*k))
def solve_stages(self):
u, f, n, t = self.u, self.f, self.n, self.t
a, c = self.a, self.c
s = self.stages
for i in range(s):
k_sum = sum(a_*k_ for a_,k_ in zip(a[i,:i],k_all))
k = root(self.stage_eq,k,args=(c[i],k_sum)).x
k_all.append(k)
return k_all
The modified stage_eq method takes two additional parameters; the coef-
ficient c_i corresponding
Pi−1 to the current stage, and the array k_sum which
holds the sum j=1 aij kj . These arguments need to be initialized correctly
for each stage, and passed as additional arguments to the SciPy root func-
tion. For convenience, we also assume that the method parameter γ has been
stored as a separate class attribute. With the stage_eq method implemented
in this general way, the solve_stages method simply needs to update the
weighted sum of previous stages (k_sum), and pass this and the correct c
value as additional arguments to the SciPy root function. The implementa-
tion above implements this in a for loop which computes the stage derivatives
sequentially and returns them as a list k_all.
As for the FIRK method classes, the only method we now need to imple-
ment specifically for each solver class is the constructor, in which we define
the number of stages and the method coefficients. A class implementation of
the method in (3.15) may look as follows.
class SDIRK2(SDIRK):
def __init__(self,f):
super().__init__(f)
self.stages = 2
gamma = (2-np.sqrt(2))/2
self.gamma = gamma
self.a = np.array([[gamma,0],
[1-gamma, gamma]])
self.c = np.array([gamma,1])
self.b = np.array([1-gamma, gamma])
Shifting our attention to the ESDIRK methods, these are identical to the
SDIRK methods except for the first stage, and the potential for code reuse
is obvious. Examining the two methods of the SDIRK base class above, we
quickly conclude that the stage_eq method can be reused in an ESDIRK
solver class, since the equations to be solved for each stage are identical for
SDIRK and ESDIRK solvers. However, the solve_stages method needs to
be modified, since there is no need to solve a non-linear equation for k1. The
modifications can, however, be very small, since all stages i > 1 are identical.
A possible implementation of the ESDIRK class can look as follows:
class ESDIRK(SDIRK):
def solve_stages(self):
u, f, n, t = self.u, self.f, self.n, self.t
a, c = self.a, self.c
s = self.stages
58 3 Stable solvers for stiff ODE systems
Reference solution
10
0
10
BackwardEuler, t = 0.1
0
5
SDIRK2, t = 0.1
10
0
10
BDF-TR2, t = 0.1
10
0
10
Radau2, t = 0.1
10
0
10
Radau3, t = 0.1
10
0
10
0.0 2.5 5.0 7.5 10.0 12.5 15.0 17.5 20.0
Fig. 3.6 Solutions of the Van der Pol model for µ = 10 and ∆t = 0.1, using implicit
RK solvers of different accuracy.
k = f(t[n],u[n])
k_sum = np.zeros_like(k)
k_all = [k]
for i in range(1,s):
k_sum = sum(a_*k_ for a_,k_ in zip(a[i,:i],k_all))
3.5 Implementing higher order IRK methods 59
k = root(self.stage_eq,k,args=(c[i],k_sum)).x
k_all.append(k)
return k_all
Comparing with the SDIRK base class above, the two methods look identical
at first, but there are two small but important differences. The first is that the
result of the first function evaluation k = f(t[n],u[n]) is now used directly
as the first stage, by setting k_all = [k], instead of just serving as an initial
guess for the nonlinear equation solver. The second is that the for-loop for
computing the remaining stages starts at i=1 rather than i=0.
With the ESDIRK base class at hand, we can implement individual ES-
DIRK methods simply by defining the constructor, for instance
class BDF_TR2(ESDIRK):
def __init__(self,f):
super().__init__(f)
self.stages = 3
gamma = 1-np.sqrt(2)/2
beta = np.sqrt(2)/4
self.gamma = gamma
self.a = np.array([[0,0,0],
[gamma, gamma,0],
[beta,beta,gamma]])
self.c = np.array([0,2*gamma,1])
self.b = np.array([beta,beta,gamma])
The other solvers are the three-stage SDIRK method of order two, the two-
stage Radau method of order three, and three-stage Radau method of order
five. We will see more examples of SDIRK methods in Chapter 4, when we
introduce RK methods with adaptive time step.
Chapter 4
Adaptive time step methods
Many ODE models of dynamic systems have solutions that vary rapidly in
some intervals and are nearly constant in others. As a motivating example,
we may consider a particular class of ODE models that which describe the
so-called action potential of excitable cells. These models, first introduced
by Hodgkin and Huxley [10], are important tools for studying the electro-
physiology of cells such as neurons and different types of muscle cells. The
main variable of interest is usually the transmembrane potential, which is
the difference in electrical potential between the internals of a cell and its
surroundings. When an excitable cell such as a neuron or a muscle cell is
stimulated electrically, it triggers a cascade of processes in the cell membrane,
leading to various ion channels opening and closing, and the membrane po-
tential going from its resting negative state to approximately zero or slightly
positive, before returning to the resting value. This process of depolarization
followed by repolarization is called the action potential, and is illustrated
in Figure 4.1. See, for instance, [11], for a comprehensive overview of the
61
62 4 Adaptive time step methods
40 40
20 20
Transmembrane potential (mV)
0 0
20 20
40 40
60 60
80 80
0 10 20 30 40 50 0 200 400 600 800 1000
Time (ms) Time (ms)
Fig. 4.1 Solution of the Hodgkin-Huxley model. The left panel shows a single action
potential, while the right panel shows the result of stimulating the cell multiple times
with a fixed period.
There are many possible approaches for selecting the time step automati-
cally. One intuitive approach is to base the time step estimate on the dynamics
of the solution, and select a small time step whenever rapid variations occur.
This approach is commonly applied in adaptive solvers for partial differential
equations (PDEs), where both the time step and space step can be chosen
adaptively. It has also been successfully applied in specialized solvers for the
action potential models mentioned above, see, e.g., [15], where the time step
is simply selected based on the variations of the transmembrane voltage.
However, this method may not be universally applicable and the criteria for
choosing the time step must be carefully selected based on the characteristics
of the problem at hand.
The goal of an adaptive time stepping method is to control the error in the
solution, and it is natural to base the step selection on some form of error
estimate. In Section 1.5 we computed the error at the end of the solution
interval, and used it to confirm the theoretical convergence of the method.
4.2 Choosing the time step based on the local error 63
Such a global error could, in principle, also be useful for selecting the time
step, since we could can simply redo the calculation with a smaller time step
if the error is too large. However, for interesting ODE problems the analytical
solution is not available, which complicates this form of error estimate. Fur-
thermore, the goal for the adaptive time step methods is to select the time
step dynamically as the solution progresses, to ensure that the final solution
satisfies a given error tolerance. This goal requires a different approach, which
is based on estimates of the local error for each step rather than the global
error.
Assuming that we are able to compute an estimate for the local error for a
given step, en , the goal is to choose the time step ∆tn so that the inequality
is satisfied for all steps. There are two essential parts to the process of choos-
ing ∆tn so that (4.1) is always satisfied. The first is that we always check
the inequality after a step is performed. If it is satisfied we accept the step
and proceed with step n + 1 as normal, and if it is not satisfied we reject the
step and try again with a smaller ∆tn . The second part of the procedure is
to choose the next time step, that is, either ∆tn+1 if the current step was
accepted, or a new guess for ∆tn if it was rejected. We shall see that the same
formula, derived from what we know about the local error, can be applied in
both cases.
We first assume, for simplicity of notation, that step n was accepted with
a time step ∆t and a local error estimate en < tol. Our aim is now to choose
∆tn+1 so that (4.1) is satisfied as sharply as possible to avoid wasting com-
putations, so we want to choose ∆tn+1 so that en+1 / tol. Recall from 1.5
that for a method of global order p, the local error is of order p + 1, so we
have
where we have assumed that the error constant C is constant from one step
to the next. Eq. (4.2) gives
en
C= ,
(∆tn )p+1
and rearrange to get the standard formula for time step selection
1/(p+1)
tol p+1
∆tn+1 = ∆t .
en n
We see that if en tol this formula will select a larger step size for the next
step, while if en ≈ tol we get ∆tn+1 ≈ ∆tn . In practice, the formula is usually
modified with a safety factor, i.e., we set
1/(p+1)
tol p+1
∆tn+1 = η ∆t . (4.4)
en n
for some η < 1. The exact same formula can be used to choose a new step
size ∆tn if the step was rejected, i.e., if en > tol.
While (4.4) gives a simple formula for the step size, and we shall see later
that it works well for our example problems, more sophisticated methods have
been derived. The problem of choosing the time step to control the error is an
optimal control problem, and successful methods have been derived based on
control theory, in order to control the error while avoiding rapid variations in
the step size. See, for instance, [9] for details and examples of such methods.
be used to provide a local error estimate for all ODE solvers. However, it is
also computationally expensive, and most modern ODE software are based
on other techniques. The second approach for computing ûn , to use a method
with higher order of accuracy, turns out to be particularly attractive for RK
methods. We shall see in the next section that it is possible to construct
so-called embedded methods, which provides an error estimate with very little
additional computation.
Although the main idea is to reuse the same stage computations to compute
both ûn+1 and un+1 , it is not uncommon to introduce one additional stage in
the method to obtain the error estimate. An RK method with an embedded
method for error estimation is often referred to as an RK pair of order n(m),
where n is the order of the main method and m the order of the method
used for error estimation. Butcher tableaus for RK pairs are written exactly
as before, but with one extra line for the additional coefficients b̂:
66 4 Adaptive time step methods
ci a11 · · · a1s
.. .. ..
. . .
cs as1 · · · ass .
b1 · · · bs
b̂1 · · · b̂s
0 0
1 1
, (4.8)
1 0
1/2 1/2
which translates to the following formulas for advancing the two solutions:
k1 = f (tn , un ),
k2 = f (tn + ∆t, un + ∆tk1 ),
un+1 = un + ∆tk1 ,
ûn+1 = un + ∆t/2(k1 + k2 ).
In the next section we will see how this method pair can be implemented as an
extension of the ODESolver hierarchy introduced earlier, before we introduce
more advanced embedded RK methods in Section 4.5.
classes of ODESolver, to benefit from this tiny code reuse and to highlight
that an adaptive solver is in fact a special case of a general ODE solver.
Since most of the new functionality needed by adaptive solvers is generic to
all adaptive solvers, it makes sense to implement them in a general base class.
In summary, the following changes and additions are needed:
• A complete rewrite of the solve method, to replace the for-loop and
NumPy arrays with lists and a while loop. Lists are usually not preferred
for computational tasks, but for adaptive time step methods their flexible
size makes them attractive. It is also natural to add more parameters to
the solve function, to let the user specify the tolerance and a maximum
and minimum step size.
• The advance method needs to be updated to return both the updated
solution and the error estimate.
• The step selection formula in (4.4) must be implemented in a separate
method.
• Adaptive methods usually include a number of additional parameters, such
as the safety factor η and the order p used in (4.4). These parameters are
conveniently defined as attributes in the constructor.
An implementation of the adaptive base class may look as follows:
from ODESolver import *
from math import isnan, isinf
class AdaptiveODESolver(ODESolver):
def __init__(self, f, eta=0.9):
super().__init__(f)
self.eta = eta
def new_step_size(self,dt,loc_error):
eta = self.eta
tol = self.tol
p = self.order
if isnan(loc_error) or isinf(loc_error):
return self.min_dt
def solve(self,t_span,tol=1e-3,max_dt=np.inf,min_dt=1e-5):
"""Compute solution for t_span[0] <= t <= t_span[1]"""
t0,T = t_span
self.tol = tol
self.min_dt = min_dt
self.max_dt = max_dt
self.t = [t0]
if self.neq == 1:
68 4 Adaptive time step methods
self.u = [np.asarray(self.u0).reshape(1)]
else:
self.u = [self.u0]
self.n = 0
self.dt = 0.1/np.linalg.norm(self.f(t0,self.u0))
loc_t = t0
while loc_t < T:
u_new, loc_error = self.advance()
if loc_error < tol or self.dt < self.min_dt:
loc_t += self.dt
self.t.append(loc_t)
self.u.append(u_new)
self.dt = self.new_step_size(self.dt,loc_error)
self.dt = min(self.dt, T-loc_t, max_dt)
self.n += 1
else:
self.dt = self.new_step_size(self.dt,loc_error)
return np.array(self.t), np.array(self.u)
The constructor should be self-explanatory, but the other two methods de-
serve a few comments. The step_size method is essentially a Python imple-
mentation of (4.4), with tests to ensure that the selected step size is within
the user defined range. We have also added a check which ensures that if
the computed error is infinity or not a number (inf or nan) the new step
size is automatically set to the minimum step size. This test is important
for the robustness of the solver, since explicit methods will often diverge and
return inf or nan values if applied to very stiff problems. Checking for these
values and setting a low step size if they occur will therefore reduce the risk
of complete solver failure. The small step size will still make the computation
inefficient, but this is far better than unexpected failure. The solve method
has also been substantially changed from the ODESolver version. First, the
parameter list has been expanded to include the tolerance as well as the max-
imum and minimum time step. These are all stored as attributes and used in
the main loop. The truly significant changes start with the initialization of the
attributes self.t and self.u, which are now lists of length one rather than
fixed size NumPy arrays. Notice also the somewhat cumbersome initializa-
tion of self.u, which includes an if-test that checks if we solve a scalar ODE
or a system. This initialization ensures that for scalar equations, self.u[0]
is a one-dimensional array of length one, rather than a zero-dimensional ar-
ray. The actual contents of these two data structures is the same, i.e., a
single number, but they are treated differently by some NumPy tools and
it is useful to make sure that self.u[0],self.u[1], and so forth all have
the same dimensions. The first step size is then calculated using a simplified
version of the algorithm outlined in [8]. The for-loop has been replaced by
a while-loop, since the number of steps is initially unknown. The call to the
advance-method gives the updated solution and the estimated local error,
and we proceed to check if the local error is lower than the tolerance. If it is,
4.4 Implementing an adaptive solver 69
the new time point and solution are appended to the corresponding lists, and
the next time step is chosen based on the current one and the local error. The
min and max operations are included to ensure that the time step is within
the selected bounds, and that the simulation actually ends at the final time
T. If the constraint loc_error < tol is not satisfied, we simply compute a
new time step and try again, without updating the lists for the time and the
solution.
While the solve loop in the AdaptiveODESolver class is obviously a lot
more complex than the earlier versions, it should be noted that it is still a
very simple version of an adaptive solver. The aim here is to present the fun-
damental ideas and promote the general understanding of how these solvers
are implemented, and we therefore only include the most essential parts.
Important limitations and simplifications include the following:
• As noted above, the step size selection in (4.4), implemented in step_size,
could be replaced with more sophisticated versions. See, for instance, [3,9]
for details.
• The formula for selecting the initial step is very simple, and is mainly
suitable for avoiding extremely bad choices for the initial step size. More
sophisticated algorithms have been derived, and we refer to, for instance,
[8, 9] for details.
• The first if-test inside the solver loop is not the most robust, since it will
accept the stem and move forward if the minimum step size is reached,
even if the error is too large. A robust solver should in this case give the
user a warning that the requested tolerance cannot be reached.
In spite of these and other limitations, the adaptive solver class works as
intended, and captures the essential behavior of adaptive ODE solvers.
With the AdaptiveODESolver base class at hand, subclasses for spe-
cific solvers can be implemented by writing specific versions of the advance
method and the constructor, since the order of the method is used in the
time step selection and therefore needs to be defined as an attribute. For the
Euler-Heun method pair listed above, a suitable implementation may look as
follows:
class EulerHeun(AdaptiveODESolver):
def __init__(self, f, eta=0.9):
super().__init__(f,eta)
self.order = 1
def advance(self):
u, f, t = self.u, self.f, self.t
dt = self.dt
k1 = f(t[-1], u[-1])
k2 = f(t[-1] + dt, u[-1] + dt*k1)
high = dt/2*(k1+k2)
low = dt*k1
error = np.linalg.norm(high-low)
return unew, error
After computing the two stage derivatives k1 and k2, the method computes
the high and low order solution updates. The low order is used to advance
the solution, while the difference between the two provides the error estimate.
The method returns the updated solution and the error, as needed by the
solve method implemented in the base class above.
Since we have two methods with different accuracy, we may ask whether it
would be better to advance the solution using the most accurate rather than
the least accurate method. This choice will, of course, give a reduced local
error, but the obvious downside is that we would no longer have a proper
error estimate. We can use the more accurate solution to estimate the error
of the less accurate, but not the other way around. However, the approach,
known as local extrapolation [8] is still used by many popular RK pairs, as
we shall see in examples below. Even if the error estimate is then no longer
a proper error estimate for the method used to integrate the solution, it still
works well as a tool for selecting the time step. In the implementation above
it is very easy to play around with this choice, by replacing low with high
in the assignment of unew, and check the effect on the error and the number
of time steps.
There are numerous examples of explicit RK pairs of higher order than the
1(2) pair defined by (4.8). We will not provide an exhaustive list here, but
mention two particularly popular methods, which have been implemented in
various software packages. The first is a method by Fehlberg, often referred to
as the Fehlberg 4(5) or simply the RKF45 method [5]. The Butcher tableau
is
0
1 1
4 4
3 3 9
8 32 32
12 1932 7200 7296
13 2197 − 2197 2197 , (4.9)
1 439
216 −8 3680 845
513 − 4104
1 8 3544 1859
2 − 27 2 − 2565 4104 − 11
40
25 1408 2197
216 0 2565 4104 − 15 0
16 6656 28561 9 2
135 0 12825 56430 − 50 55
Here, the first line of b-coefficients (bi ) yields a fourth order method, while
the bottom line (b̂i ) gives a method of order five. The implementation of
the RKF45 method is similar to the Euler-Heun pair, although the num-
ber of stages and coefficients makes the advance method considerably more
complex:
4.5 More advanced embedded RK methods 71
class RKF45(AdaptiveODESolver):
def __init__(self, f, eta=0.9):
super().__init__(f, eta)
self.order = 4
def advance(self):
u, f, t = self.u, self.f, self.t
dt = self.dt
c2 = 1/4; a21 = 1/4;
c3 = 3/8; a31 = 3/32; a32 = 9/32
c4 = 12/13; a41 = 1932/2197; a42 = -7200/2197; a43 = 7296/2197
c5 = 1; a51 = 439/216; a52 = -8; a53 = 3680/513; a54 = -845/4104
c6 = 1/2; a61 = -8/27; a62 = 2; a63 = -3544/2565;
a64 = 1859/4104; a65 = -11/40
b1 = 25/216; b2 = 0; b3 = 1408/2565; b4 = 2197/4104;
b5 = -1/5; b6 = 0
bh1 = 16/135; bh2 = 0; bh3 = 6656/12825; bh4 = 28561/56430;
bh5 = -9/50; bh6 = 2/55
k1 = f(t[-1], u[-1])
k2 = f(t[-1] + c2*dt, u[-1] + dt*(a21*k1))
k3 = f(t[-1] + c3*dt, u[-1] + dt*(a31*k1+a32*k2))
k4 = f(t[-1] + c4*dt, u[-1] + dt*(a41*k1+a42*k2+a43*k3))
k5 = f(t[-1] + c5*dt, u[-1] + dt*(a51*k1+a52*k2+a53*k3+a54*k4))
k6 = f(t[-1] + c6*dt, u[-1] +
dt*(a61*k1+a62*k2+a63*k3+a64*k4+a65*k5))
low = dt*(b1*k1+b3*k3+b4*k4+b5*k5)
high = dt*(bh1*k1+bh3*k3+bh4*k4+bh5*k5+bh6*k6)
This method has been optimized for the local extrapolation approach men-
tioned above, where the highest order method is used to advance the solu-
tion and the less accurate method is only used for step size selection. The
implementation is otherwise similar to the RKF45 method. The Dormand-
Prince method has been implemented in numerous software tools, including
the popular ode45 function in Matlab (The Math Works, Inc. MATLAB.
Version 2023a).
Implicit RK methods can also be equipped with embedded methods. The
fundamental idea is exactly the same as for explicit methods, although the
step size selection tends to be more challenging for stiff problems. The most
obvious constraint is that for stiff problems, both the main method and the
error estimator need to have good stability properties. Stiff problems are also
known to be more challenging for the error control algorithms, and simple
algorithms such as (4.4) often suffer from large fluctuations in step size and
local error. We refer to, for instance, [1, 9] for a detailed discussion of these
challenges, and here mainly present a selection of well known methods. For
instance, the TR-BDF2 method in (3.16) can be extended to include a third
order method for error estimation. The extended Butcher tableau is
0 0
2γ γ γ 0
1 β β γ, (4.10)
β β γ
1−β 3β+1 γ
3 3 3
√ √
with γ = 1 − 2/2 and β = 2/4, and the bottom line of coefficients defines
the third order method. This third order method is not L-stable, and for stiff
problems it is therefore preferable to advance the solution using the second-
order method and use the more accurate one for time step control. Ideally we
would like both methods of an embedded RK pair to be L-stable, but this is
often impossible to achieve and we need to accept somewhat weaker stability
requirements for the error estimator, see, for instance, [13].
When implementing the adaptive TR-BDF2 and other implicit methods,
we need to combine features of the AdaptiveODESolver class above with
the tools from the ImplicitRK hierarchy introduced in Chapter 3. Specifi-
cally, an adaptive implicit RK methods needs the solve and new_step_size
method from AdaptiveODESolver, while all the code for computing the stage
derivatives can be reused directly from the ImplicitRK classes. A convenient
way to reuse functionality from two different classes is to use multiple inher-
itance, where we define a new class a subclass of two different base classes.
For instance, a base class for adaptive ESDIRK methods may look like
class AdaptiveESDIRK(AdaptiveODESolver,ESDIRK):
which simply states that the new class inherits all the methods from both
the AdaptiveODESolver class and the ImplicitRK class. The general de-
4.5 More advanced embedded RK methods 73
sign of the ImplicitRK class above was to define the method coefficients in
the constructor and use a generic advance method, and it is convenient to
use the same method for the adaptive implicit methods. We then need to
override the advance method from ImplicitRK in our AdaptiveImplicitRK
base class, since we need the method to return the error in addition to
the updated solution. All other methods can be reused directly from either
AdaptiveODESolver or ImplicitRK, so a suitable implementation of the new
class may look like
class AdaptiveESDIRK(AdaptiveODESolver,ESDIRK):
def advance(self):
b = self.b
e = self.e
u = self.u
dt = self.dt
k = self.solve_stages()
u_step = dt*sum(b_*k_ for b_,k_ in zip(b,k))
error = dt*sum(e_*k_ for e_,k_ in zip(e,k))
u_new = u[-1] + u_step
error_norm = np.linalg.norm(error)
return u_new, error_norm
Here, we assume that the constructor defines all the RK method parame-
ters used earlier, and in addition a set of parameters self.e, defined by
ei = bi − b̂i , for i = 1, . . . , n, which are used in the error calculations. Except
for the two lines computing the error, the method is identical to the generic
advance method from the ImplicitRK class, which was used by all the sub-
classes. Therefore, it may be natural to ask whether we should have put
this method in a general base class for implicit RK methods, for instance
named AdaptiveImplicitRK, and then it could be used in adaptive versions
of both the SDIRK, ESDIRK, and Radau classes. However, adaptive versions
of the Radau methods use a slightly different calculation of the error, since
for a Radau method of order p it is not possible to construct an embedded
method of order p − 1. For the adaptive solvers the advance method is there-
fore slightly less general, and it is convenient to implement it separately for
the ESDIRK methods. We will not present adaptive versions of the Radau
methods here, but the details may be found in [9].
Although multiple inheritance provides a convenient way to reuse the
functionality of our existing classes, it comes with the risk of somewhat
complex and confusing class hierarchies. In particular, the fact that our
AdaptiveESDIRK class inherits from AdaptiveODESolver and ESDIRK, which
are both subclasses of ODESolver, may give rise to a well-known ambigu-
ity referred to as the diamond problem. The problem would arise if, for in-
stance, we were to define a method in ODESolver, override it with special
versions in both AdaptiveODESolver and ESDIRK, and then call it from an
instance of AdaptiveESDIRK. Would we then call the version implemented
in AdaptiveODESolver or the one in ESDIRK? The answer is determined
by Python’s so-called method resolution order (MRO), which decides which
74 4 Adaptive time step methods
method to inherit first based on its "closeness" in the class hierarchy and
then on the order of the base classes in the class definition. In our particular
example the AdaptiveESDIRK class is equally close to AdaptiveODESolver
and ESDIRK, since it is a direct subclass of both. The method called would
therefore be the version from AdaptiveODESolver, since this is listed first
in the class definition. In our relatively simple class hierarchy there are no
such ambiguities, and even if we use multiple inheritance it should not be
too challenging to determine which methods are called, but it is a potential
source of confusion that is worth being aware of.
With the AdaptiveESDIRK base class available, an adaptive version of the
TR-BDF2 method may be implemented as
class TR_BDF2_Adaptive(AdaptiveESDIRK):
def __init__(self,f,eta=0.9):
super().__init__(f,eta)
self.stages = 3
self.order = 2
gamma = 1-np.sqrt(2)/2
beta = np.sqrt(2)/4
self.gamma = gamma
self.a = np.array([[0,0,0],
[gamma, gamma,0],
[beta,beta,gamma]])
self.c = np.array([0,2*gamma,1])
self.b = np.array([beta,beta,gamma])
bh = np.array([(1-beta)/3,(3*beta + 1)/3, gamma/3])
self.e = self.b-bh
To illustrate the use of this solver class, we may return to the Hodgkin-
Huxley model considered at the start of this chapter. Assuming that we have
implemented the model as a class in a file hodgkinhuxley.py, the following
code solves the model and plots the transmembrane potential:
from AdaptiveImplicitRK import TR_BDF2_Adaptive
from hodgkinhuxley import HodgkinHuxley
import matplotlib.pyplot as plt
model = HodgkinHuxley()
u0 = [-45,0.31,0.05,0.59]
t_span = (0,50)
tol = 0.01
solver = TR_BDF2_Adaptive(model)
solver.set_initial_condition(u0)
t,u = solver.solve(t_span,tol)
plt.plot(t,u[:,0])
plt.show()
A plot of the solution is shown in Figure 4.2. The +-marks show the time
steps chosen by the adaptive TR-BDF2 solver, and it is easy to see that large
4.5 More advanced embedded RK methods 75
40 reference
TR-BDF2, tol = 0.01
20
Transmembrane potential (mV)
20
40
60
80
0 10 20 30 40 50
Time (ms)
Fig. 4.2 Solution of the Hodgkin-Huxley model. The solid line is a reference solution
computed with SciPy solve_ivp, while the +-marks are the time steps chosen by the
adaptive TR-BDF2 solver.
time steps are used in quiescent regions while smaller steps are used where
the solution varies rapidly. A more quantitative view of the solver behavior,
for three different solvers, is shown in the table below. Each method has been
applied with three different tolerance values, for the time interval from 0 to
50ms, and with default choices of the maximum and minimum time steps.
The column marked "Error" is then an estimate of the global error, based on
a reference solution computed with SciPy’s solve_ivp, the "Steps" column is
the number of accepted time steps, "Rejected" is the total number of rejected
steps, and the two last columns are the minimum and maximum time steps
that occurred during the computation.
76 4 Adaptive time step methods
the first one for most of the solvers in the table above. Furthermore, adjusted
error estimates have been proposed, see [9], which are more suitable for stiff
systems. For a more detailed and complete discussion of automatic time step
control, we refer to [1] and [8, 9].
Chapter 5
Modeling infectious diseases
79
80 5 Modeling infectious diseases
authorities to predict the spread of diseases such as Covid19, flu, ebola, HIV,
etc.
In the first version of the model we will keep track of the three categories
of people mentioned above:
• S: susceptibles - who can get the disease
• I: infected - who have developed the disease and can infect susceptibles
• R: recovered - who have recovered and become immune
We represent these as mathematical quantities S(t), I(t), R(t), which repre-
sent the number of people in each category. The goal is now to derive a set of
equations for S(t), I(t), R(t), and then solve these equations to predict the
spread of the disease.
To derive the model equations, we first consider the dynamics in a time
interval ∆t, and our goal is to derive mathematical expressions for how many
people that move between the three categories in this time interval. The key
part of the model is the description of how people move from S to I, i.e., how
susceptible individuals get the infection from those already infected. Infec-
tious diseases are (mainly) transferred by direct interactions between people,
so we need mathematical descriptions of the number of interactions between
susceptible and infected individuals. We make the following assumptions:
• An individual in the S category interacts with an approximately constant
number of people each day, so the number of interactions in a time interval
∆t is proportional to ∆t.
• The probability of one of these interactions being with an infected person
is proportional to the ratio of infected individuals to the total population,
i.e., to I/N , with N = S + I + R.
Based on these assumptions, the probability that a single susceptible person
gets infected is proportional to ∆tI/N . The total number of infections can
be written as βSI/N , for some constant β, which represents the probability
that an infected person meets and infects a susceptible person. The value of
β depends both on the infectiousness of the disease and the behavior of the
population, as will be discussed in more detail below. The infection of new
individuals represents a reduction in S and a corresponding gain in I, so we
have
S(t)I(t)
S(t + ∆t) = S(t) − ∆tβ ,
N
S(t)I(t)
I(t + ∆t) = I(t) + ∆t β .
N
These two equations represent the key component of all the models considered
in this chapter. They are formulated as difference equations, and we will see
below that they can easily be transformed to ODEs. More advanced models
are typically derived by adding more categories and more transitions between
5.1 Derivation of the SIR model 81
them, but the individual transitions are very similar to the ones presented
here.
S I R
Fig. 5.1 Graphical representation of the simplest SIR-model, where people move from
being susceptible (S) to being infected (I) and then reach the recovered (R) category
with immunity against the disease.
We also need to model the transition of people from the I to the R category.
Again considering a small time interval ∆t, it is natural to assume that a
fraction ∆t ν of the infected recover and move to the R category. Here ν is
a constant describing the time dynamics of the disease. The increase in R is
given by
R(t + ∆t) = R(t) + ∆t νI(t),
and we also need to subtract the same term in the balance equation for I,
since the people move from I to R. We get
S(t)I(t)
S(t + ∆t) = S(t) − ∆t β (5.1)
N
S(t)I(t)
I(t + ∆t) = I(t) + ∆t β − ∆tνI(t) (5.2)
N
R(t + ∆t) = R(t) + ∆t νI(t). (5.3)
1
A simpler version of the SIR model is also quite common, where the disease trans-
mission term is not scaled with N . Eq. (5.8) then reads S 0 = −βSI, and (5.8) is modified
similarly. Since N is constant the two models are equivalent, but the version in (5.7)-
(5.9) is more common in real-world applications and gives a closer relation between β
and common parameters such as the reproduction number.
5.1 Derivation of the SIR model 83
category, and since the number of infected cases in this phase is low com-
pared with the entire population we may assume that S is approximately
constant and equal to N . Inserting S ≈ N turns (5.8) into a simple equation
describing exponential growth, with solution
def SIR_model(t,u):
beta = 1.0
nu = 1/7.0
S, I, R = u[0], u[1], u[2]
N = S+I+R
dS = -beta*S*I/N
dI = beta*S*I/N - nu*I
dR = nu*I
return [dS,dI,dR]
S0 = 1000
I0 = 1
R0 = 0
solver= RungeKutta4(SIR_model)
solver.set_initial_condition([S0,I0,R0])
time_points = np.linspace(0, 100, 1001)
t, u = solver.solve(time_points)
S = u[:,0]; I = u[:,1]; R = u[:,2]
plt.plot(t,S,t,I,t,R)
plt.show()
84 5 Modeling infectious diseases
def __call__(self,u,t):
S, I, R = u[0], u[1], u[2]
N = S+I+R
dS = -self.beta*S*I/N
dI = self.beta*S*I/N - self.nu*I
dR = self.nu*I
return [dS,dI,dR]
As for the models considered in earlier chapters, the use of the class is very
similar to the use of the SIR_model function above. We create an instance
of the class with given values of beta and nu, and then this instance can be
passed to the ODE solver just as any regular Python function.
The SIR model itself in its simplest form is rarely used for predictive simu-
lations of real-world diseases, but various extensions of the model are used
to a large extent. Many such extensions have been derived, in order to best
fit the dynamics of different infectious diseases. We will here consider a few
such extensions, which are all based on the building blocks of the simple SIR
model.
5.2 Extending the SIR model 85
1000
800
Number of people
600
S
I
R
400
200
0
0 20 40 60 80 100
Time (days)
Fig. 5.2 Solution of the simplest version of the SIR model, showing how the number
of people in each category (S, I, and R) changes with time.
1000 S
I
R
800
Number of people
600
400
200
0
0 20 40 60 80 100
Time (days)
Fig. 5.3 Illustration of a SIR model without lifelong immunity, where people move
from the R category back to S after a given time.
Notice that the overall structure of the model remains the same. Since the
total population is conserved, all terms are balanced in the sense that they
occur twice in the model, with opposite signs. A decrease in one category is
always matched with an identical increase in another category. It is always
useful to be aware of such fundamental properties in a model, since they can
easily be checked in the computed solutions and may reveal errors in the
implementation.
5.2 Extending the SIR model 87
S E I R
Again, this small extension of the model does not make it much more
difficult to solve. The following code shows an example of how the SEIR model
can be implemented as a class and solved with the ODESolver hierarchy:
from ODESolver import RungeKutta4
import numpy as np
import matplotlib.pyplot as plt
class SEIR:
def __init__(self, beta, mu, nu, gamma):
self.beta = beta
self.mu = mu
self.nu = nu
self.gamma = gamma
def __call__(self,u,t):
S, E, I, R = u
N = S+I+R+E
dS = -self.beta*S*I/N + self.gamma*R
dE = self.beta*S*I/N - self.mu*E
dI = self.mu*E - self.nu*I
dR = self.nu*I - self.gamma*R
return [dS,dE,dI,dR]
S0 = 1000
E0 = 0
I0 = 1
R0 = 0
model = SEIR(beta=1.0, mu=1.0/5,nu=1.0/7,gamma=1.0/50)
solver= RungeKutta4(model)
solver.set_initial_condition([S0,E0,I0,R0])
time_points = np.linspace(0, 100, 101)
u, t = solver.solve(time_points)
S = u[:,0]; E = u[:,1]; I = u[:,2]; R = u[:,3]
plt.plot(t,S,t,E,t,I,t,R)
plt.show()
88 5 Modeling infectious diseases
infect others, unlike the people in the exposed (E) category of the simple
SEIR model above.
These two groups can be modeled by adding to new compartments to the
SEIR model introduced earlier. We split the exposed category in two, E1
and E2 , with the first being non-infectious and the second being able to
infect others. The I category is also divided in two; a symptomatic I and an
asymptomatic Ia . The flux from S to E1 will be similar to the SEIR model,
but from E1 people will follow one of two possible trajectories. Some will
move on to E2 and then into I and finally R, while others move directly into
Ia and then to R. The model is illustrated in Figure 5.5. Since there are two
different E-categories and two different I-categories, we refer to the model
as a SEEIIR model.
E2 I
S E1 R
Ia
Fig. 5.5 Illustration of the Covid19 epidemic model, with two alternative disease
trajectories.
dR
= µI + µIa .
dt
Notice that we do not consider flow from the R category back to S, so we
have effectively assumed life-long immunity. This assumption is not correct
for Covid19, but in the early phase of the pandemic the duration of immunity
was largely unknown, and the loss of immunity was therefore not considered
in the models.
To summarize, the complete ODE system of the SEEIIR model can be
written as
5.3 A model of the Covid19 pandemic 91
dS SI SIa SE2
= −β − ria β − re2 β ,
dt N N N
dE1 SI SIa SE2
=β + ria β + re2 β − λ1 E1 ,
dt N N N
dE2
= λ1 (1 − pa )E1 − λ2 E2 ,
dt
dI
= λ2 E2 − µI,
dt
dIa
= λ1 pa E1 − µIa ,
dt
dR
= µ(I + Ia ).
dt
A suitable choice of default parameters for the model can be as follows:
Parameter Value
β 0.33
ria 0.1
re2 1.25
λ1 0.33
λ2 0.5
pa 0.4
µ 0.2
These parameters are similar to the ones used by the health authorities to
model the early phase of the Covid19 outbreak in Norway. At this time the be-
havior of the disease was largely unknown, and it was also difficult to estimate
the number of disease cases in the population. It was therefore challenging
to fit the parameter values, and they were all associated with considerable
uncertainty. As mentioned earlier, the hardest parameters to estimate are the
ones related to infectiousness and disease spread, which in the present model
are β, ria , and re2 . These have been updated many times through the course
of the pandemic, both to reflect new knowledge about the disease and actual
changes in disease spread caused by new mutations or changes in the behavior
of the population. Notice that we have set re2 > 1, which means that people
in the E2 category are more infectious than the infected group in I. This
assumption reflects the fact that the E2 group is asymptomatic, so people
in this group are expected to move around more and therefore potentially
infect more people than the I group. The Ia group, on the other hand, is also
asymptomatic and therefore likely to have normal social interactions, but it
is assumed that these people have a very low virus count. They are therefore
less infectious than the people that develop symptoms, which is reflected in
the low value of ria .
The parameters µ, λ1 , and λ2 are given in units of days−1 , so the mean du-
ration of the symptomatic disease period is five days (1/µ), the non-infectious
92 5 Modeling infectious diseases
incubation period lasts three days on average (1/λ1 ), while the mean duration
of the infectious incubation period (E2 ) is two days (1/λ2 ). In the present
model, which has multiple infectious categories, the basic reproduction num-
ber is given by
R0 = re2 β/λ2 + ria β/µ + β/µ,
since the mean durations of the E2 period is 1/λ2 and the mean duration of
both I and Ia is 1/µ. The parameter choices listed above gives R0 ≈ 2.62,
which is the value used by the Institute of Public Health (FHI) to model
the early stage of the outbreak in Norway, from mid-February to mid-March
2020.
0
0 50 100 150 200 250 300
Time (days)
Fig. 5.6 Solution of the SEEIIR model with the default parameter values, which are
similar to the values used by Norwegian health authorities during the early phase of the
Covid19 pandemic.
Although the present model is somewhat more complex than the previous
ones, the implementation is not very different. A class implementation may
look as follows:
class SEEIIR:
def __init__(self, beta=0.33, r_ia=0.1,
r_e2=1.25, lmbda_1=0.33,
lmbda_2=0.5, p_a=0.4, mu=0.2):
self.beta = beta
self.r_ia = r_ia
self.r_e2 = r_e2
self.lmbda_1 = lmbda_1
self.lmbda_2 = lmbda_2
5.3 A model of the Covid19 pandemic 93
self.p_a = p_a
self.mu = mu
The model can be solved with any of the methods in the ODESolver hierarchy,
just as the simpler models considered earlier. An example solution with the
default parameter values is shown in Figure 5.6. Since the parameters listed
above are based on the very first stage of the pandemic, when no restrictions
were in place, this solution may be interpreted as a potential worst case
scenario for the pandemic in Norway if no restrictions were imposed by the
government. While the plot for the I category does not look too dramatic,
a closer inspection reveals that the peak is at just above 140,000 people.
Considering what was known, and, more importantly, what was not known,
about the severity of Covid19 at that stage, it is not surprising that a scenario
of 140,000 people being infected simultaneously caused some alarm with the
health authorities. Another interesting observation from the curve is that
the S category flattens out well below the total population number. This
behavior is an example of so-called herd immunity, that when a sufficient
number of people are immune to the disease, it will effectively stop spreading
even if a large number of people are still susceptible. As we all know, severe
restrictions were put in place in most countries during the early spring of 2020,
which makes it impossible to know whether this worst case scenario would
ever materialize. If we want to match the actual dynamics of the pandemic
in Norway, we would need to incorporate the effect of societal changes and
altered infectiousness by making the β parameter a function of time. For
instance, we can define it as a piecewise constant function to match the
reproduction numbers estimated and published by the health authorities.
Appendix A
Programming of difference equations
Although the main focus of these notes is on solvers for differential equations,
we find it useful to include a chapter on the closely related class of problems
known as difference equations. The main motivation for including this topic
in a book on ODEs is to highlight the similarity between the two classes of
problems, and in particular the similarity of the solution methods and their
implementation. Indeed, solving ODEs numerically can be seen as a two-step
procedure. First, a numerical method is applied to turn differential equations
into difference equations, and then these equations are solved using simple
for-loop. The standard formulation of difference equations is very easy to
translate into a computer program, and some readers may find it easier to
study these equations first, before moving on to ODEs. In the present chapter
we will also touch upon famous sequences and series, which have important
applications both in the numerical solution of ODEs and elsewhere.
x0 , x 1 , x 2 , . . . , x n , . . . .
For some sequences we can derive a formula that gives the the n-th number
xn as a function of n. For instance, all the odd numbers form a sequence
1, 3, 5, 7, . . . ,
and for this sequence we can write a simple formula for the n-th term;
xn = 2n + 1.
95
96 A Programming of difference equations
With this formula at hand, the complete sequence can be written on a com-
pact form;
(xn )∞
n=0 , xn = 2n + 1.
Other examples of sequences include
xn = x0 (1 + p/100)n ,
In order to compute xn , we can now simply start with the known x0 , and
compute x1 , x2 , . . . , xn . The procedure involves repeating a simple calculation
many times, which is tedious to do by hand, but very well suited for a com-
puter. The complete program for solving this difference equation may look
like:
import numpy as np
import matplotlib.pyplot as plt
x0 = 100 # initial amount
p = 5 # interest rate
N = 4 # number of years
x = np.zeros(N+1)
x[0] = x0
for n in range(1,N+1):
x[n] = x[n-1] + (p/100.0)*x[n-1]
plt.plot(x, ’ro’)
plt.xlabel(’years’)
plt.ylabel(’amount’)
plt.show()
The three lines starting with x[0] = x0 make up the core of the program.
We here initialize the first element in our solution array with the known x0,
and then step into the for-loop to compute the rest. The loop variable n
runs from 1 to N (= 4), and the formula inside the loop computes x[n] from
the known x[n-1]. Notice also that we pass a single array as argument to
plt.plot, while in most of the examples in this book we sent two; typically
representing time on the x-axis and the solution on the y-axis. When only
one array of numbers is sent to plot, these are automatically interpreted
as the y-coordinates of the points, and the x-coordinates will simply be the
indices of the array, in this case the numbers from 0 to N .
Solving a difference equation without using arrays. The program
above stored the sequence as an array, which is a convenient way to program
the solver and enables us to plot the entire sequence. However, if we are only
interested in the solution at a single point, i.e., xn , there is no need to store
the entire sequence. Since each xn only depends on the previous value xn−1 ,
we only need to store the last two values in memory. A complete loop can
look like this:
x_old = x0
for n in index_set[1:]:
x_new = x_old + (p/100.)*x_old
x_old = x_new # x_new becomes x_old at next step
print(’Final amount: ’, x_new)
For this simple case we can actually make the code even shorter, since x_old
is only used in a single line and we can just as well overwrite it once it has
been used:
98 A Programming of difference equations
We see that these codes store just one or two numbers, and for each pass
through the loop we simply update these and overwrite the values we no
longer need. Although this approach is quite simple, and we obviously save
some memory since we do not store the complete array, programming with an
array x[n] is usually safer, and we are often interested in plotting the entire
sequence. We will therefore mostly use arrays in the subsequent examples.
Extending the solver for the growth of money. Say we are interested in
changing our model for interest rate, to a model where the interest is added
every day instead of every year. The interest rate per day is r = p/D if p is the
annual interest rate and D is the number of days in a year. A common model
in business applies D = 360, but n counts exact (all) days. The difference
equation relating one day’s amount to the previous is the same as above
r
xn = xn−1 + xn−1 ,
100
except that the yearly interest rate has been replaced by the daily (r). If we
are interested in how much the money grows from a given date to another we
also need to find the number of days between those dates. This calculation
could of course be done by hand, but Python has a convenient module named
datetime for this purpose. The following session illustrates how it can be
used:
>>> import datetime
>>> date1 = datetime.date(2017, 9, 29) # Sep 29, 2017
>>> date2 = datetime.date(2018, 8, 4) # Aug 4, 2018
>>> diff = date2 - date1
>>> print(diff.days)
309
Putting these tools together, a complete program for daily interest rates may
look like
import numpy as np
import matplotlib.pyplot as plt
import datetime
x = np.zeros(len(index_set))
x[0] = x0
for n in index_set[1:]:
x[n] = x[n-1] + (r/100.0)*x[n-1]
plt.plot(index_set, x)
plt.xlabel(’days’)
plt.ylabel(’amount’)
plt.show()
This program is slightly more sophisticated than the first one, but one may
still argue that solving this problem with a difference equation is unnecessarily
r n
complex, since we could just apply the well-known formula xn = x0 (1 + 100 )
to compute any xn we want. However, we know that interest rates change
quite often, and the formula is only valid for a constant r. For the pro-
gram based on solving the difference equation, on the other hand, only minor
changes are needed in the program to handle a varying interest rate. The
simplest approach is to let p be an array of the same length as the number of
days, and fill it with the correct interest rates for each day. The modifications
to the program above may look like this:
p = np.zeros(len(index_set))
# fill p[n] with correct values
x[0] = x0
for n in index_set[1:]:
x[n] = x[n-1] + (r[n-1]/100.0)*x[n-1]
The only real difference from the previous example is that we initialize p as
an array, and then r = p/360.0 becomes an array of the same length. In the
formula inside the for-loop we then look up the correct value r[n-1] for each
iteration of the loop. Filling p with the correct values can be non-trivial, but
many cases can be handled quite easily. For instance, say the interest rate is
piecewise constant and increases from 4.0% to 5.0% on a given date. Code
for filling the array with values may then look like this
date0 = datetime.date(2017, 9, 29)
date1 = datetime.date(2018, 2, 6)
date2 = datetime.date(2018, 8, 4)
Np = (date1-date0).days
N = (date2-date0).days
p = np.zeros(len(index_set))
p[:Np] = 4.0
p[Np:] = 5.0
100 A Programming of difference equations
xn = xn−1 + xn−2 , x0 = 1, x1 = 1.
is called the Fibonacci numbers. It was originally derived for modeling rat
populations, but it has a range of interesting mathematical properties and
has therefore attracted much attention from mathematicians. The equation
for the Fibonacci numbers differs from the previous ones, since xn depends
on the two previous values (n − 1, n − 2), which makes this a second order
difference equation. This classification is important for mathematical solution
techniques, but in a program the difference between first and second order
equations is small. A complete code to solve the difference equation and
generate the Fibonacci numbers can be written as
import sys
from numpy import zeros
N = int(sys.argv[1])
x = zeros(N+1, int)
x[0] = 1
x[1] = 1
for n in range(2, N+1):
x[n] = x[n-1] + x[n-2]
print(n, x[n])
We use the builtin list sys.argv from the sys model in order to provide
the input N as a command line argument. See, for instance, [16] for an ex-
planation. Notice that in this case we need to initialize both x[0] and x[1]
before starting the loop, since the update formula involves both x[n-1] and
x[n-2]. This is the main difference between this second order equation and
the programs for first order equations considered above. The Fibonacci num-
bers grow quickly and running this program for large N will lead to problems
with overflow (try for instance N = 100). The NumPy int type supports up
to 9223372036854775807, which is almost 1019 , so this is very rarely a prob-
lem in practical applications. We can fix the problem by avoiding NumPy
arrays and instead use the standard Python int type, but we will not go into
these details here.
Logistic growth. If we return to the initial problem of calculating growth of
money in a bank, we can write the classical solution formula more compactly
as
xn = x0 (1 + p/100)n = x0 C n (= x0 en ln C ),
A.2 More Examples of Difference Equations 101
xn = xn−1 + (r/100)xn−1 .
by
x[n] = x[n-1] + (rho/100)*x[n-1]*(1 - x[n-1]/R)
1
As discussed in Chapter 1, the formula x = x0 eλt is the solution of the differential
equation dx/dt = λx, which illustrates the close relation between difference equations
and differential equations.
102 A Programming of difference equations
index_set = range(N+1)
x = np.zeros(len(index_set))
x[0] = x0
for n in index_set[1:]:
x[n] = x[n-1] + (rho/100) *x[n-1]*(1 - x[n-1]/R)
plt.plot(index_set, x)
plt.xlabel(’years’)
plt.ylabel(’amount’)
plt.show()
Note that the logistic growth model is more commonly formulated as an ODE,
which we considered in Chapter 1 For certain choices of numerical method
and discretization parameters, the program for solving the ODE is identical
to the program for the difference equation considered here.
500
450
400
350
Population
300
250
200
150
100
0 25 50 75 100 125 150 175 200
Time units
Fig. A.1 Solution of the logistic growth model for x0 = 100, ρ = 5.0, R = 500.
f (x) = 0.
Starting from some initial guess x0 , Newton’s method gradually improves the
approximation by iterations
f (xn−1 )
xn = xn−1 − .
f 0 (xn−1 )
The arguments to the function are Python functions f and dfdx implementing
f (x) and its derivative. Both of these arguments are called inside the function
and must therefore be callable. The x argument is the initial guess for the
solution x, and the two optional arguments at the end are the tolerance and
the maximum number of iteration. Although the method is implemented as
a while-loop rather than a for-loop, the main structure of the algorithm is
exactly the same as for the other difference equations considered earlier.
104 A Programming of difference equations
c = np.zeros_like(x)
x[0] = F
c[0] = q*p*F*1e-4
for n in index_set[1:]:
x[n] = x[n-1] + (p/100.0)*x[n-1] - c[n-1]
c[n] = c[n-1] + (I/100.0)*c[n-1]
Here, a is the natural growth rate of the prey in the absence of predators, b
is the death rate of prey per encounter of prey and predator, c is the natural
death rate of predators in the absence of food (prey), and d is the efficiency
of turning predated prey into predators. This is a system of two first-order
difference equations, just as the previous example, and a complete solution
code may look as follows.
import numpy as np
import matplotlib.pyplot as plt
x0 = 100 # initial prey population
y0 = 8 # initial predator pop.
a = 0.0015
b = 0.0003
c = 0.006
d = 0.5
N = 10000 # number of time units (days)
index_set = range(N+1)
x = np.zeros(len(index_set))
y = np.zeros_like(x)
print(x.shape)
x[0] = x0
y[0] = y0
106 A Programming of difference equations
for n in index_set[1:]:
x[n] = x[n-1] + a*x[n-1] - b*x[n-1]*y[n-1]
y[n] = y[n-1] + d*b*x[n-1]*y[n-1] - c*y[n-1]
ex ≈ 1 + x,
1 1
ex ≈ 1 + x + x2 + x3 ,
2 6
respectively. These approximations are obviously not very accurate for large
x, but close to x = 0 they are sufficiently accurate for many applica-
tions. Taylor series approximations for other functions can be constructed
by similar arguments. Consider, for instance, sin(x), where the derivatives
follow the repetitive pattern sin0 (x) = cos(x), sin00 (x) = −sin(x), sin000 (x) =
− cos(x), . . . .... We also have sin(0) = 0, cos(0) = 1, so in general we have
dk sin(0)/dxk = (−1)k mod(k, 2), where mod(k, 2) is zero for k even and
∞
X x2k+1
sin x = (−1)k .
(2k + 1)!
k=0
xn−1
en = en−1 + , e0 = 0. (A.4)
(n − 1)!
We see that this difference equation involves (n − 1)!, and computing the
complete factorial for every iteration involves a large number of redundant
multiplications. Above we introduced a difference equation for the factorial,
and this idea can be utilized to formulate a more efficient computation of the
Taylor polynomial. We have that
xn xn−1 x
= · ,
n! (n − 1)! n
en = en−1 + an−1 , e0 = 0,
x
an = an−1 , a0 = 1.
n
Although we are here solving a system of two difference equations, the com-
putation is far more efficient than solving the single equation in (A.4) directly,
since we avoid the repeated multiplications involved in the factorial compu-
tation.
A complete Python code for solving the system of difference equation and
computing the Taylor approximation to the exponential function may look
like
import numpy as np
N = 5
index_set = range(N+1)
a = np.zeros(len(index_set))
e = np.zeros(len(index_set))
a[0] = 1
A.4 Taylor Series and Approximations 109
This small program first prints the exact value ex for x = 0.5, and then the
Taylor approximation and associated error for n = 1 to n = 5. The Taylor
series approximation is most accurate close to x = 0, so choosing a larger
value of x leads to larger errors, and we need to also increase n for the
approximation to be accurate.
References
111
112 REFERENCES
A-stability, 40 eigenvalues, 37
action potential, 61 embedded method, 65
adaptive methods, 61 epidemiology, 79
AdaptiveESDIRK class, 72 error analysis, 16
AdaptiveODESolver, 66 error estimates, 64
amplification factor, 38 ESDIRK class, 57
ESDIRK method, 51
backward Euler method, 40 Euler method
Butcher tableau, 26 implicit, 40
Euler method
class explicit, 2
hierarchy, 28 Euler-Heun method, 66
abstract base class, 29 Euler-Heun method
for ODE solver, 7 implementation, 69
for right-hand side, 9 exponential growth, 2
superclass/base class, 28
collocation methods, 47 Fehlberg method, 70
convergence, 16 Fibonacci numbers, 100
Covid19, 88 FIRK method, 47
Crank-Nicolson method, 41 forward Euler method, 2
ForwardEuler class, 7, 11
Dahlquist test equation, 36
difference equations, 95 Gauss methods, 48
difference equations
implementation, 96 Heun’s method, 25
SIR model, 81 Hodgkin-Huxley model, 61
systems of, 104
DIRK method, 50 ImplicitRK class, 53
Dormand-Prince method, 71 incubation period, 86
113
114 INDEX
SciPy, 20
SDIRK class, 56
SDIRK method, 51
SEEIIR model
class implementation, 92