Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
Ok-landscape
GitHub Repository: Ok-landscape/computational-pipeline
Path: blob/main/latex-templates/templates/numerical-methods/ode_solver.tex
51 views
unlisted
1
\documentclass[a4paper, 11pt]{report}
2
\usepackage[utf8]{inputenc}
3
\usepackage[T1]{fontenc}
4
\usepackage{amsmath, amssymb}
5
\usepackage{graphicx}
6
\usepackage{siunitx}
7
\usepackage{booktabs}
8
\usepackage{algorithm2e}
9
\usepackage{xcolor}
10
\usepackage[makestderr]{pythontex}
11
12
% Colors for method comparison
13
\definecolor{euler}{RGB}{231, 76, 60}
14
\definecolor{rk4}{RGB}{46, 204, 113}
15
\definecolor{rkf45}{RGB}{52, 152, 219}
16
\definecolor{exact}{RGB}{44, 62, 80}
17
18
\title{Numerical Methods for Ordinary Differential Equations:\\
19
A Comparative Analysis of Integration Schemes}
20
\author{Computational Mathematics Division\\Technical Report CM-2024-003}
21
\date{\today}
22
23
\begin{document}
24
\maketitle
25
26
\begin{abstract}
27
This technical report presents a comprehensive comparison of numerical methods for solving ordinary differential equations. We implement and analyze Forward Euler, fourth-order Runge-Kutta (RK4), and adaptive Runge-Kutta-Fehlberg (RKF45) methods. Performance is evaluated on stiff and non-stiff test problems using accuracy, computational cost, and stability metrics. Results demonstrate the trade-offs between method complexity and solution quality, with RKF45 achieving optimal efficiency for most engineering applications.
28
\end{abstract}
29
30
\tableofcontents
31
32
\chapter{Introduction}
33
34
Ordinary differential equations (ODEs) are fundamental to modeling physical, biological, and engineering systems. While analytical solutions exist for simple cases, most practical problems require numerical integration. This report evaluates three widely-used methods with increasing sophistication.
35
36
\section{Problem Statement}
37
We consider initial value problems of the form:
38
\begin{equation}
39
\frac{dy}{dt} = f(t, y), \quad y(t_0) = y_0
40
\end{equation}
41
42
The challenge is to approximate $y(t)$ at discrete time points with controllable accuracy and computational efficiency.
43
44
\section{Methods Overview}
45
46
\subsection{Forward Euler Method}
47
The simplest explicit method, first-order accurate:
48
\begin{equation}
49
y_{n+1} = y_n + h f(t_n, y_n)
50
\end{equation}
51
\textbf{Stability:} Conditionally stable, $|1 + h\lambda| < 1$ for $\dot{y} = \lambda y$.
52
53
\subsection{Classical Runge-Kutta (RK4)}
54
Fourth-order method using weighted average of slopes:
55
\begin{align}
56
k_1 &= f(t_n, y_n) \\
57
k_2 &= f(t_n + h/2, y_n + hk_1/2) \\
58
k_3 &= f(t_n + h/2, y_n + hk_2/2) \\
59
k_4 &= f(t_n + h, y_n + hk_3) \\
60
y_{n+1} &= y_n + \frac{h}{6}(k_1 + 2k_2 + 2k_3 + k_4)
61
\end{align}
62
63
\subsection{Runge-Kutta-Fehlberg (RKF45)}
64
Adaptive method with embedded error estimation using 4th and 5th order formulas:
65
\begin{equation}
66
\text{Error estimate: } \epsilon = |y_{n+1}^{(5)} - y_{n+1}^{(4)}|
67
\end{equation}
68
69
Step size adapts to maintain $\epsilon < \text{tolerance}$.
70
71
\chapter{Implementation}
72
73
\begin{pycode}
74
import numpy as np
75
import matplotlib.pyplot as plt
76
from time import time
77
plt.rc('text', usetex=True)
78
plt.rc('font', family='serif')
79
80
np.random.seed(42)
81
82
# Define numerical methods
83
def euler(f, y0, t_span, h):
84
"""Forward Euler method."""
85
t = np.arange(t_span[0], t_span[1] + h, h)
86
y = np.zeros((len(t), len(np.atleast_1d(y0))))
87
y[0] = np.atleast_1d(y0)
88
89
for i in range(len(t) - 1):
90
y[i+1] = y[i] + h * np.atleast_1d(f(t[i], y[i]))
91
92
return t, y, len(t) - 1 # Return function evaluations
93
94
def rk4(f, y0, t_span, h):
95
"""Classical 4th-order Runge-Kutta."""
96
t = np.arange(t_span[0], t_span[1] + h, h)
97
y = np.zeros((len(t), len(np.atleast_1d(y0))))
98
y[0] = np.atleast_1d(y0)
99
n_evals = 0
100
101
for i in range(len(t) - 1):
102
k1 = np.atleast_1d(f(t[i], y[i]))
103
k2 = np.atleast_1d(f(t[i] + h/2, y[i] + h*k1/2))
104
k3 = np.atleast_1d(f(t[i] + h/2, y[i] + h*k2/2))
105
k4 = np.atleast_1d(f(t[i] + h, y[i] + h*k3))
106
y[i+1] = y[i] + h/6 * (k1 + 2*k2 + 2*k3 + k4)
107
n_evals += 4
108
109
return t, y, n_evals
110
111
def rkf45(f, y0, t_span, tol=1e-6, h_init=0.1):
112
"""Adaptive Runge-Kutta-Fehlberg 4(5) method."""
113
# Butcher tableau coefficients
114
c = np.array([0, 1/4, 3/8, 12/13, 1, 1/2])
115
a = np.array([
116
[0, 0, 0, 0, 0],
117
[1/4, 0, 0, 0, 0],
118
[3/32, 9/32, 0, 0, 0],
119
[1932/2197, -7200/2197, 7296/2197, 0, 0],
120
[439/216, -8, 3680/513, -845/4104, 0],
121
[-8/27, 2, -3544/2565, 1859/4104, -11/40]
122
])
123
b4 = np.array([25/216, 0, 1408/2565, 2197/4104, -1/5, 0])
124
b5 = np.array([16/135, 0, 6656/12825, 28561/56430, -9/50, 2/55])
125
126
t_list = [t_span[0]]
127
y_list = [np.atleast_1d(y0)]
128
h = h_init
129
t = t_span[0]
130
y = np.atleast_1d(y0)
131
n_evals = 0
132
133
while t < t_span[1]:
134
if t + h > t_span[1]:
135
h = t_span[1] - t
136
137
# Compute stages
138
k = np.zeros((6, len(y)))
139
for i in range(6):
140
yi = y + h * sum(a[i, j] * k[j] for j in range(i))
141
k[i] = np.atleast_1d(f(t + c[i] * h, yi))
142
n_evals += 1
143
144
# 4th and 5th order solutions
145
y4 = y + h * sum(b4[i] * k[i] for i in range(6))
146
y5 = y + h * sum(b5[i] * k[i] for i in range(6))
147
148
# Error estimate
149
error = np.max(np.abs(y5 - y4))
150
151
# Step size control
152
if error < tol or h < 1e-10:
153
t = t + h
154
y = y5
155
t_list.append(t)
156
y_list.append(y.copy())
157
158
# Adjust step size
159
if error > 0:
160
h = 0.9 * h * (tol / error) ** 0.2
161
h = min(h, 0.5) # Cap step size
162
163
return np.array(t_list), np.array(y_list), n_evals
164
165
# Test Problem 1: Exponential decay (non-stiff)
166
def exp_decay(t, y):
167
return -2 * y
168
169
def exp_decay_exact(t, y0=1):
170
return y0 * np.exp(-2 * t)
171
172
# Test Problem 2: Van der Pol oscillator (stiff)
173
def vanderpol(t, y, mu=1):
174
y1, y2 = y
175
dy1 = y2
176
dy2 = mu * (1 - y1**2) * y2 - y1
177
return np.array([dy1, dy2])
178
179
# Test Problem 3: Lorenz system (chaotic)
180
def lorenz(t, y, sigma=10, rho=28, beta=8/3):
181
x, y_l, z = y
182
dx = sigma * (y_l - x)
183
dy = x * (rho - z) - y_l
184
dz = x * y_l - beta * z
185
return np.array([dx, dy, dz])
186
187
# Run comparison on exponential decay
188
t_span = (0, 5)
189
y0 = 1.0
190
step_sizes = [0.5, 0.2, 0.1, 0.05, 0.02]
191
192
# Store results for error analysis
193
errors_euler = []
194
errors_rk4 = []
195
evals_euler = []
196
evals_rk4 = []
197
198
for h in step_sizes:
199
t_e, y_e, n_e = euler(exp_decay, y0, t_span, h)
200
t_r, y_r, n_r = rk4(exp_decay, y0, t_span, h)
201
202
exact_e = exp_decay_exact(t_e, y0)
203
exact_r = exp_decay_exact(t_r, y0)
204
205
errors_euler.append(np.max(np.abs(y_e.flatten() - exact_e)))
206
errors_rk4.append(np.max(np.abs(y_r.flatten() - exact_r)))
207
evals_euler.append(n_e)
208
evals_rk4.append(n_r)
209
210
# RKF45 with adaptive stepping
211
t_rkf, y_rkf, n_rkf = rkf45(exp_decay, y0, t_span, tol=1e-8)
212
exact_rkf = exp_decay_exact(t_rkf, y0)
213
error_rkf = np.max(np.abs(y_rkf.flatten() - exact_rkf))
214
215
# Detailed comparison at h=0.1
216
h_test = 0.1
217
t_euler, y_euler, _ = euler(exp_decay, y0, t_span, h_test)
218
t_rk4, y_rk4, _ = rk4(exp_decay, y0, t_span, h_test)
219
220
# Van der Pol solution
221
t_vdp = np.linspace(0, 20, 2000)
222
from scipy.integrate import solve_ivp
223
sol_vdp = solve_ivp(vanderpol, (0, 20), [2, 0], t_eval=t_vdp, method='RK45')
224
225
# Create comprehensive figure
226
fig = plt.figure(figsize=(12, 10))
227
228
# Plot 1: Solution comparison
229
ax1 = fig.add_subplot(2, 2, 1)
230
t_exact = np.linspace(0, 5, 200)
231
ax1.plot(t_exact, exp_decay_exact(t_exact), 'k-', linewidth=2, label='Exact')
232
ax1.plot(t_euler, y_euler, 'o-', color='#e74c3c', markersize=4, linewidth=1, label=f'Euler ($h={h_test}$)')
233
ax1.plot(t_rk4, y_rk4, 's-', color='#2ecc71', markersize=3, linewidth=1, label=f'RK4 ($h={h_test}$)')
234
ax1.plot(t_rkf, y_rkf, '^', color='#3498db', markersize=4, label=f'RKF45 ({len(t_rkf)} steps)')
235
ax1.set_xlabel('Time $t$')
236
ax1.set_ylabel('$y(t)$')
237
ax1.set_title('Exponential Decay: Method Comparison')
238
ax1.legend(fontsize=8)
239
ax1.grid(True, alpha=0.3)
240
241
# Plot 2: Error convergence
242
ax2 = fig.add_subplot(2, 2, 2)
243
ax2.loglog(step_sizes, errors_euler, 'o-', color='#e74c3c', linewidth=2, label='Euler (slope$\\approx 1$)')
244
ax2.loglog(step_sizes, errors_rk4, 's-', color='#2ecc71', linewidth=2, label='RK4 (slope$\\approx 4$)')
245
ax2.axhline(error_rkf, color='#3498db', linestyle='--', label=f'RKF45 (tol=$10^{{-8}}$)')
246
247
# Reference slopes
248
h_ref = np.array([0.5, 0.02])
249
ax2.loglog(h_ref, 0.5*h_ref, ':', color='gray', alpha=0.5, label='$O(h)$')
250
ax2.loglog(h_ref, 0.005*h_ref**4, ':', color='gray', alpha=0.5, label='$O(h^4)$')
251
252
ax2.set_xlabel('Step size $h$')
253
ax2.set_ylabel('Maximum Error')
254
ax2.set_title('Error Convergence')
255
ax2.legend(fontsize=8)
256
ax2.grid(True, alpha=0.3, which='both')
257
258
# Plot 3: Van der Pol phase portrait
259
ax3 = fig.add_subplot(2, 2, 3)
260
ax3.plot(sol_vdp.y[0], sol_vdp.y[1], color='#9b59b6', linewidth=1)
261
ax3.plot(sol_vdp.y[0, 0], sol_vdp.y[1, 0], 'go', markersize=8, label='Start')
262
ax3.set_xlabel('$y_1$')
263
ax3.set_ylabel('$y_2$')
264
ax3.set_title('Van der Pol Oscillator Phase Portrait')
265
ax3.legend()
266
ax3.grid(True, alpha=0.3)
267
ax3.set_aspect('equal')
268
269
# Plot 4: Efficiency comparison
270
ax4 = fig.add_subplot(2, 2, 4)
271
ax4.loglog(evals_euler, errors_euler, 'o-', color='#e74c3c', linewidth=2, markersize=8, label='Euler')
272
ax4.loglog(evals_rk4, errors_rk4, 's-', color='#2ecc71', linewidth=2, markersize=8, label='RK4')
273
ax4.plot(n_rkf, error_rkf, '^', color='#3498db', markersize=12, label='RKF45')
274
275
ax4.set_xlabel('Function Evaluations')
276
ax4.set_ylabel('Maximum Error')
277
ax4.set_title('Computational Efficiency')
278
ax4.legend(fontsize=8)
279
ax4.grid(True, alpha=0.3, which='both')
280
281
plt.tight_layout()
282
plt.savefig('ode_solver_plot.pdf', bbox_inches='tight', dpi=150)
283
print(r'\begin{center}')
284
print(r'\includegraphics[width=\textwidth]{ode_solver_plot.pdf}')
285
print(r'\end{center}')
286
plt.close()
287
288
# Additional stability analysis
289
# Create stability region plot
290
fig2, axes2 = plt.subplots(1, 2, figsize=(10, 4))
291
292
# Euler stability region
293
theta = np.linspace(0, 2*np.pi, 200)
294
ax_stab1 = axes2[0]
295
ax_stab1.plot(np.cos(theta) - 1, np.sin(theta), 'r-', linewidth=2)
296
ax_stab1.fill(np.cos(theta) - 1, np.sin(theta), alpha=0.3, color='red')
297
ax_stab1.axhline(0, color='k', linewidth=0.5)
298
ax_stab1.axvline(0, color='k', linewidth=0.5)
299
ax_stab1.set_xlabel(r'Re($h\lambda$)')
300
ax_stab1.set_ylabel(r'Im($h\lambda$)')
301
ax_stab1.set_title('Euler Stability Region')
302
ax_stab1.set_xlim([-3, 1])
303
ax_stab1.set_ylim([-2, 2])
304
ax_stab1.grid(True, alpha=0.3)
305
ax_stab1.set_aspect('equal')
306
307
# RK4 stability region (approximate)
308
x = np.linspace(-3, 1, 200)
309
y = np.linspace(-3, 3, 200)
310
X, Y = np.meshgrid(x, y)
311
Z = X + 1j*Y
312
R = 1 + Z + Z**2/2 + Z**3/6 + Z**4/24
313
ax_stab2 = axes2[1]
314
ax_stab2.contour(X, Y, np.abs(R), [1], colors='g', linewidths=2)
315
ax_stab2.contourf(X, Y, np.abs(R), levels=[0, 1], colors=['green'], alpha=0.3)
316
ax_stab2.axhline(0, color='k', linewidth=0.5)
317
ax_stab2.axvline(0, color='k', linewidth=0.5)
318
ax_stab2.set_xlabel(r'Re($h\lambda$)')
319
ax_stab2.set_ylabel(r'Im($h\lambda$)')
320
ax_stab2.set_title('RK4 Stability Region')
321
ax_stab2.set_xlim([-3, 1])
322
ax_stab2.set_ylim([-3, 3])
323
ax_stab2.grid(True, alpha=0.3)
324
ax_stab2.set_aspect('equal')
325
326
plt.tight_layout()
327
plt.savefig('stability_regions.pdf', bbox_inches='tight')
328
print(r'\begin{center}')
329
print(r'\includegraphics[width=0.9\textwidth]{stability_regions.pdf}')
330
print(r'\end{center}')
331
plt.close()
332
333
# Store key results
334
best_euler_error = errors_euler[-1]
335
best_rk4_error = errors_rk4[-1]
336
euler_order = np.log(errors_euler[0]/errors_euler[-1]) / np.log(step_sizes[0]/step_sizes[-1])
337
rk4_order = np.log(errors_rk4[0]/errors_rk4[-1]) / np.log(step_sizes[0]/step_sizes[-1])
338
\end{pycode}
339
340
\chapter{Results and Analysis}
341
342
\section{Accuracy Comparison}
343
344
\begin{pycode}
345
# Generate results table
346
print(r'\begin{table}[h]')
347
print(r'\centering')
348
print(r'\caption{Error Analysis for Exponential Decay Problem}')
349
print(r'\begin{tabular}{lcccc}')
350
print(r'\toprule')
351
print(r'Step Size & Euler Error & RK4 Error & Euler Evals & RK4 Evals \\')
352
print(r'\midrule')
353
for h, e_e, e_r, n_e, n_r in zip(step_sizes, errors_euler, errors_rk4, evals_euler, evals_rk4):
354
print(f'{h:.2f} & {e_e:.2e} & {e_r:.2e} & {n_e} & {n_r} \\\\')
355
print(r'\midrule')
356
print(f'RKF45 (adaptive) & \\multicolumn{{2}}{{c}}{{{error_rkf:.2e}}} & \\multicolumn{{2}}{{c}}{{{n_rkf}}} \\\\')
357
print(r'\bottomrule')
358
print(r'\end{tabular}')
359
print(r'\end{table}')
360
\end{pycode}
361
362
\subsection{Order of Convergence}
363
The empirical convergence rates confirm theoretical predictions:
364
\begin{itemize}
365
\item \textbf{Forward Euler}: Order $\approx$ \py{f"{euler_order:.2f}"} (theoretical: 1)
366
\item \textbf{RK4}: Order $\approx$ \py{f"{rk4_order:.2f}"} (theoretical: 4)
367
\end{itemize}
368
369
\section{Computational Efficiency}
370
The efficiency plot reveals that:
371
\begin{enumerate}
372
\item RK4 requires 4x more evaluations per step than Euler
373
\item For the same accuracy, RK4 is significantly more efficient
374
\item RKF45 achieves the best accuracy-to-cost ratio through adaptive stepping
375
\end{enumerate}
376
377
\section{Stability Analysis}
378
The stability regions show:
379
\begin{itemize}
380
\item \textbf{Euler}: Small circular region (radius 1 centered at $-1$)
381
\item \textbf{RK4}: Much larger region extending along the imaginary axis
382
\end{itemize}
383
384
This explains why Euler requires very small step sizes for oscillatory problems, while RK4 remains stable with larger steps.
385
386
\chapter{Method Selection Guidelines}
387
388
\section{Recommendations by Problem Type}
389
390
\begin{pycode}
391
print(r'\begin{table}[h]')
392
print(r'\centering')
393
print(r'\caption{Method Selection Guidelines}')
394
print(r'\begin{tabular}{lp{8cm}}')
395
print(r'\toprule')
396
print(r'Problem Type & Recommended Method \\')
397
print(r'\midrule')
398
print(r'Simple, smooth ODEs & RK4 with fixed step \\')
399
print(r'Problems requiring error control & RKF45 or similar adaptive method \\')
400
print(r'Stiff systems & Implicit methods (BDF, Radau) \\')
401
print(r'Long-time integration & Symplectic methods for Hamiltonian systems \\')
402
print(r'Real-time simulation & Euler or RK2 (when speed critical) \\')
403
print(r'\bottomrule')
404
print(r'\end{tabular}')
405
print(r'\end{table}')
406
\end{pycode}
407
408
\section{Key Performance Metrics}
409
410
For the test problem $y' = -2y$:
411
\begin{itemize}
412
\item Best Euler accuracy (h=0.02): \py{f"{best_euler_error:.2e}"}
413
\item Best RK4 accuracy (h=0.02): \py{f"{best_rk4_error:.2e}"}
414
\item RKF45 accuracy (tol=$10^{-8}$): \py{f"{error_rkf:.2e}"} with \py{n_rkf} evaluations
415
\end{itemize}
416
417
\chapter{Conclusions}
418
419
\section{Summary}
420
This report compared three numerical methods for ODE integration:
421
422
\begin{enumerate}
423
\item \textbf{Forward Euler} is simple but has first-order accuracy and limited stability. Suitable only for quick estimates or when computational resources are extremely limited.
424
425
\item \textbf{Classical RK4} provides an excellent balance of accuracy (4th order) and implementation simplicity. Recommended for most smooth, non-stiff problems with known smoothness.
426
427
\item \textbf{RKF45} (adaptive) automatically adjusts step size to meet error tolerances. Optimal for problems where the solution behavior varies across the domain or when guaranteed accuracy is required.
428
\end{enumerate}
429
430
\section{Future Work}
431
Extensions to consider:
432
\begin{itemize}
433
\item Implement implicit methods for stiff problems
434
\item Add dense output for interpolation between steps
435
\item Parallelize for systems of ODEs
436
\item Investigate symplectic integrators for Hamiltonian systems
437
\end{itemize}
438
439
\appendix
440
\chapter{Algorithm Pseudocode}
441
442
\begin{algorithm}[H]
443
\SetAlgoLined
444
\KwIn{$f(t,y)$, $y_0$, $[t_0, t_f]$, tolerance $\tau$}
445
\KwOut{Solution arrays $t[], y[]$}
446
$h \leftarrow h_{\text{initial}}$\;
447
$t \leftarrow t_0$, $y \leftarrow y_0$\;
448
\While{$t < t_f$}{
449
Compute $k_1, \ldots, k_6$ (RKF45 stages)\;
450
$y_4 \leftarrow y + h \sum b_i^{(4)} k_i$\;
451
$y_5 \leftarrow y + h \sum b_i^{(5)} k_i$\;
452
$\epsilon \leftarrow |y_5 - y_4|$\;
453
\eIf{$\epsilon < \tau$}{
454
Accept step: $t \leftarrow t + h$, $y \leftarrow y_5$\;
455
}{
456
Reject step\;
457
}
458
Update: $h \leftarrow 0.9 h (\tau/\epsilon)^{1/5}$\;
459
}
460
\caption{Adaptive Runge-Kutta-Fehlberg}
461
\end{algorithm}
462
463
\end{document}
464
465