import numpy as np
import sympy
from sympy import pi
sympy.init_printing()
import matplotlib.pyplot as plt
import matplotlib as mpl
from mpl_toolkits.mplot3d.axes3d import Axes3D
Appendix H — Ordinary Differential Equations
In this appendix, we will explore both symbolic and numerical approaches to solving ODE problems. For symbolic methods, we use the sympy
module, and for numerical integration of ODEs, we use functions from the integrate
module in scipy
H.1 Importing modules
H.2 Ordinary differential equations
The simplest form of an ordinary differential equation is
\[ \frac{dy(x)}{dx} = f(x,y(x))\]
where \(y(x)\) is the unknown function and \(f(x, y(x))\) is known. \(\,\)Only the first derivative occurs in the equation, and it is therefore an example of a first-order ODE
More generally, \(~\)we can write an \(n\)-th order ODE in explicit form as
\[ \frac{d^ny}{dx^n} = f\left(x,y,\frac{dy}{dx},\cdots,\frac{d^{n-1}y}{dx^{n-1}}\right)\]
or in implicit form as
\[ F\left(x,y,\frac{dy}{dx},\cdots,\frac{d^{n}y}{dx^{n}}\right)=0\]
where \(f\) and \(F\) are known functions.
The general solution of an \(n\)-th order ODE have \(n\) free parameters that we need to specify, for example, as initial conditions for the unknown function and \(n -1\) of its derivatives
\[y(0)=y_0, y'(0)=y_1, \cdots, y^{n-1}(0)=y_{n-1}\]
An ODE can always be rewritten as a system of first-order ODEs. Specifically, the \(n\)-th order ODE on the explicit form can be written in the standard form by introducing \(n\) new functions
\[y_1 = y,\, y_2=\frac{dy}{dx},\, \cdots,\, y_n = \frac{dy^{n-1}}{dx^{n-1}}\]
This gives the following system of first-order ODEs:
\[\frac{d}{dx} \begin{bmatrix} y_1\\ \vdots\\ y_{n-1}\\ y_n \end{bmatrix} = \begin{bmatrix} y_2\\ \vdots\\ y_n\\ f(x,y_1,\cdots, y_n) \end{bmatrix} \]
which also can be written in a more compact vector form:
\[\frac{d\mathbf{y}}{dx} = \mathbf{f}(x,\mathbf{y}(x))\]
This canonical form is particularly useful for numerical solutions of ODEs, and it is common that numerical methods for solving ODEs take the function \(\mathbf{f} = (f_1, f_2,\cdots,f_n)\)
If the functions \(f_1, f_2, \cdots, f_n\) are all linear, then the corresponding system of ODEs can be written on the simple form
\[ \frac{d\mathbf{y}}{dx} = \mathbf{A}(\mathbf{x})\mathbf{y}(\mathbf{x}) +\mathbf{r}(\mathbf{x}),\]
where \(\mathbf{A}(\mathbf{x})\) is an \(n\times n\) matrix, and \(\mathbf{r}(\mathbf{x})\) is an \(n\)-vector, that only depends on \(\mathbf{x}\). In this form, the \(\mathbf{r}(\mathbf{x})\) is known as the source term, and the linear system is known as homogeneous if \(\mathbf{r}(\mathbf{x})=\mathbf{0}\), and nonhomogeneous otherwise
For certain properties and forms of the function \(\mathbf{f}(\mathbf{x}, \mathbf{y}(\mathbf{x}))\), there may be known solutions and special methods for solving the corresponding ODE problem, but there is no general method for an arbitrary \(\mathbf{f}(\mathbf{x}, \mathbf{y}(\mathbf{x}))\), other than approximate numerical methods
In addition to the properties of \(\mathbf{f}(\mathbf{x}, \mathbf{y}(\mathbf{x}))\), the boundary conditions for an ODE also influence the solvability of the ODE problem, as well as which numerical approaches are available. There are two main types of boundary conditions for ODE problems: initial value conditions and boundary value conditions
H.3 Symbolic solution to ODEs
Sympy
provides a generic ODE solversympy.dsolve
, which is able to find analytical solutions to many elementary ODEs. Thesympy.dsolve
function attempts to automatically classify a given ODE, and it may attempt a variety of techniques to find its solutionWhile
dsolve
can be used to solve many simple ODEs symbolically, as we will see in the following, it is worth keeping in mind that most ODEs cannot be solved analytically. Typical examples of ODEs where one can hope to find a symbolic solution are ODEs of first or second-order, or linear systems of first-order ODEs with only a few unknown functionsThe first example is the simple first-order ODE for Newton’s cooling law,
\[\frac{dT(t)}{dt} = -k(T(t) -T_a)\]
with the initial value \(T(0) = T_0\)
= sympy.symbols('t, k, T_0, T_a')
t, k, T0, Ta = sympy.Function('T')
T
= T(t).diff(t) +k *(T(t) - Ta)
ode
# display the equation including the equality sign
# and a right-hand side that is zero
0) sympy.Eq(ode,
\(\displaystyle k \left(- T_{a} + T{\left(t \right)}\right) + \frac{d}{d t} T{\left(t \right)} = 0\)
= sympy.dsolve(ode)
ode_sol ode_sol
\(\displaystyle T{\left(t \right)} = C_{1} e^{- k t} + T_{a}\)
- The return value from
sympy.dsolve
is an instance ofsympy.Eq
, which is a symbolic representation of an equality
ode_sol.lhs, ode_sol.rhs
\(\displaystyle \left( T{\left(t \right)}, \ C_{1} e^{- k t} + T_{a}\right)\)
Once the general solution has been found, \(\,\)we need to use the initial conditions to find the values of the yet-to-be-determined integration constants
We first create a dictionary that describes the initial condition,
ics = {T(0): T0}
, which we can use withsympy
’ssubs
method to apply the initial condition to the solution of the ODE. This results in an equation for the unknown integration constant \(C_1\):
= {T(0): T0}
ics = sympy.Eq(ode_sol.lhs.subs(t, 0).subs(ics),
C_eq 0))
ode_sol.rhs.subs(t, C_eq
\(\displaystyle T_{0} = C_{1} + T_{a}\)
- In the present example, the equation for \(C_1\) is trivial to solve, but for the sake of generality, here we solve it using
sympy.solve
:
= sympy.solve(C_eq)
C_sol C_sol
\(\displaystyle \left[ \left\{ C_{1} : T_{0} - T_{a}\right\}\right]\)
0]) ode_sol.subs(C_sol[
\(\displaystyle T{\left(t \right)} = T_{a} + \left(T_{0} - T_{a}\right) e^{- k t}\)
- Applying the initial conditions and solving for the undetermined integration constants can be slightly tedious, and it worth while to collect these steps in a reusable function. The following function
apply_ics
is a basic implementation that generalizes these steps to a differential equation of arbitrary order
def apply_ics(sol, ics, x, known_params):
"""
Apply the initial conditions (ics),
given as a dictionary on the form
ics = {y(0): y0, y(x).diff(x).subs(x, 0): y1, ...}
to the solution of the ODE with independent variable x
The undetermined integration constants
C1, C2, ...
are extracted from the free symbols of the ODE solution,
excluding symbols in the known_params list
"""
= sol.free_symbols - set(known_params)
free_params = [(sol.lhs.diff(x, n)
eqs - sol.rhs.diff(x, n)).subs(x, 0).subs(ics)
for n in range(len(ics))]
= sympy.solve(eqs, free_params)
sol_params
return sol.subs(sol_params)
= apply_ics(ode_sol, ics, t, [k, Ta])
T_sol T_sol
\(\displaystyle T{\left(t \right)} = T_{a} + \left(T_{0} - T_{a}\right) e^{- k t}\)
= np.linspace(0, 4, 100)
x = sympy.lambdify((t, k),
y 5, Ta: 1}),
T_sol.rhs.subs({T0: 'numpy')
= plt.subplots(figsize=(6, 4))
fig, ax
for k in [1, 2, 3]:
=rf'$k={k}$')
ax.plot(x, y(x, k), label
rf'${sympy.latex(T_sol)}$', fontsize=12)
ax.set_title(r"$x$", fontsize=12)
ax.set_xlabel(r"$y$", fontsize=12)
ax.set_ylabel(
ax.legend()0, 4)
ax.set_xlim(1, 5)
ax.set_ylim(='both', direction='in', axis='x', pad=7) ax.tick_params(which
As an example of a slightly more complicated problem, \(\,\)consider the ODE for a damped harmonic oscillator, which is a second-order ODE on the form
\[ \frac{d^2x(t)}{dt^2} +2\gamma \omega_0 \frac{dx(t)}{dt} +\omega_0^2 x(t) = 0\]
where \(x(t)\) is the position of the oscillator at time \(t\), \(\,\omega_0\) is the frequency for the undamped case, and \(\,\gamma\) is the damping ratio
= sympy.symbols('t, omega_0, gamma',
t, omega0, gamma =True)
positive= sympy.Function("x")
x
= (x(t).diff(t, 2)
ode +2 *gamma* omega0 *x(t).diff(t)
+omega0**2 *x(t))
0) sympy.Eq(ode,
\(\displaystyle 2 \gamma \omega_{0} \frac{d}{d t} x{\left(t \right)} + \omega_{0}^{2} x{\left(t \right)} + \frac{d^{2}}{d t^{2}} x{\left(t \right)} = 0\)
= sympy.dsolve(ode)
ode_sol ode_sol
\(\displaystyle x{\left(t \right)} = C_{1} e^{\omega_{0} t \left(- \gamma + \sqrt{\gamma - 1} \sqrt{\gamma + 1}\right)} + C_{2} e^{- \omega_{0} t \left(\gamma + \sqrt{\gamma - 1} \sqrt{\gamma + 1}\right)}\)
- Since this is a second-order ODE, there are two undetermined integration constants in the general solution. We need to specify initial conditions for both the position \(x(0)\) and the velocity \(x'(0)\) to single out a particular solution to the ODE
= {x(0): 1, x(t).diff(t).subs(t, 0): 0}
ics = apply_ics(ode_sol, ics, t, [omega0, gamma])
x_sol = x_sol.rhs.args[0].args[0]
C1 C1
\(\displaystyle \frac{\gamma}{2 \sqrt{\gamma - 1} \sqrt{\gamma + 1}} + \frac{1}{2}\)
= x_sol.rhs.args[1].args[0]
C2 C2
\(\displaystyle - \frac{\gamma}{2 \sqrt{\gamma - 1} \sqrt{\gamma + 1}} + \frac{1}{2}\)
- This is the solution for the dynamics of the oscillator for arbitrary values of \(t\), \(\omega_0\) and \(\gamma\). \(~\)However, substituting \(\gamma = 1\), \(\,\)which corresponds to critical damping, directly into this expression results in a division by zero error, and for this particular choice of \(\gamma\), we need to be careful and compute the limit where \(\gamma \rightarrow 1\)
= sympy.limit(x_sol.rhs, gamma, 1)
x_critical x_critical
\(\displaystyle \left(\omega_{0} t + 1\right) e^{- \omega_{0} t}\)
- Finally, \(\,\)we plot the solutions for \(\omega_0 = 2\pi\) and a sequence of different values of the damping ratio \(\gamma\)
= plt.subplots(figsize=(6, 4))
fig, ax
= np.linspace(0, 3, 250)
tt for g in [0.1, 0.5, 1, 2.0, 5.0]:
if g == 1:
= sympy.lambdify(t,
x_t 2.0 *pi}), 'numpy')
x_critical.subs({omega0: else:
= sympy.lambdify(t,
x_t 2.0 *pi, gamma: g}), 'numpy')
x_sol.rhs.subs({omega0: =rf'$\gamma = {g: .1f}$')
ax.plot(tt, x_t(tt).real, label
r"$t$", fontsize=12)
ax.set_xlabel(r"$x(t)$", fontsize=12)
ax.set_ylabel(
ax.legend()0, 3)
ax.set_xlim(-1, 1.5)
ax.set_ylim(='both', direction='in', axis='x', pad=7) ax.tick_params(which
- Even many first-order ODEs cannot be solved exactly in terms of elementary functions. For example, consider \(\displaystyle\frac{dy}{dx} = x +y^2\), \(\,\)which is an example of an ODE that does not have any closed-form solution
= sympy.symbols('x', positive=True)
x = sympy.Function('y')
y = sympy.Function('u')
u
= y(x)**2 +x
f
= y(x).diff(x) -f
ode ode
\(\displaystyle - x - y^{2}{\left(x \right)} + \frac{d}{d x} y{\left(x \right)}\)
sympy.classify_ode(ode)
('1st_rational_riccati', '1st_power_series', 'lie_group')
- See sec-2-5
# sympy bug: '1st_rational_riccati' not working
= x
q0 = sympy.Integer('0')
q1 = sympy.Integer('1')
q2
= q2 *q0
S = q1 +q2.diff(x) /q2
R
= u(x).diff(x, 2) -R *u(x).diff(x) +S *u(x)
ode_ = sympy.dsolve(ode_).rhs
u_sol u_sol
\(\displaystyle C_{1} Ai\left(- x\right) + C_{2} Bi\left(- x\right)\)
= -u_sol.diff(x) /(q2 *u_sol)
y_sol y_sol
\(\displaystyle \frac{C_{1} Ai^\prime\left(- x\right) + C_{2} Bi^\prime\left(- x\right)}{C_{1} Ai\left(- x\right) + C_{2} Bi\left(- x\right)}\)
= sympy.Symbol('C1')
C1 = sympy.solve(y_sol.subs({x: 0}), C1)[0]
C1_ C1_
\(\displaystyle \sqrt{3} C_{2}\)
= y_sol.subs({C1: C1_}).cancel()
y_sol y_sol
\(\displaystyle \frac{\sqrt{3} Ai^\prime\left(- x\right) + Bi^\prime\left(- x\right)}{\sqrt{3} Ai\left(- x\right) + Bi\left(- x\right)}\)
For many other types of equations,
sympy
outright fails to produce any solution at all. \(\,\)For example, if we attempt to solve the second-order\[\frac{d^2y}{dx^2} =x +y^2\]
2), f) sympy.Eq(y(x).diff(x,
\(\displaystyle \frac{d^{2}}{d x^{2}} y{\left(x \right)} = x + y^{2}{\left(x \right)}\)
try:
2) -f)
sympy.dsolve(y(x).diff(x, except Exception as e:
print(e)
solve: Cannot solve -x - y(x)**2 + Derivative(y(x), (x, 2))
H.3.1 Direction fields
A direction field graph is a simple but useful technique to visualize possible solutions to arbitrary first-order ODEs. It is made up of short lines that show the slope of the unknown function on a grid in the \(x\)–\(y\) plane. This graph can be easily produced because the slope of \(y(x)\) at arbitrary points of the \(x\)–\(y\) plane is given by the definition of the ODE:
\[\frac{dy}{dx}=f(x,y(x))\]
The reason why the direction field graph is useful is that smooth and continuous curves that tangent the slope lines (at every point) in the direction field graph are possible solutions to the ODE
def plot_direction_field(x, y_x, f_xy,
=(-5, 5), y_lim=(-5, 5), ax=None):
x_lim
= sympy.lambdify((x, y_x), f_xy, 'numpy')
f_np
= np.linspace(x_lim[0], x_lim[1], 20)
x_vec = np.linspace(y_lim[0], y_lim[1], 20)
y_vec
if ax is None:
= plt.subplots(figsize=(4, 4))
_, ax
= x_vec[1] -x_vec[0]
dx = y_vec[1] -y_vec[0]
dy = 0.8 *dx
scale
for m, x_ in enumerate(x_vec):
for n, y_ in enumerate(y_vec):
= f_np(x_, y_) *dx
Dy = scale *dx /np.sqrt(dx**2 +Dy**2)
Dx = scale *Dy /np.sqrt(dx**2 +Dy**2)
Dy -Dx/2, x_ +Dx/2],
ax.plot([x_ -Dy/2, y_ +Dy/2], 'b', lw=1)
[y_ +Dx/2, y_ +Dy/2, 'bo', ms=2)
ax.plot(x_
0], x_lim[1])
ax.set_xlim(x_lim[0], y_lim[1])
ax.set_ylim(y_lim[='both', direction='in')
ax.tick_params(which= sympy.latex(sympy.Eq(y(x).diff(x), f_xy))
title_ rf'${title_}$', fontsize=12)
ax.set_title(
return ax
- With this function, we can produce the direction field graphs for the ODEs. The direction lines in the graphs suggest how the curves that are solutions to the corresponding ODE behave, and direction field graphs are therefore a useful tool for visualizing solutions to ODEs that cannot be solved analytically
= sympy.symbols('x')
x = sympy.Function('y')
y
= y(x)**2 +x f
= plt.subplots(1, 1, figsize=(5, 5))
fig, ax
# left panel
=ax)
plot_direction_field(x, y(x), f, ax
= np.linspace(-5, 5, 300)
x_vec = sympy.lambdify(x, y_sol)(x_vec)
y_vec
= 20
threshold > threshold] = np.inf
y_vec[y_vec <-threshold] = np.inf
y_vec[y_vec
'r', lw=2) ax.plot(x_vec, y_vec,
The following code generates the direction field graphs for
\(f(x,y(x))=y^2(x) +x\)
\(f(x,y(x))=-x/y(x)\)
\(f(x,y(x))=y^2(x)/x\)
= sympy.symbols('x')
x = sympy.Function('y')
y
= plt.subplots(3, 1, figsize=(4, 13))
fig, axes
**2 +x, ax=axes[0])
plot_direction_field(x, y(x), y(x)-x /y(x), ax=axes[1])
plot_direction_field(x, y(x), **2 /x, ax=axes[2]) plot_direction_field(x, y(x), y(x)
H.3.2 Solving ODEs using Laplace Transformation
A technique that can be used to solve certain ODE problems is to do Laplace transform on the ODE, which for many problems results in an algebraic equation that is easier to solve. The solution to the algebraic equation can then be transformed back to the original domain with an inverse Laplace transform, to obtain the solution to the original problem. For example, consider the following differential equation for a driven harmonic oscillator:
\[ \frac{d^2 y}{dt^2} +2\frac{dy}{dt} +10y =2\sin 3t\]
_laplace_trabsform_expansion.py
laplace_tranform_()
is the expanded version oflaplace_transform()
subs_()
is the modified function ofsub()
method
= sympy.symbols("t", positive=True)
t = sympy.Function("y")
y
= (y(t).diff(t, 2)
ode +2 *y(t).diff(t)
+10 *y(t)
-2 *sympy.sin(3*t))
= {y(0): 1, y(t).diff(t).subs(t, 0): 0}
ics
ode
\(\displaystyle 10 y{\left(t \right)} - 2 \sin{\left(3 t \right)} + 2 \frac{d}{d t} y{\left(t \right)} + \frac{d^{2}}{d t^{2}} y{\left(t \right)}\)
= sympy.symbols('s', real=True)
s = sympy.Function('Y')
Y
= laplace_transform_(y(t), t, s) Ly
= subs_(laplace_transform_(ode, t, s), Ly, Y(s)).subs(ics)
Lode Lode
\(\displaystyle s^{2} Y{\left(s \right)} + 2 s Y{\left(s \right)} - s + 10 Y{\left(s \right)} - 2 - \frac{6}{s^{2} + 9}\)
= sympy.solve(Lode, Y(s))[0]
Y_sol Y_sol
\(\displaystyle \frac{s^{3} + 2 s^{2} + 9 s + 24}{s^{4} + 2 s^{3} + 19 s^{2} + 18 s + 90}\)
= sympy.inverse_laplace_transform(Y_sol, s, t)
y_sol y_sol
\(\displaystyle \frac{2 \sin{\left(3 t \right)}}{37} - \frac{12 \cos{\left(3 t \right)}}{37} + \frac{43 e^{- t} \sin{\left(3 t \right)}}{111} + \frac{49 e^{- t} \cos{\left(3 t \right)}}{37}\)
= sympy.lambdify(t, y_sol, modules=['numpy']) y_t
= plt.subplots(figsize=(6, 4))
fig, ax
= np.linspace(0, 10, 500)
tt
ax.plot(tt, y_t(tt))r"$t$", fontsize=12)
ax.set_xlabel(r"$y(t)$", fontsize=12)
ax.set_ylabel( fig.tight_layout()
H.4 Numerical methods for solving ODEs
In practice, ODE problems are therefore mainly solved with numerical methods. There are many approaches to solving ODEs numerically, and most of them are designed for problems that are formulated as a system of first-order ODEs on the standard form:
\[ \frac{d\mathbf{y}}{dx} = \mathbf{f}(x, \mathbf{y}(x))\]
The basic idea of many numerical methods for ODEs is captured in Euler’s method. This method can, for example, be derived from a Taylor-series expansion of \(y(x)\) around the point \(x\):
\[ y(x+h) = y(x) +\frac{dy(x)}{dx}h +\frac{d^2 y(x)}{dx^2} h^2 + \cdots, \]
where for notational simplicity we consider the case when \(y(x)\) is a scalar function
By dropping terms of second order or higher, \(\,\)we get the approximate equation
\[ y(x+h) \approx y(x) +\frac{dy(x)}{dx}h, \]
which is accurate to first order in the step size \(h\). This equation can be turned into an iteration formula by discretizing the \(x\) variable, \(x_0\), \(x_1\), \(\cdots\), \(x_k\), \(\,\)choosing the step size \(h_k =x_{k+1} -x_{k}\), \(\,\)and denoting \(y_k = y(x_k)\). \(\,\)The resulting iteration formula:
\[\color{red}{y_{k+1} \approx y_{k} +f(x_k, y_k) h_k},\]
is known as the forward Euler method, and it is said to be an explicit form because given the value of the \(y_k\), we can directly compute \(y_{k+1}\) using the formula
There are two types of errors involved in this approach: First, the truncation of the Taylor series gives error that limits the accuracy of the method. Second, using the approximation of \(y_k\) given by the previous iteration when computing \(y_{k+1}\) gives an additional error that may accumulate over successive iterations, and that can affect the stability of the method
An alternative form, which can be derived in a similar manner, is the backward Euler method, \(\,\)given by the iteration formula
\[\color{red}{y_{k+1} \approx y_{k} +f(x_{k+1}, y_{k+1}) h_k}\]
This is an example of a backward differentiation formula(BDF), which is implicit, \(\,\)because \(y_{k+1}\) occurs on both sides of the equation. \(~\)To compute \(y_{k+1}\), \(\text{ }\)we therefore need to solve an algebraic equation (for example using Newton’s method)
Implicit methods are more complicated to implement than explicit methods, and each iteration requires more computational work. However, the advantage is that implicit methods generally have larger stability region and better accuracy, which means that larger step size \(h_k\) can be used while still obtaining an accurate and stable solution
Implicit methods are often particularly useful for stiff problems, which loosely speaking are ODE problems that describe dynamics with multiple disparate time scales
There are several methods to improve upon the first-order Euler forward and backward methods. One strategy is to keep higher-order terms in the Taylor-series expansion of \(y(x + h)\). \(\,\)However, such methods require evaluating higher-order derivatives of \(y(x)\), which may be a problem
Ways around this problem include to approximate the higher-order derivatives using finite-difference approximations of the derivatives, or by sampling the function \(f(x, y(x))\) at intermediary points in the interval \([x_k, x_{k+1}]\)
An example of this type of method is the well-known Runge-Kutta method, which is a single-step method that uses additional evaluations of \(f(x, y(x))\). The most well-known Runge-Kutta method is the 4th-order scheme:
\[\color{red}{y_{k+1} = y_k +\frac{h_k}{6} (k_1 +2k_2 +2k_3 +k_4)}\]
where
\[ \begin{aligned} k_1 &= f(t_k, \,y_k)\\ k_2 &= f\left(t_k + \frac{h_k}{2},\, y_k +h_k \frac{k_1}{2} \right)\\ k_3 &= f\left(t_k + \frac{h_k}{2},\, y_k +h_k \frac{k_2}{2} \right)\\ k_4 &= f(t_k +h_k, \,y_k +h_k k_3) \end{aligned} \]
Here, \(k_1\) to \(k_4\) are four different evaluations of the ODE function \(f(x, y(x))\) that are used in the explicit formula for \(y_{k+1}\) given above. The resulting estimate of \(y_{k+1}\) is accurate to 4-th order, with an error of 5-th order.
Higher-order schemes that use more function evaluations can also be constructed. By combining two methods of different order, it can be possible to also estimate the error in the approximation.
A popular combination is the Runge-Kutta 4-th and 5-th order schemes, which results in a 4-th order accurate method with error estimates. It is known as RK45 or the Runge-Kutta-Fehlberg method. The Dormand-Prince method is another example of a higher-order method, which additionally uses adaptive step size control. For example, the 8-5-3 method combines 3rd and 5-th order schemes to produce an 8-th order method
An alternative method is to use more than one previous value of \(y_k\) to compute \(y_{k+1}\). Such methods are known as multistep methods, and can in general be written on the form
\[\color{red}{y_{k + s} = \sum_{n=0}^{s-1} a_n y_{k + n} + h \sum_{n=0}^{s} b_n f(x_{k + n}, y_{k + n})}\]
This formula means that to compute \(y_{k+s}\), the previous \(s\) values of \(y_k\) and \(f(x_k, y_k)\) are used (known as an \(s\)-step method). The choices of the coefficients \(a_n\) and \(b_n\) give rise to different multistep methods. Note that if \(b_s = 0\), then the method is explicit, and if \(b_s \neq 0\) it is implicit
For example, \(b_0 = b_1 = \cdots = b_{s-1} = 0\) gives the general formula for an \(s\)-step BDF formula, where \(a_n\) and \(b_s\) are chosen to maximize the order of the accuracy. For example, the one-step BDF method with \(b_1 = a_0 = 1\) reduces to the backward Euler method, and the two-step BDF method,
\[y_{k+2} = a_0 y_k +a_1 y_{k+1} +hb_2 f(x_{k+2}, y_{k+2})\]
when solved for the coefficients \((a_0, a_1, b_2)\) becomes \((-\frac{1}{3},\,\frac{4}{3},\,\frac{2}{3})\)
Another family of multistep methods are the Adams methods, which result from the choice
\[\color{red}{a_0 = a_1 = \cdots = a_{s-2} = 0, \text{ and } a_{s-1}=1}\]
where again the remaining unknown coefficients are chosen to maximize the order of the method
Specifically, the explicit method with \(b_s = 0\) are known as Adams-Bashforth methods, and the implicit methods with \(b_s \neq 0\) are known as Adams-Moulton methods. For example, the one-step Adams-Bashforth and Adams-Moulton methods reduce to the forward and backward Euler methods, respectively
And the two-step methods are
\[y_{k+2} = y_{k+1} + h \left[ -\frac{1}{2} f(x_{k}, y_{k}) + \frac{3}{2} f(x_{k+1}, y_{k+1}) \right]\]
and
\[y_{k+1} = y_{k} + \frac{h}{2} \left[ f(x_{k}, y_{k}) + f(x_{k+1}, y_{k+1}) \right]\]
In general, explicit methods are more convenient to implement and less computationally demanding to iterate than implicit methods, which in principle requires solving (a potentially nonlinear) equation in each iteration with an initial guess for the unknown \(y_{k+1}\)
However, as mentioned earlier, implicit methods often are more accurate and have superior stability properties. A compromise that retain some of the advantages of both methods is to combine explicit and implicit methods in the following way:
First compute \(y_{k+1}\) using an explicit method,
then use this \(y_{k+1}\) as an initial guess for solving the equation for \(y_{k+1}\) given by an implicit method.
This equation does not need to be solved exactly, and since the initial guess from the explicit method should be quite good, a fixed number of iterations, using for example Newton’s method, could be sufficient. Methods like these, where an explicit method is used to predict \(y_{k+1}\) and an implicit method is used to correct the prediction, are called predictor-corrector methods
H.5 Numerical integration of ODEs using scipy
The
integrate
module ofscipy
provides the ODE solver interface:integrate.solve_ivp
. This function numerically integrates a system of ordinary differential equations given an initial value:\[\begin{aligned} \frac{d\mathbf{y}}{dt}&= \mathbf{f}(t, \mathbf{y})\\ \mathbf{y}(t_0)&= \mathbf{y}_0 \end{aligned}\]
Explicit Runge-Kutta methods
('RK23','RK45','DOP853')
should be used for non-stiff problems and implicit methods('Radau','BDF')
for stiff problems. Among Runge-Kutta methods,'DOP853'
is recommended for solving with high precision (low values ofrtol
andatol
)If not sure, first try to run
'RK45'
. If it makes unusually many iterations, diverges, or fails, your problem is likely to be stiff and you should use'Radau'
or'BDF'
.'LSODA'
can also be a good universal choice, but it might be somewhat less convenient to work with as it wraps old Fortran codeFor example, \(\,\)consider again the scalar ODE
\[y'(x) = f(x, y(x)) = x + y^2(x)\]
from scipy import integrate
def dydx(x, y):
return x +y*y
= [0]
y0
= integrate.solve_ivp(dydx, [0, 1.9], y0)
sol_p = integrate.solve_ivp(dydx, [0, -5], y0) sol_m
= plt.subplots(1, 1, figsize=(6, 6))
fig, ax =ax)
plot_direction_field(x, y(x), f, ax'g', lw=2)
ax.plot(sol_p.t, sol_p.y.T, 'r', lw=2) ax.plot(sol_m.t, sol_m.y.T,
In the previous example, we solved a scalar ODE problem. More often we are interested in vector-valued ODE problems (systems of ODEs). To see how we can solve that kind of problems using
solve_ivp
, consider the Lokta-Volterra equations for the dynamics of a population of predator and prey animals (a classic example of coupled ODEs). The equations are\[ x'(t) = a x - b x y \]
\[ y'(t) = c x y - d y \]
where \(x(t)\) is the number of prey animals and \(y(t)\) is the number of predator animals, and the coefficients \(a\), \(b\), \(c\), and \(d\) describe the rates of the processes in the model
= 0.4, 0.002, 0.001, 0.7
a, b, c, d def f(t, xy):
= xy
x, y return [a *x -b *x *y, c *x *y -d *y]
= [600, 400]
xy0 = integrate.solve_ivp(f, [0, 50], xy0,
sol =np.linspace(0, 50, 400))
t_eval
= sol.t
t = sol.y.T xy_t
= plt.subplots(2, 1, figsize=(6, 12))
fig, axes
0].plot(t, xy_t[:,0], 'r', label="Prey")
axes[0].plot(t, xy_t[:,1], 'b', label="Predator")
axes[0].set_xlabel("Time")
axes[0].set_ylabel("Number of animals")
axes[0].legend()
axes[0].set_xlim(0, 50)
axes[0].set_ylim(0, 1400)
axes[0].tick_params(which='both', direction='in')
axes[
1].plot(xy_t[:,0], xy_t[:,1], 'k')
axes[1].set_xlabel("Number of prey")
axes[1].set_ylabel("Number of predators")
axes[1].set_xlim(300, 1300)
axes[1].set_ylim(50, 450)
axes[1].tick_params(which='both', direction='in') axes[
In the previous two examples, the function for the right-hand side of the ODE was implemented without additional arguments. Rather than using global variables, it is often convenient and elegant to implement the
f
function in such a way that it takes arguments for all its coefficient or parameters. To illustrate this point, let’s consider another famous ODE problem: the Lorenz equations, which is the following system of three coupled nonlinear ODEs,\[ \begin{aligned} x'(t) &= \sigma(y - x)\\ y'(t) &= x(\rho - z) - y \\ z'(t) &= x y - \beta z \end{aligned} \]
= 28, 8, 8/3.0
rho, sigma, beta
def f(t, xyz, rho, sigma, beta):
= xyz
x, y, z return [sigma *(y -x), x *(rho -z) -y, x *y -beta *z]
= [1.0, 1.0, 1.0]
xyz0
= [0, 25]
t_span = np.linspace(0, 25, 10000)
t_eval
= integrate.solve_ivp(f, t_span, xyz0,
sol1 =t_eval, args=(rho, sigma, beta))
t_eval= integrate.solve_ivp(f, t_span, xyz0,
sol2 =t_eval, args=(rho, sigma, 0.6*beta))
t_eval= integrate.solve_ivp(f, t_span, xyz0,
sol3 =t_eval, args=(rho, 2*sigma, 0.6*beta)) t_eval
= plt.subplots(3, 1, figsize=(7, 15),
fig, (ax1, ax2, ax3) ={'projection': '3d'})
subplot_kw
= sol1.y
xyz1 = sol2.y
xyz2 = sol3.y
xyz3
for ax, xyz, c in [(ax1, xyz1, 'r'),
'b'),
(ax2, xyz2, 'g')]:
(ax3, xyz3, 0,:], xyz[1,:], xyz[2,:], c, alpha=0.5)
ax.plot(xyz[
'$x$', fontsize=12)
ax.set_xlabel('$y$', fontsize=12)
ax.set_ylabel('$z$', fontsize=12)
ax.set_zlabel(-15, 0, 15])
ax.set_xticks([-20, 0, 20])
ax.set_yticks([0, 20, 40]) ax.set_zticks([
To illustrate how to use the
integrate.ode
interface, \(\,\)we first look at the following sets of coupled second-order ODEs:\[ \begin{aligned} m_1 x_1''(t) + \gamma_1 x_1'(t) + k_1 x_1(t) - k_2 (x_2(t) - x_1(t)) &= 0\\ m_2 x_2''(t) + \gamma_2 x_2'(t) + k_2 (x_2(t) - x_1(t)) &= 0 \end{aligned} \]
These equations describe the dynamics of two coupled springs, where \(x_1(t)\) and \(x_2(t)\) are the displacement of two objects, with masses \(m_1\) and \(m_2\), from their equilibrium positions. The object at \(x_1\) is connected to a fixed wall via a spring with spring constant \(k_1\), and connected to the object at \(x_2\) via a spring with spring constant \(k_2\). Both objects are subject to damping forces characterized by \(\gamma_1\) and \(\gamma_2\), respectively
To solve this kind of problem with
scipy
, we first have to write it in standard form, which we can do by introducing \(y_0(t) =x_1(t)\), \(\,y_1(t) =x_1'(t)\), \(\,y_2(t) =x_2(t)\), \(\,\)and \(\,y_3(t) =x_2'(t)\), which results in four coupled first-order equations:\[ \frac{d}{dt} \begin{bmatrix} y_0(t)\\ y_1(t)\\ y_2(t)\\ y_3(t) \end{bmatrix} = \begin{bmatrix} y_1(t) \\ \left[-\gamma_1 y_1(t) -k_1 y_0(t) -k_2 y_0(t) +k_2 y_2(t)\right]/m_1 \\ y_3(t) \\ \left[-\gamma_2 y_3(t) -k_2 y_2(t) +k_2 y_0(t)\right]/m_2 \end{bmatrix} \]
def f(t, y, args):
= args
m1, k1, g1, m2, k2, g2
return [y[1],
- k1/m1 *y[0] +k2/m1 *(y[2] -y[0]) -g1/m1 *y[1],
3],
y[- k2/m2 *(y[2] -y[0]) -g2/m2 *y[3]]
= 1.0, 10.0, 0.5, 2.0, 40.0, 0.25
m1, k1, g1, m2, k2, g2 = (m1, k1, g1, m2, k2, g2)
args
= [1.0, 0, 0.5, 0]
y0 = np.linspace(0, 20, 1000) t
- Instead of calling the
solve_ivp
function, \(\,\)we now need to create an instance of the classintegrate.ode
, \(\,\)passing the ODE function \(f\) as an argument:
= integrate.ode(f) r
# the possible first argument:
# vode, zvode, lsoda, dopri5 and dop853
'lsoda')
r.set_integrator(0])
r.set_initial_value(y0, t[
r.set_f_params(args)
= t[1] -t[0]
dt = np.zeros((len(t), len(y0)))
y = 0
idx while r.successful() and r.t < t[-1]:
= r.y
y[idx, :] +dt)
r.integrate(r.t += 1 idx
- This is arguably not as convenient as simply calling the
solive_ivp
, but it offers extra flexibility that sometimes is exactly what is needed
= plt.figure(figsize=(5, 10))
fig
= plt.subplot2grid((10, 4), (0, 0), colspan=4, rowspan=3)
ax1 = plt.subplot2grid((10, 4), (3, 0), colspan=4, rowspan=3)
ax2 = plt.subplot2grid((10, 4), (6, 0), colspan=4, rowspan=4)
ax3
# x_1 vs time plot
0], 'r')
ax1.plot(t, y[:, '$x_1$', fontsize=12)
ax1.set_ylabel(-1, -.5, 0, .5, 1])
ax1.set_yticks([0, 20)
ax1.set_xlim(-1.0, 1.0)
ax1.set_ylim(='both', direction='in', axis='x', pad=7)
ax1.tick_params(which
# x2 vs time plot
2], 'b')
ax2.plot(t, y[:, '$t$', fontsize=12)
ax2.set_xlabel('$x_2$', fontsize=12)
ax2.set_ylabel(-1, -.5, 0, .5, 1])
ax2.set_yticks([0, 20)
ax2.set_xlim(-1.0, 1.0)
ax2.set_ylim(='both', direction='in', axis='x', pad=7)
ax2.tick_params(which
# x1 and x2 phase space plot
0], y[:, 2], 'g')
ax3.plot(y[:, '$x_1$', fontsize=12)
ax3.set_xlabel('$x_2$', fontsize=12)
ax3.set_ylabel(-1, -.5, 0, .5, 1])
ax3.set_xticks([-1, -.5, 0, .5, 1])
ax3.set_yticks([-1.0, 1.00)
ax3.set_xlim(-1.0, 1.0)
ax3.set_ylim(='both', direction='in', axis='x', pad=7)
ax3.tick_params(which
fig.tight_layout()
- Python functions for both \(f(t, y(t))\) and its Jacobian can conveniently be generated using
sympy
’slambdify
, provided that the ODE problem first can be defined as asympy
expression. This symbolic-numeric hybrid approach is a powerful method to solving ODE problems(See sec-double-pendulum)