3.3. Numerical methods for 2nd-order ODEs#
We’ve gone over how to solve 1st-order ODEs using numerical methods, but what about 2nd-order or any higher-order ODEs? We can use the same methods we’ve already discussed by transforming our higher-order ODEs into a system of first-order ODEs.
Meaning, if we are given a 2nd-order ODE
we can transform this into a system of two 1st-order ODEs that are coupled:
where
Thus, instead of a 2nd-order ODE to solve, we have two 1st-order ODEs:
So, we can use all of the methods we have talked about so far to solve 2nd-order ODEs by transforming the one equation into a system of two 1st-order equations.
3.3.1. Higher-order ODEs#
This works for higher-order ODEs too! For example, if we have a 3rd-order ODE, we can transform it into a system of three 1st-order ODEs:
# import libraries for numerical functions and plotting
import numpy as np
import matplotlib.pyplot as plt
# these lines are only for helping improve the display
import matplotlib_inline.backend_inline
matplotlib_inline.backend_inline.set_matplotlib_formats('pdf', 'png')
plt.rcParams['figure.dpi']= 300
plt.rcParams['savefig.dpi'] = 300
3.3.2. Example: mass-spring problem#
For example, let’s solve a forced damped mass-spring problem given by a 2nd-order ODE:
with the initial conditions
We start by transforming the equation into two 1st-order ODEs. Let’s use the variables
3.3.2.1. Forward Euler#
Then, let’s solve numerically using the forward Euler method. Recall that the recursion formula for forward Euler is:
where
Let’s solve using
We can compare this against the exact solution, obtainable using the method of undetermined coefficients:
# plot exact solution first
time = np.linspace(0, 3)
y_exact = (
-6*np.exp(-3*time) + 7*np.exp(-2*time) +
np.sin(time) - np.cos(time)
)
plt.plot(time, y_exact, label='Exact')
omega = 1
dt = 0.1
time = np.arange(0, 3.001, dt)
f = lambda t,z1,z2: 10*np.sin(omega*t) - 5*z2 - 6*z1
z1 = np.zeros_like(time)
z2 = np.zeros_like(time)
# initial conditions
z1[0] = 0
z2[0] = 5
# Forward Euler iterations
for idx, t in enumerate(time[:-1]):
z1[idx+1] = z1[idx] + dt*z2[idx]
z2[idx+1] = z2[idx] + dt*f(t, z1[idx], z2[idx])
plt.plot(time, z1, 'o--', label='Forward Euler solution')
plt.xlabel('time')
plt.ylabel('displacement')
plt.legend()
plt.grid(True)
plt.show()

Since the method is only first-order accurate, we see both qualitative and quantiative differences compared with the exact solution, but the overall solution agrees. We can use more-accurate methods to better-capture the exact solution.
3.3.2.2. Heun’s Method#
For schemes that involve more than one stage, like Heun’s method, we’ll need to implement both stages for each 1st-order ODE. For example:
# plot exact solution first
time = np.linspace(0, 3)
y_exact = (
-6*np.exp(-3*time) + 7*np.exp(-2*time) +
np.sin(time) - np.cos(time)
)
plt.plot(time, y_exact, label='Exact')
omega = 1
dt = 0.1
time = np.arange(0, 3.001, dt)
f = lambda t,z1,z2: 10*np.sin(omega*t) - 5*z2 - 6*z1
z1 = np.zeros_like(time)
z2 = np.zeros_like(time)
# initial conditions
z1[0] = 0
z2[0] = 5
# Forward Euler iterations
for idx, t in enumerate(time[:-1]):
# predictor
z1p = z1[idx] + dt*z2[idx]
z2p = z2[idx] + dt*f(t, z1[idx], z2[idx])
# corrector
z1[idx+1] = z1[idx] + 0.5*dt*(z2[idx] + z2p)
z2[idx+1] = z2[idx] + 0.5*dt*(
f(t, z1[idx], z2[idx]) + f(time[idx+1], z1p, z2p)
)
plt.plot(time, z1, 'o--', label='Heuns method solution')
plt.xlabel('time')
plt.ylabel('displacement')
plt.legend()
plt.grid(True)
plt.show()

