ODE Numerical Methods
ODE Numerical Methods¶
Overview¶
Numerical methods for ordinary differential equations calculate approximate solutions when analytical solutions cannot be found or are difficult to obtain. We will learn the major methods from Euler's method to 4th-order Runge-Kutta.
1. Euler Method¶
1.1 Forward Euler¶
import numpy as np
import matplotlib.pyplot as plt
def forward_euler(f, y0, t_span, n_steps):
"""
Forward Euler method
dy/dt = f(t, y)
y_{n+1} = y_n + h * f(t_n, y_n)
"""
t = np.linspace(t_span[0], t_span[1], n_steps + 1)
h = t[1] - t[0]
y = np.zeros(n_steps + 1)
y[0] = y0
for i in range(n_steps):
y[i+1] = y[i] + h * f(t[i], y[i])
return t, y
# Test: dy/dt = -y, y(0) = 1
# Analytical solution: y = e^(-t)
f = lambda t, y: -y
y0 = 1.0
t_span = (0, 5)
# Compare with various step numbers
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
t_exact = np.linspace(0, 5, 200)
y_exact = np.exp(-t_exact)
for n in [10, 20, 50, 100]:
t, y = forward_euler(f, y0, t_span, n)
axes[0].plot(t, y, 'o-', markersize=3, label=f'n={n}')
axes[0].plot(t_exact, y_exact, 'k-', linewidth=2, label='Exact solution')
axes[0].set_xlabel('t')
axes[0].set_ylabel('y')
axes[0].set_title('Forward Euler Method')
axes[0].legend()
axes[0].grid(True)
# Error analysis
errors = []
n_values = [10, 20, 50, 100, 200, 500]
for n in n_values:
t, y = forward_euler(f, y0, t_span, n)
error = np.abs(y[-1] - np.exp(-5))
errors.append(error)
axes[1].loglog(n_values, errors, 'bo-', label='Actual error')
axes[1].loglog(n_values, [5/n for n in n_values], 'r--', label='O(h)')
axes[1].set_xlabel('Number of steps n')
axes[1].set_ylabel('Error')
axes[1].set_title('Error vs Steps (O(h) convergence)')
axes[1].legend()
axes[1].grid(True)
plt.tight_layout()
plt.show()
1.2 Backward Euler¶
def backward_euler(f, df_dy, y0, t_span, n_steps, tol=1e-10):
"""
Backward Euler method (implicit)
y_{n+1} = y_n + h * f(t_{n+1}, y_{n+1})
Solve implicit equation using Newton-Raphson
"""
t = np.linspace(t_span[0], t_span[1], n_steps + 1)
h = t[1] - t[0]
y = np.zeros(n_steps + 1)
y[0] = y0
for i in range(n_steps):
# Newton-Raphson: g(y_{n+1}) = y_{n+1} - y_n - h*f(t_{n+1}, y_{n+1}) = 0
y_guess = y[i] # Initial guess
for _ in range(100): # Maximum iterations
g = y_guess - y[i] - h * f(t[i+1], y_guess)
dg = 1 - h * df_dy(t[i+1], y_guess)
y_new = y_guess - g / dg
if abs(y_new - y_guess) < tol:
break
y_guess = y_new
y[i+1] = y_new
return t, y
# Test: dy/dt = -y
f = lambda t, y: -y
df_dy = lambda t, y: -1 # ∂f/∂y
t_fw, y_fw = forward_euler(f, 1.0, (0, 5), 20)
t_bw, y_bw = backward_euler(f, df_dy, 1.0, (0, 5), 20)
t_exact = np.linspace(0, 5, 200)
y_exact = np.exp(-t_exact)
plt.figure(figsize=(10, 5))
plt.plot(t_fw, y_fw, 'bo-', label='Forward Euler')
plt.plot(t_bw, y_bw, 'rs-', label='Backward Euler')
plt.plot(t_exact, y_exact, 'k-', linewidth=2, label='Exact solution')
plt.xlabel('t')
plt.ylabel('y')
plt.title('Forward vs Backward Euler')
plt.legend()
plt.grid(True)
plt.show()
2. Runge-Kutta Methods¶
2.1 RK2 (Midpoint, Heun)¶
def rk2_midpoint(f, y0, t_span, n_steps):
"""RK2 midpoint method"""
t = np.linspace(t_span[0], t_span[1], n_steps + 1)
h = t[1] - t[0]
y = np.zeros(n_steps + 1)
y[0] = y0
for i in range(n_steps):
k1 = f(t[i], y[i])
k2 = f(t[i] + h/2, y[i] + h/2 * k1)
y[i+1] = y[i] + h * k2
return t, y
def rk2_heun(f, y0, t_span, n_steps):
"""RK2 Heun method (modified Euler)"""
t = np.linspace(t_span[0], t_span[1], n_steps + 1)
h = t[1] - t[0]
y = np.zeros(n_steps + 1)
y[0] = y0
for i in range(n_steps):
k1 = f(t[i], y[i])
k2 = f(t[i] + h, y[i] + h * k1)
y[i+1] = y[i] + h/2 * (k1 + k2)
return t, y
# Comparison
f = lambda t, y: -y
t_span = (0, 5)
n = 20
t_euler, y_euler = forward_euler(f, 1.0, t_span, n)
t_mid, y_mid = rk2_midpoint(f, 1.0, t_span, n)
t_heun, y_heun = rk2_heun(f, 1.0, t_span, n)
t_exact = np.linspace(0, 5, 200)
y_exact = np.exp(-t_exact)
plt.figure(figsize=(10, 5))
plt.plot(t_euler, y_euler, 'o-', label='Euler', alpha=0.7)
plt.plot(t_mid, y_mid, 's-', label='RK2 Midpoint', alpha=0.7)
plt.plot(t_heun, y_heun, '^-', label='RK2 Heun', alpha=0.7)
plt.plot(t_exact, y_exact, 'k-', linewidth=2, label='Exact solution')
plt.xlabel('t')
plt.ylabel('y')
plt.title('Euler vs RK2 Comparison')
plt.legend()
plt.grid(True)
plt.show()
2.2 RK4 (Classical 4th Order)¶
def rk4(f, y0, t_span, n_steps):
"""
Classical 4th-order Runge-Kutta method
k1 = f(t_n, y_n)
k2 = f(t_n + h/2, y_n + h*k1/2)
k3 = f(t_n + h/2, y_n + h*k2/2)
k4 = f(t_n + h, y_n + h*k3)
y_{n+1} = y_n + h/6 * (k1 + 2*k2 + 2*k3 + k4)
"""
t = np.linspace(t_span[0], t_span[1], n_steps + 1)
h = t[1] - t[0]
y = np.zeros(n_steps + 1)
y[0] = y0
for i in range(n_steps):
k1 = f(t[i], y[i])
k2 = f(t[i] + h/2, y[i] + h/2 * k1)
k3 = f(t[i] + h/2, y[i] + h/2 * k2)
k4 = f(t[i] + h, y[i] + h * k3)
y[i+1] = y[i] + h/6 * (k1 + 2*k2 + 2*k3 + k4)
return t, y
# Accuracy comparison
f = lambda t, y: -y
t_span = (0, 5)
methods = [
('Euler', forward_euler),
('RK2', rk2_heun),
('RK4', rk4)
]
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Solution comparison
n = 10
for name, method in methods:
t, y = method(f, 1.0, t_span, n)
axes[0].plot(t, y, 'o-', label=name)
t_exact = np.linspace(0, 5, 200)
axes[0].plot(t_exact, np.exp(-t_exact), 'k-', linewidth=2, label='Exact solution')
axes[0].set_xlabel('t')
axes[0].set_ylabel('y')
axes[0].set_title(f'n={n} steps')
axes[0].legend()
axes[0].grid(True)
# Convergence order
n_values = [10, 20, 50, 100, 200]
for name, method in methods:
errors = []
for n in n_values:
t, y = method(f, 1.0, t_span, n)
error = np.abs(y[-1] - np.exp(-5))
errors.append(error)
axes[1].loglog(n_values, errors, 'o-', label=name)
# Theoretical convergence orders
axes[1].loglog(n_values, [1/n for n in n_values], 'k--', alpha=0.5, label='O(h)')
axes[1].loglog(n_values, [1/n**2 for n in n_values], 'k:', alpha=0.5, label='O(h²)')
axes[1].loglog(n_values, [1/n**4 for n in n_values], 'k-.', alpha=0.5, label='O(h⁴)')
axes[1].set_xlabel('Number of steps n')
axes[1].set_ylabel('Error')
axes[1].set_title('Convergence Order Comparison')
axes[1].legend()
axes[1].grid(True)
plt.tight_layout()
plt.show()
3. Application to Systems¶
3.1 Vectorized RK4¶
def rk4_system(f, y0, t_span, n_steps):
"""
RK4 for systems of ODEs
y: vector
f: vector function
"""
t = np.linspace(t_span[0], t_span[1], n_steps + 1)
h = t[1] - t[0]
y = np.zeros((n_steps + 1, len(y0)))
y[0] = y0
for i in range(n_steps):
k1 = np.array(f(t[i], y[i]))
k2 = np.array(f(t[i] + h/2, y[i] + h/2 * k1))
k3 = np.array(f(t[i] + h/2, y[i] + h/2 * k2))
k4 = np.array(f(t[i] + h, y[i] + h * k3))
y[i+1] = y[i] + h/6 * (k1 + 2*k2 + 2*k3 + k4)
return t, y
# Simple harmonic oscillator: x'' + ω²x = 0
# System: y₁' = y₂, y₂' = -ω²y₁
omega = 2.0
def harmonic_oscillator(t, y):
return [y[1], -omega**2 * y[0]]
t_span = (0, 10)
y0 = [1.0, 0.0] # x(0) = 1, x'(0) = 0
t, y = rk4_system(harmonic_oscillator, y0, t_span, 200)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Time response
axes[0].plot(t, y[:, 0], label='x(t)')
axes[0].plot(t, y[:, 1], label="x'(t)")
axes[0].plot(t, np.cos(omega * t), 'k--', alpha=0.5, label='Exact solution')
axes[0].set_xlabel('t')
axes[0].set_ylabel('Value')
axes[0].set_title('Harmonic Oscillator')
axes[0].legend()
axes[0].grid(True)
# Phase plane
axes[1].plot(y[:, 0], y[:, 1])
axes[1].set_xlabel('x')
axes[1].set_ylabel("x'")
axes[1].set_title('Phase Space')
axes[1].set_aspect('equal')
axes[1].grid(True)
plt.tight_layout()
plt.show()
3.2 Damped Oscillator¶
def damped_oscillator_example():
"""Damped oscillator: x'' + 2γx' + ω₀²x = 0"""
omega0 = 2.0
gamma = 0.3 # Damping coefficient
def damped_osc(t, y):
return [y[1], -2*gamma*y[1] - omega0**2 * y[0]]
t_span = (0, 15)
y0 = [1.0, 0.0]
t, y = rk4_system(damped_osc, y0, t_span, 300)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Time response
omega_d = np.sqrt(omega0**2 - gamma**2) # Damped frequency
envelope = np.exp(-gamma * t)
axes[0].plot(t, y[:, 0], label='x(t)')
axes[0].plot(t, envelope, 'r--', label='Damping envelope')
axes[0].plot(t, -envelope, 'r--')
axes[0].set_xlabel('t')
axes[0].set_ylabel('x')
axes[0].set_title(f'Damped Oscillator (γ={gamma}, ω₀={omega0})')
axes[0].legend()
axes[0].grid(True)
# Phase space (spiral)
axes[1].plot(y[:, 0], y[:, 1])
axes[1].scatter([0], [0], color='red', s=100, zorder=5, label='Stable equilibrium')
axes[1].set_xlabel('x')
axes[1].set_ylabel("x'")
axes[1].set_title('Phase Space')
axes[1].legend()
axes[1].grid(True)
plt.tight_layout()
plt.show()
damped_oscillator_example()
4. Adaptive Step Size¶
4.1 Error Estimation¶
def rk45_step(f, t, y, h):
"""
RK4-5 (Dormand-Prince) one step
Calculate 4th and 5th order approximations simultaneously for error estimation
"""
# Dormand-Prince coefficients
c = [0, 1/5, 3/10, 4/5, 8/9, 1, 1]
a = [
[],
[1/5],
[3/40, 9/40],
[44/45, -56/15, 32/9],
[19372/6561, -25360/2187, 64448/6561, -212/729],
[9017/3168, -355/33, 46732/5247, 49/176, -5103/18656],
[35/384, 0, 500/1113, 125/192, -2187/6784, 11/84]
]
b5 = [35/384, 0, 500/1113, 125/192, -2187/6784, 11/84, 0]
b4 = [5179/57600, 0, 7571/16695, 393/640, -92097/339200, 187/2100, 1/40]
# Calculate k
k = [np.array(f(t, y))]
for i in range(1, 7):
yi = y.copy()
for j in range(i):
yi = yi + h * a[i][j] * k[j]
k.append(np.array(f(t + c[i]*h, yi)))
# 5th order approximation
y5 = y.copy()
for i in range(7):
y5 = y5 + h * b5[i] * k[i]
# 4th order approximation (for error estimation)
y4 = y.copy()
for i in range(7):
y4 = y4 + h * b4[i] * k[i]
# Error estimation
error = np.max(np.abs(y5 - y4))
return y5, error
def adaptive_rk45(f, y0, t_span, tol=1e-6, h_init=0.1):
"""Adaptive RK4-5 solver"""
t = [t_span[0]]
y = [np.array(y0)]
h = h_init
while t[-1] < t_span[1]:
# Ensure step size doesn't exceed range
if t[-1] + h > t_span[1]:
h = t_span[1] - t[-1]
# RK4-5 step
y_new, error = rk45_step(f, t[-1], y[-1], h)
# Adjust step based on error
if error < tol:
t.append(t[-1] + h)
y.append(y_new)
# Increase step size (safety factor 0.9)
if error > 0:
h = min(h * 0.9 * (tol / error) ** 0.2, 2 * h)
else:
# Decrease step size
h = max(h * 0.9 * (tol / error) ** 0.25, h / 4)
return np.array(t), np.array(y)
# Test
def stiff_like(t, y):
"""Function with rapid changes"""
return [-10 * y[0] if t < 1 else -0.1 * y[0]]
t_adapt, y_adapt = adaptive_rk45(stiff_like, [1.0], (0, 5), tol=1e-6)
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.plot(t_adapt, y_adapt[:, 0], 'o-', markersize=3)
plt.xlabel('t')
plt.ylabel('y')
plt.title('Adaptive RK4-5')
plt.grid(True)
plt.subplot(1, 2, 2)
dt = np.diff(t_adapt)
plt.plot(t_adapt[:-1], dt, 'o-')
plt.xlabel('t')
plt.ylabel('Step size h')
plt.title('Step Size Variation')
plt.grid(True)
plt.tight_layout()
plt.show()
5. Solving ODEs with SciPy¶
5.1 solve_ivp¶
from scipy.integrate import solve_ivp
# Lorenz system
def lorenz(t, state, sigma=10, rho=28, beta=8/3):
x, y, z = state
return [
sigma * (y - x),
x * (rho - z) - y,
x * y - beta * z
]
t_span = (0, 50)
y0 = [1.0, 1.0, 1.0]
# Compare various solvers
solvers = ['RK45', 'RK23', 'DOP853', 'Radau', 'BDF']
fig = plt.figure(figsize=(15, 10))
for idx, method in enumerate(solvers, 1):
sol = solve_ivp(lorenz, t_span, y0, method=method,
dense_output=True, max_step=0.01)
ax = fig.add_subplot(2, 3, idx, projection='3d')
ax.plot(sol.y[0], sol.y[1], sol.y[2], linewidth=0.5)
ax.set_title(f'{method} (n={len(sol.t)})')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')
plt.tight_layout()
plt.show()
5.2 Event Detection¶
def projectile_motion():
"""Projectile motion - ground impact detection"""
g = 9.8
def projectile(t, state):
x, y, vx, vy = state
return [vx, vy, 0, -g]
def hit_ground(t, state):
return state[1] # Event when y = 0
hit_ground.terminal = True # Stop when event occurs
hit_ground.direction = -1 # Only when decreasing (falling)
# Initial condition: 45 degree angle, velocity 20 m/s
v0 = 20
angle = 45 * np.pi / 180
y0 = [0, 0, v0 * np.cos(angle), v0 * np.sin(angle)]
sol = solve_ivp(projectile, (0, 10), y0, events=hit_ground,
dense_output=True, max_step=0.01)
print(f"Flight time: {sol.t_events[0][0]:.4f} s")
print(f"Range: {sol.y_events[0][0][0]:.4f} m")
plt.figure(figsize=(10, 5))
plt.plot(sol.y[0], sol.y[1])
plt.scatter(sol.y_events[0][0][0], 0, color='red', s=100, label='Landing point')
plt.xlabel('x (m)')
plt.ylabel('y (m)')
plt.title('Projectile Motion')
plt.legend()
plt.grid(True)
plt.axis('equal')
plt.show()
projectile_motion()
6. Stability Analysis¶
6.1 Stability Regions¶
def stability_regions():
"""Stability regions of numerical methods"""
# Test equation: dy/dt = λy
# Exact solution: y(nh) = e^(λnh)
# Numerical solution: y_n = R(λh)^n * y_0
# Region where |R(z)| ≤ 1 in complex plane
x = np.linspace(-4, 2, 200)
y = np.linspace(-3, 3, 200)
X, Y = np.meshgrid(x, y)
Z = X + 1j * Y # z = λh
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
# Forward Euler: R(z) = 1 + z
R_euler = np.abs(1 + Z)
axes[0, 0].contour(X, Y, R_euler, levels=[1], colors='blue')
axes[0, 0].contourf(X, Y, R_euler, levels=[0, 1], alpha=0.3)
axes[0, 0].set_title('Forward Euler: |1 + z| ≤ 1')
axes[0, 0].set_xlabel('Re(λh)')
axes[0, 0].set_ylabel('Im(λh)')
axes[0, 0].grid(True)
axes[0, 0].set_aspect('equal')
# Backward Euler: R(z) = 1/(1-z)
R_backward = np.abs(1 / (1 - Z))
axes[0, 1].contour(X, Y, R_backward, levels=[1], colors='blue')
axes[0, 1].contourf(X, Y, R_backward, levels=[0, 1], alpha=0.3)
axes[0, 1].set_title('Backward Euler: |1/(1-z)| ≤ 1')
axes[0, 1].set_xlabel('Re(λh)')
axes[0, 1].set_ylabel('Im(λh)')
axes[0, 1].grid(True)
axes[0, 1].set_aspect('equal')
# RK2: R(z) = 1 + z + z²/2
R_rk2 = np.abs(1 + Z + Z**2/2)
axes[1, 0].contour(X, Y, R_rk2, levels=[1], colors='blue')
axes[1, 0].contourf(X, Y, R_rk2, levels=[0, 1], alpha=0.3)
axes[1, 0].set_title('RK2: |1 + z + z²/2| ≤ 1')
axes[1, 0].set_xlabel('Re(λh)')
axes[1, 0].set_ylabel('Im(λh)')
axes[1, 0].grid(True)
axes[1, 0].set_aspect('equal')
# RK4: R(z) = 1 + z + z²/2 + z³/6 + z⁴/24
R_rk4 = np.abs(1 + Z + Z**2/2 + Z**3/6 + Z**4/24)
axes[1, 1].contour(X, Y, R_rk4, levels=[1], colors='blue')
axes[1, 1].contourf(X, Y, R_rk4, levels=[0, 1], alpha=0.3)
axes[1, 1].set_title('RK4')
axes[1, 1].set_xlabel('Re(λh)')
axes[1, 1].set_ylabel('Im(λh)')
axes[1, 1].grid(True)
axes[1, 1].set_aspect('equal')
plt.tight_layout()
plt.show()
stability_regions()
Practice Problems¶
Problem 1¶
Solve the Van der Pol oscillator using RK4: x'' - μ(1 - x²)x' + x = 0, μ = 2
def exercise_1():
mu = 2.0
def van_der_pol(t, y):
return [y[1], mu * (1 - y[0]**2) * y[1] - y[0]]
sol = solve_ivp(van_der_pol, (0, 30), [0.1, 0],
method='RK45', max_step=0.01)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].plot(sol.t, sol.y[0])
axes[0].set_xlabel('t')
axes[0].set_ylabel('x')
axes[0].set_title('Van der Pol Oscillator')
axes[1].plot(sol.y[0], sol.y[1])
axes[1].set_xlabel('x')
axes[1].set_ylabel("x'")
axes[1].set_title('Phase Space (Limit Cycle)')
plt.tight_layout()
plt.show()
exercise_1()
Summary¶
| Method | Order | Characteristics |
|---|---|---|
| Forward Euler | O(h) | Simple, limited stability |
| Backward Euler | O(h) | Implicit, A-stable |
| RK2 | O(h²) | Midpoint, Heun |
| RK4 | O(h⁴) | Most widely used |
| RK4-5 | O(h⁵) | Adaptive step |
| SciPy Solver | Type | Purpose |
|---|---|---|
| RK45 | Explicit | General problems (default) |
| DOP853 | Explicit | High precision |
| Radau | Implicit | Stiff problems |
| BDF | Implicit | Stiff problems |