Solving ODEs with scipy.integrate.solve_ivp
Contents
Solving ODEs with scipy.integrate.solve_ivp#
Solving ordinary differential equations (ODEs)#
Here we will revisit the differential equations solved in 5300_Jupyter_Python_intro_01.ipynb with odeint
, only now we’ll use solve_ivp
from Scipy. We’ll compare the new and old solutions as we go.
First-order ODE#
# Import the required modules
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp # Now preferred to odeint
Let’s try a one-dimensional first-order ODE, say:
\(\begin{align} \quad \frac{dv}{dt} = -g, \quad \mbox{with} \quad v(0) = 10 \end{align}\)
in some appropriate units (we’ll use MKS units by default). This ODE can be separated and directly integrated:
\(\begin{align} \int_{v_0=10}^{v} dv' = - g \int_{0}^{t} dt' \quad\Longrightarrow\quad v - v_0 = - g (t - 0) \quad\Longrightarrow\quad v(t) = 10 - gt \end{align}\)
The goal is to find the solution \(v(t)\) as an array v_pts
at the times in the array t_pts
.
# Define a function which calculates the derivative
def dv_dt_new(t, v, g=9.8):
"""Returns the right side of a simple first-order ODE with default g."""
return -g
t_start = 0.
t_end = 10.
t_pts = np.linspace(t_start, t_end, 20) # 20 points between t=0 and t=10.
v_0 = np.array([10.0]) # initial condition, in form of a list or numpy array
abserr = 1.e-8
relerr = 1.e-8
solution = solve_ivp(dv_dt_new, (t_start, t_end), v_0, t_eval=t_pts,
rtol=relerr, atol=abserr)
# solve_ivp( function for rhs with (t, v) argument (cf. (v,t) for odeint),
# tspan=(starting t value, ending t value),
# initial value of v(t), array of points we want to know v(t),
# method='RK45' is the default method,
# rtol=1.e-3, atol=1.e-6 are default tolerances
# )
v_pts = solution.y # array of results at t_pts
v_pts.shape # 1 x 100 matrix (row vector)
(1, 20)
Here’s how we did it before with odeint:
from scipy.integrate import odeint
# Define a function which calculates the derivative
def dv_dt(v, t, g=9.8):
"""Returns the right side of a simple first-order ODE with default g."""
return -g
t_pts = np.linspace(0., 10., 20) # 20 points between t=0 and t=10.
v_0 = 10. # the initial condition
v_pts_odeint = odeint(dv_dt, v_0, t_pts) # odeint( function for rhs,
# initial value of v(t),
# array of t values )
v_pts_odeint.shape # 100 x 1 matrix (column vector)
(20, 1)
Make a table comparing results (using flatten()
to make the matrices into arrays):
print(' t v(t) [solve_ivp] v(t) [odeint]')
for t, v_solve_ivp, v_odeint in zip(t_pts,
v_pts.flatten(),
v_pts_odeint.flatten()):
print(f' {t:6.3f} {v_solve_ivp:12.7f} {v_odeint:12.7f}')
t v(t) [solve_ivp] v(t) [odeint]
0.000 10.0000000 10.0000000
0.526 4.8421053 4.8421053
1.053 -0.3157895 -0.3157895
1.579 -5.4736842 -5.4736842
2.105 -10.6315789 -10.6315789
2.632 -15.7894737 -15.7894737
3.158 -20.9473684 -20.9473684
3.684 -26.1052632 -26.1052632
4.211 -31.2631579 -31.2631579
4.737 -36.4210526 -36.4210526
5.263 -41.5789474 -41.5789474
5.789 -46.7368421 -46.7368421
6.316 -51.8947368 -51.8947368
6.842 -57.0526316 -57.0526316
7.368 -62.2105263 -62.2105263
7.895 -67.3684211 -67.3684211
8.421 -72.5263158 -72.5263158
8.947 -77.6842105 -77.6842105
9.474 -82.8421053 -82.8421053
10.000 -88.0000000 -88.0000000
Differences between solve_ivp
and odeint
:
dv_dt(t, v)
vs.dv_dt(v, t)
, i.e., the function definitions have the arguments reversed.With
odeint
, you only specify the full array of \(t\) points you want to know \(v(t)\) at. Withsolve_ivp
, you first specify the starting \(t\) and ending \(t\) as a tuple:(t_start, t_end)
and then (optionally) specifyt_eval=t_pts
to evaluate \(v\) at the points in thet_pts
array.solve_ivp
returns an object from which \(v(t)\) (and other results) can be found, whileode_int
returns \(v(t)\).For this single first-order equation, \(v(t)\) is returned for the \(N\) requested \(t\) points as a \(1 \times N\) two-dimensional array by
solve_ivp
and as a \(N \times 1\) array byodeint
.odeint
has no choice of solver while thesolve_ivp
solver can be set bymethod
. The default ismethod='RK45'
, which is good, general-purpose Runge-Kutta solver.
Second-order ODE#
Suppose we have a second-order ODE such as:
We can turn this into two first-order equations by defining a new dependent variable. For example,
Now introduce the vector
We can solve this system of ODEs using solve_ivp
with lists, as follows. We will try it first without specifying the relative and absolute error tolerances rtol and atol.
# Define a function for the right side
def dU_dx_new(x, U):
"""Right side of the differential equation to be solved.
U is a two-component vector with y=U[0] and z=U[1].
Thus this function should return [y', z']
"""
return [U[1], -2*U[1] - 2*U[0] + np.cos(2*x)]
# initial condition U_0 = [y(0)=0, z(0)=y'(0)=0]
U_0 = [0., 0.]
x_pts = np.linspace(0, 15, 20) # Set up the mesh of x points
result = solve_ivp(dU_dx_new, (0, 15), U_0, t_eval=x_pts)
y_pts = result.y[0,:] # Ok, this is tricky. For each x, result.y has two
# components. We want the first component for all
# x, which is y(x). The 0 means the first index and
# the : means all of the x values.
Here’s how we did it before with odeint
:
# Define a function for the right side
def dU_dx(U, x):
"""Right side of the differential equation to be solved.
U is a two-component vector with y=U[0] and z=U[1].
Thus this function should return [y', z']
"""
return [U[1], -2*U[1] - 2*U[0] + np.cos(2*x)]
# initial condition U_0 = [y(0)=0, z(0)=y'(0)=0]
U_0 = [0., 0.]
x_pts = np.linspace(0, 15, 20) # Set up the mesh of x points
U_pts = odeint(dU_dx, U_0, x_pts) # U_pts is a 2-dimensional array
y_pts_odeint = U_pts[:,0] # Ok, this is tricky. For each x, U_pts has two
# components. We want the upper component for all
# x, which is y(x). The : means all of the first
# index, which is x, and the 0 means the first
# component in the other dimension.
Make a table comparing results (using flatten()
to make the matrices into arrays):
print(' x y(x) [solve_ivp] y(x) [odeint]')
for x, y_solve_ivp, y_odeint in zip(x_pts,
y_pts.flatten(),
y_pts_odeint.flatten()):
print(f' {x:6.3f} {y_solve_ivp:12.7f} {y_odeint:12.7f}')
x y(x) [solve_ivp] y(x) [odeint]
0.000 0.0000000 0.0000000
0.789 0.1360331 0.1360684
1.579 0.0346996 0.0347028
2.368 -0.2285869 -0.2287035
3.158 -0.0975124 -0.0974702
3.947 0.2065854 0.2067492
4.737 0.0927442 0.0927536
5.526 -0.2041596 -0.2042677
6.316 -0.0865498 -0.0865921
7.105 0.2065843 0.2066669
7.895 0.0832378 0.0832707
8.684 -0.2080557 -0.2081975
9.474 -0.0800124 -0.0799972
10.263 0.2092958 0.2094602
11.053 0.0766106 0.0765810
11.842 -0.2105482 -0.2107011
12.632 -0.0731339 -0.0731411
13.421 0.2117964 0.2118952
14.211 0.0696499 0.0696868
15.000 -0.2129584 -0.2130316
Not very close agreement by the end. Run both again with greater accuracy.
relerr = 1.e-10
abserr = 1.e-10
result = solve_ivp(dU_dx_new, (0, 15), U_0, t_eval=x_pts,
rtol=relerr, atol=abserr)
y_pts = result.y[0,:]
U_pts = odeint(dU_dx, U_0, x_pts,
rtol=relerr, atol=abserr)
y_pts_odeint = U_pts[:,0]
print(' x y(x) [solve_ivp] y(x) [odeint]')
for x, y_solve_ivp, y_odeint in zip(x_pts,
y_pts.flatten(),
y_pts_odeint.flatten()):
print(f' {x:6.3f} {y_solve_ivp:12.7f} {y_odeint:12.7f}')
x y(x) [solve_ivp] y(x) [odeint]
0.000 0.0000000 0.0000000
0.789 0.1360684 0.1360684
1.579 0.0347028 0.0347028
2.368 -0.2287035 -0.2287035
3.158 -0.0974702 -0.0974702
3.947 0.2067492 0.2067492
4.737 0.0927536 0.0927536
5.526 -0.2042678 -0.2042678
6.316 -0.0865921 -0.0865921
7.105 0.2066669 0.2066669
7.895 0.0832707 0.0832707
8.684 -0.2081975 -0.2081975
9.474 -0.0799972 -0.0799972
10.263 0.2094602 0.2094602
11.053 0.0765810 0.0765810
11.842 -0.2107011 -0.2107011
12.632 -0.0731411 -0.0731411
13.421 0.2118952 0.2118952
14.211 0.0696868 0.0696868
15.000 -0.2130316 -0.2130316
Comparing the results from when we didn’t specify the errors we see that the default error tolerances for solve_ivp were insufficient. Moral: specify them explicitly.