As expected, the numerical solution using Heun’s method agrees much more closely to the exact solution (vs. forward Euler), though we can still see some error due to the relatively large step size.
3.3.2.3. Runge-Kutta method#
We can also solve using the 4th-order Runge–Kutta method available through the solve_ivp()
function, by providing a function that defines the system of first-order ODEs.
Since there are technically two variables being integrated (sol.y
will be a two-dimensional array.
The first column, sol.y[0,:]
, will contain sol.y[1,;]
, will contain
from scipy.integrate import solve_ivp
# plot exact solution first
time = np.linspace(0, 3)
y_exact = (
-6*np.exp(-3*time) + 7*np.exp(-2*time) +
np.sin(time) - np.cos(time)
)
plt.plot(time, y_exact, label='Exact')
def mass_spring(time, z):
'''Function providing dz/dt derivative system'''
omega = 1
return [z[1], 10*np.sin(omega*time) - 6*z[0] - 5*z[1]]
sol = solve_ivp(mass_spring, [0, 3], [0, 5], method='RK45')
plt.plot(
sol.t, sol.y[0,:], '.',
markersize=15, label='RK45 (solve_ivp)'
)
plt.grid(True)
plt.legend()
plt.show()

3.3.3. Backward Euler for 2nd-order ODEs#
We saw how to implement the Backward Euler method for a 1st-order ODE, but what about a 2nd-order ODE? (Or in general a system of 1st-order ODEs?)
The recursion formula is the same, except now our dependent variable is an array/vector:
where the bolded
In general, we can use Backward Euler to solve 2nd-order ODEs in a similar fashion as our other numerical methods:
Convert the 2nd-order ODE into a system of two 1st-order ODEs
Insert the ODEs into the Backward Euler recursion formula and solve for
The main difference is that we will now have a system of two equations and two unknowns:
Let’s demonstrate with an example:
where the exact solution is
To solve numerically,
Convert the 2nd-order ODE into a system of two 1st-order ODEs:
Then, for the derivatives of these variables:
Then plug these derivatives into the Backward Euler recursion formulas and solve for
and :
or, represented as a product of a coefficient matrix and a vector:
To isolate
Let’s confirm that this gives us a good, well-behaved numerical solution and compare with the Forward Euler method:
# Exact solution
time = np.linspace(0, 5)
y_exact = lambda t: -0.75*np.exp(-5*t) - 1.25*np.exp(-t) + 2
plt.plot(time, y_exact(time), label='Exact')
dt = 0.1
time = np.arange(0, 5.001, dt)
# Forward Euler
f = lambda t,y1,y2: 10 - 6*y2 - 5*y1
y1 = np.zeros_like(time)
y2 = np.zeros_like(time)
y1[0] = 0
y2[0] = 5
for idx, t in enumerate(time[:-1]):
y1[idx+1] = y1[idx] + dt*y2[idx]
y2[idx+1] = y2[idx] + dt*f(t, y1[idx], y2[idx])
plt.plot(time, y1, 's', label='Forward Euler')
# Backward Euler
# use a single array to contain both variables being integrated
y_vals = np.zeros((len(time), 2))
y_vals[0,0] = 0
y_vals[0,1] = 5
for idx, t in enumerate(time[:-1]):
denom = 1 + 6*dt + 5*dt**2
y_vals[idx+1,0] = (
y_vals[idx,0]*(1 + 6*dt) + dt*(y_vals[idx,1] + 10*dt)
) / denom
y_vals[idx+1,1] = (
y_vals[idx,1] + 10*dt - y_vals[idx,0]*5*dt
) / denom
plt.plot(time, y_vals[:,0], 'o', label='Backward Euler')
plt.legend()
plt.grid(True)
plt.show()
print(
'Maximum error of Forward Euler: '
f'{np.max(np.abs(y1 - y_exact(time))): .3f}'
)
print(
'Maximum error of Forward Euler: '
f'{np.max(np.abs(y_vals[:,0] - y_exact(time))): .3f}'
)

Maximum error of Forward Euler: 0.099
Maximum error of Forward Euler: 0.068
So, for
Let’s see how they compare for a larger step size:
# Exact solution
time = np.linspace(0, 5)
y_exact = lambda t: -0.75*np.exp(-5*t) - 1.25*np.exp(-t) + 2
plt.plot(time, y_exact(time), label='Exact')
dt = 0.5
time = np.arange(0, 5.001, dt)
# Forward Euler
f = lambda t,y1,y2: 10 - 6*y2 - 5*y1
y1 = np.zeros_like(time)
y2 = np.zeros_like(time)
y1[0] = 0
y2[0] = 5
for idx, t in enumerate(time[:-1]):
y1[idx+1] = y1[idx] + dt*y2[idx]
y2[idx+1] = y2[idx] + dt*f(t, y1[idx], y2[idx])
plt.plot(time, y1, 's', label='Forward Euler')
# Backward Euler
# use a single array to contain both variables being integrated
y_vals = np.zeros((len(time), 2))
y_vals[0,0] = 0
y_vals[0,1] = 5
for idx, t in enumerate(time[:-1]):
denom = 1 + 6*dt + 5*dt**2
y_vals[idx+1,0] = (
y_vals[idx,0]*(1 + 6*dt) + dt*(y_vals[idx,1] + 10*dt)
) / denom
y_vals[idx+1,1] = (
y_vals[idx,1] + 10*dt - y_vals[idx,0]*5*dt
) / denom
plt.plot(time, y_vals[:,0], 'o', label='Backward Euler')
plt.legend()
plt.grid(True)
plt.show()
print(
'Maximum error of Forward Euler: '
f'{np.max(np.abs(y1 - y_exact(time))): .3f}'
)
print(
'Maximum error of Backward Euler: '
f'{np.max(np.abs(y_vals[:,0] - y_exact(time))): .3f}'
)

Maximum error of Forward Euler: 43.242
Maximum error of Backward Euler: 0.228
Backward Euler, since it is unconditionally stable, remains well-behaved at this larger step size, while the Forward Euler method blows up.
One other thing: instead of using Cramer’s rule to get expressions for np.linalg.solve()
.
To do that, we could replace it with
A = np.array([1, -dt], [5*dt, 1+6*dt])
b = np.array([y_vals[idx,0], y_vals[idx,1]+10*dt])
y_vals[idx+1,:] = np.linalg.solve(A, b)
Let’s confirm that this gives the same answer:
# Exact solution
time = np.linspace(0, 5)
y_exact = lambda t: -0.75*np.exp(-5*t) - 1.25*np.exp(-t) + 2
plt.plot(time, y_exact(time), label='Exact')
dt = 0.1
time = np.arange(0, 5.001, dt)
# Backward Euler
y_vals = np.zeros((len(time), 2))
y_vals[0,0] = 0
y_vals[0,1] = 5
for idx, t in enumerate(time[:-1]):
A = np.array([[1, -dt], [5*dt, 1+6*dt]])
b = np.array([y_vals[idx,0], y_vals[idx,1]+10*dt])
y_vals[idx+1,:] = np.linalg.solve(A, b)
plt.plot(time, y_vals[:,0], 'o', label='Backward Euler')
plt.legend()
plt.grid(True)
plt.show()

3.3.4. Cramer’s Rule#
Cramer’s Rule provides a solution method for a system of linear equations, where the number of equations equals the number of unknowns. It works for any number of equations/unknowns, but isn’t really practical for more than two or three. We’ll focus on using it for a system of two equations, with two unknowns
where
The solutions for the unknowns are then
where
This works as long as the determinant does not equal zero.