FIZ353 - Numerical Analysis | 31/12/2020
Emre S. Tasci emre.tasci@hacettepe.edu.tr
At the end of our lecture on Interpolation, as a bonus, we talked about the finite difference method and employed it to solve an ordinary differential equation with respect to the given boundary conditions.
Let's expand $f(x_i)$ to reach $f(x_{i+1})$ with $\Delta x \equiv x_{i+1} - x$:
$$f(x_{i+1}) = f(x_i)+\frac {f'(x_i)}{1!} (\Delta x)+ \frac{f''(x_i)}{2!} (\Delta x)^2+\frac{f'''(x_i)}{3!}(\Delta x)^3+ \cdots\quad(1)$$re-arranging, we get:
$$f'(x_i) = \frac{f(x_{i+1})-f(x_{i})}{\Delta x} -\frac{f''(x_i)}{2!} \Delta x-\frac{f'''(x_i)}{3!}(\Delta x)^2+ \cdots$$and if we expand $f(x_i)$ to reach $f(x_{i-1})$, this time, assuming we are maintaining the same distance length between consecutive 'steps', the difference $x_{i-1} - x_{i}$ will be $-\Delta x$
If this is not obvious, consider $x{i+1} = 6$, $xi = 5$ and $x{i-1}=4$ points: then $x_{i+1} - xi = 6-5=1=\Delta x$ and $x{i-1} - xi =4 - 5=-1=-\Delta x$.
so: $$f(x_{i-1}) = f(x_i)-\frac {f'(x_i)}{1!} (\Delta x)+ \frac{f''(x_i)}{2!} (\Delta x)^2-\frac{f'''(x_i)}{3!}(\Delta x)^3+ \cdots\quad(2)$$
Subtracting the second from the first and then isolating $f'(x_i)$ yields:
$$f'(x_i)= \frac{f(x_{i+1})-f(x_{i-1})}{2\Delta x}-\frac{f'''(x_{i})}{6}(\Delta x)^2+\cdots\quad(3)$$Let's ponder on the order of the errors we suffer when we only take the first terms and ignore the rest, with respect to the three approaches above (namely, the "forward finite difference", the "backward finite difference" and the "centered finite difference" approaches -- for details, the reader is once again reffered to the bonus section of the interpolation lecture)
Take $f(x) = \sin(x)$ as our function. Let's say we want to calculate it's derivative at $x_i=\tfrac{\pi}{3}$ and $\Delta x = 0.5$
Forward Finite Difference Approximation From the (1) equation, taking upto the 1st term yields: $$f_f'(x_i) =\frac{f(x_{i+1})-f(x_{i})}{\Delta x}\underbrace{ -\frac{f''(x_i)}{2!} \Delta x-\frac{f'''(x_i)}{3!}(\Delta x)^2+ \cdots}_{\text{ignored part}} = \frac{f(x_{i+1}) - f(x_i)}{\Delta x} + O(\Delta x)$$
$$\Rightarrow\boxed{f_f'(x_i) \approx \frac{f(x_{i+1}) - f(x_i)}{\Delta x}}$$Thus:
$$f_f'(\tfrac{\pi}{3}) \approx \frac{f(\tfrac{\pi}{3}+0.5)-f(0)}{0.5}$$import numpy as np
x = np.pi/3
delta_x = 0.5
true_value = np.cos(x)
ffd = (np.sin(x+delta_x) - np.sin(x))/delta_x
print("True Value (cos(x)): {:10.7f}".format(true_value))
print("Forward finite difference f'(0): {:10.7f}".format(ffd))
print("Absolute Error: {:10.7f}".format(np.abs(true_value - ffd)))
True Value (cos(x)): 0.5000000 Forward finite difference f'(0): 0.2673923 Absolute Error: 0.2326077
It doesn't look very promising. Let's see how the error value changes as we shorten our $\Delta x$ and while we're doing it, let's also calculate the first term approximations for the backward finite difference and the centered finite difference methods as well:
Backward Finite Difference Approximation From the (2) equation, taking upto the 1st term yields:
$$\boxed{f_b'(x_i) \approx \frac{f(x_{i}) - f(x_{i-1})}{\Delta x}}$$Centered Finite Difference Approximation Directly from the (3) equation, taking upto the 1st term we have:
$$\boxed{f_c'(x_i)\approx \frac{f(x_{i+1})-f(x_{i-1})}{2\Delta x}}$$import pandas as pd # Not necessary but since we can, why not? ;)
data = pd.DataFrame({'Delta_x':[],'forward fd':[],'ffd Err':[],
'backward fd':[],'bfd Err':[],
'centered fd':[],'cfd Err':[]})
x = np.pi / 3
delta_x = 2
true_value = np.cos(x)
for i in range(17):
delta_x = delta_x / 2
ffd = (np.sin(x+delta_x) - np.sin(x))/delta_x
ffd_err = np.abs(true_value - ffd)
bfd = (np.sin(x) - np.sin(x-delta_x))/delta_x
bfd_err = np.abs(true_value - bfd)
cfd = (np.sin(x+delta_x) - np.sin(x-delta_x))/(2*delta_x)
cfd_err = np.abs(true_value - cfd)
data = data.append({'Delta_x':delta_x,
'forward fd':ffd,'ffd Err':ffd_err,
'backward fd':bfd,'bfd Err':bfd_err,
'centered fd':cfd,'cfd Err':cfd_err}
,ignore_index=True)
data
| Delta_x | forward fd | ffd Err | backward fd | bfd Err | centered fd | cfd Err | |
|---|---|---|---|---|---|---|---|
| 0 | 1.000000 | 0.022626 | 0.477374 | 0.818845 | 0.318845 | 0.420735 | 7.926451e-02 |
| 1 | 0.500000 | 0.267392 | 0.232608 | 0.691459 | 0.191459 | 0.479426 | 2.057446e-02 |
| 2 | 0.250000 | 0.387117 | 0.112883 | 0.602498 | 0.102498 | 0.494808 | 5.192081e-03 |
| 3 | 0.125000 | 0.444643 | 0.055357 | 0.552755 | 0.052755 | 0.498699 | 1.301066e-03 |
| 4 | 0.062500 | 0.472620 | 0.027380 | 0.526729 | 0.026729 | 0.499675 | 3.254573e-04 |
| 5 | 0.031250 | 0.486388 | 0.013612 | 0.513449 | 0.013449 | 0.499919 | 8.137623e-05 |
| 6 | 0.015625 | 0.493214 | 0.006786 | 0.506745 | 0.006745 | 0.499980 | 2.034480e-05 |
| 7 | 0.007812 | 0.496612 | 0.003388 | 0.503378 | 0.003378 | 0.499995 | 5.086247e-06 |
| 8 | 0.003906 | 0.498307 | 0.001693 | 0.501690 | 0.001690 | 0.499999 | 1.271565e-06 |
| 9 | 0.001953 | 0.499154 | 0.000846 | 0.500845 | 0.000845 | 0.500000 | 3.178914e-07 |
| 10 | 0.000977 | 0.499577 | 0.000423 | 0.500423 | 0.000423 | 0.500000 | 7.947284e-08 |
| 11 | 0.000488 | 0.499789 | 0.000211 | 0.500211 | 0.000211 | 0.500000 | 1.986825e-08 |
| 12 | 0.000244 | 0.499894 | 0.000106 | 0.500106 | 0.000106 | 0.500000 | 4.967205e-09 |
| 13 | 0.000122 | 0.499947 | 0.000053 | 0.500053 | 0.000053 | 0.500000 | 1.241460e-09 |
| 14 | 0.000061 | 0.499974 | 0.000026 | 0.500026 | 0.000026 | 0.500000 | 3.101378e-10 |
| 15 | 0.000031 | 0.499987 | 0.000013 | 0.500013 | 0.000013 | 0.500000 | 7.639767e-11 |
| 16 | 0.000015 | 0.499993 | 0.000007 | 0.500007 | 0.000007 | 0.500000 | 1.819001e-11 |
lines=data.plot.line(y=['ffd Err','bfd Err','cfd Err'])
It is obvious that the error of the centered finite difference approximation is much less compared to the forward & backward finite difference approximations. In order to see why is that, consider the higher terms (2nd and afterwards) that we are ignoring in our calculations: For the forward & backward methods, they start from the order of $\Delta x$, however for the centered method, the ignored part is in the order of $\Delta x$.
Once again, in our quick introduction to the topic last lecture, we had gone around and derived in a fast-forward way an identity/approximation for the 2nd order derivative using logic and the already found 1st order derivatives as:
$$\frac{\text{d}^2 f(x)}{\text{d}x^2}\biggr\vert_{x_i} \approx \frac{\frac{f(x_{i+1})-f(x_{i})}{h}-\frac{f(x_{i})-f(x_{i-1})}{h}}{h}=\frac{f(x_{i+1})-2f(x_{i})+f(x_{i-1})}{h^2}$$Now, let's redrive it more gracefully. First remember our equations for the Taylor expansions of $f(x_{i+1})$ and $ f(x_{i-1})$:
$$f(x_{i+1}) = f(x_i)+\frac {f'(x_i)}{1!} (\Delta x)+ \frac{f''(x_i)}{2!} (\Delta x)^2+\frac{f'''(x_i)}{3!}(\Delta x)^3+ \cdots\quad(1)$$$$f(x_{i-1}) = f(x_i)-\frac {f'(x_i)}{1!} (\Delta x)+ \frac{f''(x_i)}{2!} (\Delta x)^2-\frac{f'''(x_i)}{3!}(\Delta x)^3+ \cdots\quad(2)$$Then we ignore the 3rd and higher derivates and isolate the 2nd derivatives to the left hand side:
Adding them side by side will yield:
Question: Can you figure out the order of error of this above approximation? ;)
Comment: Boy, these derivations eerily look like the workings of the Newton polynomials (of the interpolation lecture), innit? ;;;)
An ordinary differential equation (ODE) is an equation that includes (usually linear) combinations of a function's derivatives with respect to its parameter. The "ordinary" indicates that, the function depends only on one parameter (as opposed to partial derivatives).
$$a_0(x)y +a_1(x)y' + a_2(x)y'' +\cdots +a_n(x)y^{(n)}+b(x)=0$$Here's an equation you might be familiar with, i.e., the mass-spring system! From Hooke's Law, we have:
$$ F = -kx$$and Hooke's nemesis, Sir Isaac Newton tells us that:
$$ F = m \ddot{x}$$and therefore, combining these two by eliminating F, we get:
$$m\ddot x = -k x\rightarrow \ddot x(t) = -\frac{k}{m} x(t)$$(I've explicitly indicated the time dependence of position)
and giving $\tfrac{k}{m}$ a fancy name like "$\omega^2$", we have:
$$\boxed {\ddot x(t) = -\omega^2 x(t)}\quad\left(\omega\equiv\sqrt{\frac{k}{m}}\right)$$As opposed to the classic opening to the analitic solution that is "let's assume $x(t) =x_0 \cos{(\omega t + \varphi)}$, we will actually solve it without let's assuming anything:
We are given these data:
$$ k=1\text{ N/m}\\ m = 1\text{ kg}\\ x_0 = 10\text{ cm} = 0.1\text{ m}\\ v_0 = 0$$So:
$$\omega = \sqrt{\frac{k}{m}} = 1\text{ s}^{-1}\\ x(t=0) = 0.1\text{ m}\\ \dot x(t=0) = 0$$We write our approximation for the 2nd derivative:
$$f''(x_i) \approx\frac{f(x_{i+1}) - 2f(x_i) + f(x_{i-1})}{(\Delta x)^2}$$In our problem, this translates to (I'm now switching to equality sign even though it is approximation for practicality): $$\ddot x_0 = \frac{x_1 - 2x_0 + x_{-1}}{(\Delta t)^2}$$
The $0$ indice denotes the value at $t=0$, $1$ indice the value at $t=\Delta t$ and $-1$ denotes the value at $t=-\Delta t$.
Plugging this into the $\ddot x(t) = -\omega^2 x(t)$ equation yields:
$$\frac{x_1 - 2x_0 + x_{-1}}{(\Delta t)^2} = -\omega^2 x_0$$Looking at this equation, we realize that, if we knew the positions $x_0$ and $x_{-1}$ we could recover $x_1$ and increase the indice & re-iterate it as:
$$\frac{x_2 - 2x_1 + x_{0}}{(\Delta t)^2} = -\omega^2 x_1$$and it would continue on and on...
Let's focus on our initial equation once again:
$$\frac{x_1 - 2x_0 + x_{-1}}{(\Delta t)^2} = -\omega^2 x_0$$We know $x_0$ but not $x_{-1}$. Can't we use our heads?...
Let's put our physics knowledge in front of math: We have pulled the spring up to $x_0 = 0.1\text{ m}$ and then let it go. From physics we know that, this is the maximum displacement our spring-mass will achieve. We can also deduce the fact that, the movement will be symmetrical with respect to the maximum point, e.g., the particle will be at the same position for $t=-\Delta t$ and $t=\Delta t$, meaning that $x_1 = x_{-1}$. Substituting $x_1$ as $x_{-1}$, our equation becomes:
Re-arranging yields:
$$x_1 = 2x_0 - \omega^2 {\Delta t}^2 x_0 = \left(1-\frac{\omega^2 {\Delta t}^2}{2}\right)x_0$$But don't forget that, this last equation we derived is only correct for $x_{-1}$ and $x_1$ -- it is not a general relation as:
$$\frac{x_{i+1} - 2x_i + x_{i-1}}{(\Delta t)^2} = -\omega^2 x_i$$which corresponds to:
$$ x_{i+1} = \underbrace{\left(2 -\omega^2{\Delta t}^2\right)}_{\zeta} x_i - x_{i-1}$$We identified $\left(2 -\omega^2{\Delta t}^2\right)$ as $\zeta$ because it doesn't change its value and it would be a bad example to recalculate it at every iteration, so:
$$ x_{i+1} =\zeta x_i - x_{i-1}$$Now that we have the get going, let's give it a try!
import numpy as np
import pandas as pd
motion = pd.DataFrame({'i':[],'t':[],'x':[]})
motion = motion.set_index('i')
# Given data:
motion.loc[0,'x'] = 0.1
motion.loc[0,'t'] = 0
k = 1 # N/m
m = 1 # kg
# Assigned value:
Delta_t = 1E-2
# Derived quantities:
omega = np.sqrt(k/m) # s^{-1}
zeta = 2 - omega**2*Delta_t**2
motion.loc[1,'x'] = zeta/2 * motion.loc[0,'x']
motion.loc[1,'t'] = Delta_t
motion
t = Delta_t
t_f = 20
i = 1
while t<t_f:
motion.loc[i+1,'x'] = zeta * motion.loc[i,'x'] \
- motion.loc[i-1,'x']
t = i*Delta_t
motion.loc[i+1,'t'] = t
i = i + 1
motion = motion.reset_index(drop=True)
motion
| t | x | |
|---|---|---|
| 0 | 0.00 | 0.100000 |
| 1 | 0.01 | 0.099995 |
| 2 | 0.01 | 0.099980 |
| 3 | 0.02 | 0.099955 |
| 4 | 0.03 | 0.099920 |
| ... | ... | ... |
| 1997 | 19.96 | 0.043521 |
| 1998 | 19.97 | 0.042618 |
| 1999 | 19.98 | 0.041712 |
| 2000 | 19.99 | 0.040801 |
| 2001 | 20.00 | 0.039886 |
2002 rows × 2 columns
Well, we got something to ourselves, so let's see if this is indeed what we wanted by having plotting it:
import matplotlib.pyplot as plt
plt.plot(motion.loc[:,'t'],motion.loc[:,'x'],"*-b")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
We can see the period being something around 6 seconds, let's also check it out by first creating a filter that will be true if the position of that row is equal to the position of the first row (i.e., 0.1) and then, apply this to our calculated data:
filter_0 = motion.loc[:,'x'] == motion.loc[0,'x']
motion.loc[filter_0]
| t | x | |
|---|---|---|
| 0 | 0.0 | 0.1 |
Only the first entry (being equal to itself -- duh!)??? Why can it be? Oh, right, that's because these are NUMERICAL VALUES OBTAINED FROM NUMERICAL VALUES! So there's a very low probability that we will have a definite "0.1" but they will rather be something like "0.100000000000001" or "0.099999999999999998" - always keep this in mind when filtering!
So instead of seeking those entries with positions exactly equal to that of the first row's position, we go for very close values -- it would be a miracle if our designated $\Delta t$ would exactly step on the period of the oscillation! Therefore we will take this as an "equality":
$$|x_i - x_0| < 10^{-6} \rightarrow x_i \approx x_0$$and here is how we write it in code:
filter_1 = np.abs(motion.loc[:,'x'] - motion.loc[0,'x']) < 1E-6
motion.loc[filter_1]
| t | x | |
|---|---|---|
| 0 | 0.00 | 0.100000 |
| 628 | 6.27 | 0.100000 |
| 1257 | 12.56 | 0.099999 |
| 1885 | 18.84 | 0.100000 |
The period seems to be 6.27 seconds. Let's calculate it using the relation between the angular frequency $\omega$ and the period:
$$ T = \frac{2\pi}{\omega}$$T = 2*np.pi/omega; #s
print(T)
6.283185307179586
Question: We could actually do much better than taking the first period we see... what was it that we could do?...
Hint:
print(12.56/2) => 6.28print(18.84/3) => 6.28When solving this case, we actually kind of 'cheated' somewhere at the start (do you see where it was?)...
Yes, it was the physics thing ($x1 = x{-1}) - we used our physics head to get over the difficult part. Could we have done it without resorting to physical interpretation, just treating it as a math problem? Yes we could. First the implementation, then the explanation:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
motion2 = pd.DataFrame({'i':[],'t':[],'x':[]})
motion2 = motion2.set_index('i')
# Given data:
motion2.loc[0,'x'] = 0.1
motion2.loc[0,'t'] = 0
k = 1 # N/m
m = 1 # kg
# Assigned value:
Delta_t = 1E-2
# Derived quantities:
omega = np.sqrt(k/m) # s^{-1}
zeta = 2 - omega**2*Delta_t**2
motion2.loc[1,'x'] = motion2.loc[0,'x']
motion2.loc[1,'t'] = Delta_t
motion2
t = Delta_t
t_f = 20
i = 1
while t<t_f:
motion2.loc[i+1,'x'] = zeta * motion2.loc[i,'x'] \
- motion2.loc[i-1,'x']
t = i*Delta_t
motion2.loc[i+1,'t'] = t
i = i + 1
motion2 = motion2.reset_index(drop=True)
plt.plot(motion2.loc[:,'t'],motion2.loc[:,'x'],"*-r")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
motion2
| t | x | |
|---|---|---|
| 0 | 0.00 | 0.100000 |
| 1 | 0.01 | 0.100000 |
| 2 | 0.01 | 0.099990 |
| 3 | 0.02 | 0.099970 |
| 4 | 0.03 | 0.099940 |
| ... | ... | ... |
| 1997 | 19.96 | 0.043971 |
| 1998 | 19.97 | 0.043071 |
| 1999 | 19.98 | 0.042166 |
| 2000 | 19.99 | 0.041257 |
| 2001 | 20.00 | 0.040344 |
2002 rows × 2 columns
# And here's the comparison of the two calculated results:
plt.plot(motion.loc[:,'t'],motion.loc[:,'x'],"-b")
plt.plot(motion2.loc[:,'t'],motion2.loc[:,'x'],"-r")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
Actually, for the sake of the discussion, let's focus on the 1st period:
# And here's the comparison of the two calculated results:
filter_2 = motion.loc[:,'t']<T
plt.plot(motion.loc[filter_2,'t'],motion.loc[filter_2,'x'],"-b")
plt.plot(motion2.loc[filter_2,'t'],motion2.loc[filter_2,'x'],"-r")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
Now we can talk about what we did: in the second approach, we just assigned the same value of $x_0$ to $x_1$ and moved on from there. Does this make sense? As long as our incrementation $\Delta t$ is small enough.
Let's follow the same approach but this time taking $\Delta t = 0.1$:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
motion3 = pd.DataFrame({'i':[],'t':[],'x':[]})
motion3 = motion3.set_index('i')
# Given data:
motion3.loc[0,'x'] = 0.1
motion3.loc[0,'t'] = 0
k = 1 # N/m
m = 1 # kg
# Assigned value:
Delta_t = 1E-1
# Derived quantities:
omega = np.sqrt(k/m) # s^{-1}
zeta = 2 - omega**2*Delta_t**2
motion3.loc[1,'x'] = motion3.loc[0,'x']
motion3.loc[1,'t'] = Delta_t
motion3
t = Delta_t
t_f = 20
i = 1
while t<t_f:
motion3.loc[i+1,'x'] = zeta * motion3.loc[i,'x'] \
- motion3.loc[i-1,'x']
t = i*Delta_t
motion3.loc[i+1,'t'] = t
i = i + 1
motion3 = motion3.reset_index(drop=True)
plt.plot(motion3.loc[:,'t'],motion3.loc[:,'x'],"*-r")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
motion3
| t | x | |
|---|---|---|
| 0 | 0.0 | 0.100000 |
| 1 | 0.1 | 0.100000 |
| 2 | 0.1 | 0.099000 |
| 3 | 0.2 | 0.097010 |
| 4 | 0.3 | 0.094050 |
| ... | ... | ... |
| 197 | 19.6 | 0.069135 |
| 198 | 19.7 | 0.061556 |
| 199 | 19.8 | 0.053361 |
| 200 | 19.9 | 0.044632 |
| 201 | 20.0 | 0.035458 |
202 rows × 2 columns
# And here's the comparison of the two calculated results:
filter_2 = motion.loc[:,'t']<T
filter_3 = motion3.loc[:,'t']<T
plt.plot(motion.loc[filter_2,'t'],motion.loc[filter_2,'x'],"-b")
plt.plot(motion3.loc[filter_3,'t'],motion3.loc[filter_3,'x'],"-r")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
Now we start to spot the difference. To bluntly put it:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
motion3 = pd.DataFrame({'i':[],'t':[],'x':[]})
motion3 = motion3.set_index('i')
# Given data:
motion3.loc[0,'x'] = 0.1
motion3.loc[0,'t'] = 0
k = 1 # N/m
m = 1 # kg
# Assigned value:
Delta_t = 0.5
# Derived quantities:
omega = np.sqrt(k/m) # s^{-1}
zeta = 2 - omega**2*Delta_t**2
motion3.loc[1,'x'] = motion3.loc[0,'x']
motion3.loc[1,'t'] = Delta_t
motion3
t = Delta_t
t_f = 20
i = 1
while t<t_f:
motion3.loc[i+1,'x'] = zeta * motion3.loc[i,'x'] \
- motion3.loc[i-1,'x']
t = i*Delta_t
motion3.loc[i+1,'t'] = t
i = i + 1
motion3 = motion3.reset_index(drop=True)
plt.plot(motion3.loc[:,'t'],motion3.loc[:,'x'],"*-r")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
# And here's the comparison of the two calculated results:
filter_2 = motion.loc[:,'t']<T
filter_3 = motion3.loc[:,'t']<T
plt.plot(motion.loc[filter_2,'t'],motion.loc[filter_2,'x'],"-b")
plt.plot(motion3.loc[filter_3,'t'],motion3.loc[filter_3,'x'],"-r")
plt.xlabel("Time (s)")
plt.ylabel("Position (m)")
plt.title("x vs t")
plt.show()
In summary, when we take $\Delta t$ sufficiently small, under a reasonable force(/acceleration) the system will not differ much from its state in the previous iteration. This is exactly what we are exploiting by using small differences. Its downside is the computational cost: the smaller the differences are, the greater the number of calculation points and hence the longer the calculation time. There are of course a couple of optimization techniques such as updating the incrementation size with respect to the slope of the function at the current point, etc.
Now that we have wildly entered into the scene, it's time for more systematical approaches
This method is actually the proper treatment to what we have done so far, only with a limitation to 1st order derivatives(*) and with the initial value given: We approximate the derivatives with slopes, such that:
$$\text{Given: }y'(x) = f(x,y(x)),\;y(x_0) = y_0\\ \Rightarrow y_{n+1} = y_n + \Delta x f(x_n,y_n)$$where $f(x_n,y_n)$ is called as the incrementation function $\varphi$.
(*) Even though Euler's method works for 1st order derivatives, any ODE of order N can be re-written as a system of 1st order ODE equations.
Use Euler's method to solve
$$y' = 4e^{0.8x} - 0.5y$$from $x=0$ to $x=4$ with a step size of $\Delta x = 1$
with the given initial condition: $y(x=0)=2$.
Compare your results with the analytical solution:
$$y(x) = e^{-0.5x}\left(3.07692e^{1.3x}-1.07692\right)$$Solution $$y(1) = y(0) + 1.f(x_0=0,y_0=2)\\ f(0,2) = 4e^0-0.5\times2=3\\ \rightarrow y(1) = 2 +1\times3=5$$
$$y(2) = y(1) + 1.f(x_1=1,y_1=5)\\ f(1,5) = 4e^{0.8\times 1}-0.5\times5=6.40216\\ \rightarrow y(2) = 5 +1\times6.40216=11.40216\\ \vdots$$import numpy as np
def f(x,y):
return 4*np.exp(0.8*x)-0.5*y
def s(x):
# Analytical solution
return np.exp(-0.5*x)*(3.07692*np.exp(1.3*x) - 1.07692)
xy = np.array([0,2,s(0)]).reshape(1,3)
delta_x = 1
i = 0
x = i*delta_x
while x<4:
y_1 = xy[i,1] + delta_x * f(x,xy[i,1])
x = x + delta_x
xy = np.vstack((xy,[x,y_1,s(x)]))
i = i + 1
err = np.abs((xy[:,1] - xy[:,2])/xy[:,2]*100)
np.set_printoptions(precision=5,suppress=True)
print(np.hstack((xy,err.reshape(len(err),1))))
Euler_dx1 = np.copy(xy)
[[ 0. 2. 2. 0. ] [ 1. 5. 6.19463 19.28488] [ 2. 11.40216 14.84391 23.18624] [ 3. 25.51321 33.67714 24.24175] [ 4. 56.84931 75.33889 24.54188]]
import matplotlib.pyplot as plt
xx = np.linspace(0,4,50)
yy = s(xx)
plt.plot(xy[:,0],xy[:,1],"b*-")
plt.plot(xx,yy,"r-")
plt.legend(["Euler Approx.","True Values"])
plt.show()
Let's decrease the step size:
import numpy as np
def f(x,y):
return 4*np.exp(0.8*x)-0.5*y
def s(x):
# Analytical solution
return np.exp(-0.5*x)*(3.07692*np.exp(1.3*x) - 1.07692)
xy = np.array([0,2,s(0)]).reshape(1,3)
delta_x = 1E-1
i = 0
x = i*delta_x
while x<4:
y_1 = xy[i,1] + delta_x * f(x,xy[i,1])
x = x + delta_x
xy = np.vstack((xy,[x,y_1,s(x)]))
i = i + 1
err = np.abs((xy[:,1] - xy[:,2])/xy[:,2]*100)
np.set_printoptions(precision=5,suppress=True)
print(np.hstack((xy,err.reshape(len(err),1))))
Euler_dx0p1 = np.copy(xy)
import matplotlib.pyplot as plt
xx = np.linspace(0,4,50)
yy = s(xx)
plt.plot(xy[:,0],xy[:,1],"b*-")
plt.plot(xx,yy,"r-")
plt.legend(["Euler Approx.","True Values"])
plt.show()
[[ 0. 2. 2. 0. ] [ 0.1 2.3 2.30879 0.3807 ] [ 0.2 2.61831 2.63636 0.68453] [ 0.3 2.9568 2.98462 0.93194] [ 0.4 3.31746 3.3556 1.13665] [ 0.5 3.70244 3.75152 1.30822] [ 0.6 4.11405 4.17473 1.45353] [ 0.7 4.55478 4.62779 1.57765] [ 0.8 5.02731 5.11344 1.68444] [ 0.9 5.53453 5.63465 1.77684] [ 1. 6.07958 6.19463 1.8572 ] [ 1.1 6.66582 6.79682 1.92736] [ 1.2 7.29689 7.44495 1.98884] [ 1.3 7.97672 8.14307 2.04287] [ 1.4 8.70957 8.89553 2.09046] [ 1.5 9.50003 9.70703 2.13246] [ 1.6 10.35308 10.58268 2.1696 ] [ 1.7 11.27408 11.52798 2.20248] [ 1.8 12.26885 12.5489 2.23164] [ 1.9 13.34369 13.65188 2.2575 ] [ 2. 14.5054 14.84391 2.28048] [ 2.1 15.76134 16.13253 2.30089] [ 2.2 17.11949 17.52593 2.31905] [ 2.3 18.58849 19.03295 2.3352 ] [ 2.4 20.17769 20.66318 2.34957] [ 2.5 21.89718 22.42699 2.36236] [ 2.6 23.75795 24.33562 2.37376] [ 2.7 25.77184 26.40122 2.3839 ] [ 2.8 27.9517 28.63696 2.39293] [ 2.9 30.31145 31.05712 2.40098] [ 3. 32.86615 33.67714 2.40814] [ 3.1 35.63211 36.51374 2.41452] [ 3.2 38.62701 39.58505 2.4202 ] [ 3.3 41.86999 42.91068 2.42527] [ 3.4 45.38177 46.5119 2.42977] [ 3.5 49.18481 50.41172 2.43379] [ 3.6 53.30343 54.63508 2.43736] [ 3.7 57.76396 59.20898 2.44054] [ 3.8 62.59496 64.16269 2.44337] [ 3.9 67.8273 69.52788 2.44589] [ 4. 73.49449 75.33889 2.44813]]
Runge-Kutta (RK) methods can be thought as a higher order version of the Euler's method. The incrementation factor $\varphi$ is defined as:
$$\varphi = a_1 k_1+a_2 k_2+\dots+a_n k_n$$where $a$'s are constants and the $k$'s are defined as:
$$k_1 = f(x_i,y_i)\\ k_2 = f(x_i+p_1\Delta x,y_i+q_{11}k_1\Delta x)\\ k_3 = f(x_i+p_2\Delta x,y_i+q_{21}k_1\Delta x + q_{22}k_2\Delta x)\\ \vdots\\ k_n = f(x_i+p_{n-1}\Delta x,y_i + q_{n-1,1}k_1 \Delta x+ q_{n-1,2}k_2 \Delta x+\dots+q_{n-1,n-1}k_{n-1} \Delta x)$$where the $p$'s and $q$'s are constants.
(for $n=1$ RK converges to Euler's method)
Given $\frac{dy}{dx} = f(x, y), \quad y(x_0) = y_0$
The most employed RK method is the 4th order RK approach with the following fix:
$$\begin{align} x_{n+1} &= x_n + \Delta x \\ y_{n+1} &= y_n + \frac{1}{6}\Delta x\left(k_1 + 2k_2 + 2k_3 + k_4 \right) \end{align}$$with:
$$\begin{align} k_1 &= \ f(x_n, y_n), \\ k_2 &= \ f\left(x_n + \frac{\Delta x}{2}, y_n + \Delta x\frac{k_1}{2}\right), \\ k_3 &= \ f\left(x_n + \frac{\Delta x}{2}, y_n + \Delta x\frac{k_2}{2}\right), \\ k_4 &= \ f\left(x_n + \Delta x, y_n + \Delta xk_3\right). \end{align}$$
[Source: Chapra]
Use RK method to solve
$$y' = 4e^{0.8x} - 0.5y$$from $x=0$ to $x=4$ with a step size of $\Delta x = 1$
with the given initial condition: $y(x=0)=2$.
Compare your results with the analytical solution:
$$y(x) = e^{-0.5x}\left(3.07692e^{1.3x}-1.07692\right)$$import numpy as np
def f(x,y):
return 4*np.exp(0.8*x)-0.5*y
def s(x):
# Analytical solution
return np.exp(-0.5*x)*(3.07692*np.exp(1.3*x) - 1.07692)
xy = np.array([0,2,s(0)]).reshape(1,3)
delta_x = 1
i = 0
x = i*delta_x
while x<4:
k1 = f(x,xy[i,1])
k2 = f(x+delta_x/2,xy[i,1]+delta_x*k1/2)
k3 = f(x+delta_x/2,xy[i,1]+delta_x*k2/2)
k4 = f(x+delta_x,xy[i,1]+delta_x*k3)
y_1 = xy[i,1] + delta_x/6*(k1+2*k2+2*k3+k4)
x = x + delta_x
xy = np.vstack((xy,[x,y_1,s(x)]))
i = i + 1
err = np.abs((xy[:,1] - xy[:,2])/xy[:,2]*100)
np.set_printoptions(precision=5,suppress=True)
print(np.hstack((xy,err.reshape(len(err),1))))
RK_dx1 = np.copy(xy)
[[ 0. 2. 2. 0. ] [ 1. 6.20104 6.19463 0.10349] [ 2. 14.86248 14.84391 0.12514] [ 3. 33.72135 33.67714 0.13127] [ 4. 75.43917 75.33889 0.13311]]
import matplotlib.pyplot as plt
xx = np.linspace(0,4,50)
yy = s(xx)
plt.plot(Euler_dx1[:,0],Euler_dx1[:,1],"r*-")
plt.plot(RK_dx1[:,0],RK_dx1[:,1],"b*-")
plt.plot(xx,yy,"g-")
plt.legend(["Euler Approx.","Runge-Kutta","True Values"])
plt.show()
A bungee jumper's acceleration, with the drag force (which is proportional to $v^2$) taken into account is given by:
$$a = g - \frac{c_d}{m}v^2$$where $g=9.81$ m/s2, $m=68.1$ kg and the drag coefficient is $c_d = 0.25$ kg/m.
Solve for the position & velocity with respect to time, assuming that $x(t=0)=v(t=0) = 0$ and take $\Delta t =h= 2$ s increments.
Compare your findings with the analytical solutions: $$x(t)=\frac{m}{c_d}\ln{\left[\cosh\left({\sqrt{\frac{g c_d}{m}}t}\right)\right]}$$
$$v(t)=\sqrt{\frac{gm}{c_d}}\tanh\left({\sqrt{\frac{g c_d}{m}}t}\right)$$Solution $$\frac{\text{d}x}{\text{d}t}=v$$ $$\frac{\text{d}v}{\text{d}t}=a=g - \frac{c_d}{m}v^2\\ \downarrow$$
$$\frac{\text{d}x}{\text{d}t}=f_1(t,x,v)=v\rightarrow k_{1,1}=f_1(0,0,0)=0\\ \frac{\text{d}v}{\text{d}t}=f_2(t,x,v)=g- \frac{c_d}{m}v^2\rightarrow k_{1,2}=f_2(0,0,0)=9.81 - \frac{0.25}{68.1}(0)^2 = 9.81$$$$x(1) = x(0) + k_{1,1}\frac{h}{2}=0+0\frac{2}{2} = 0\\ v(1) = v(0)++ k_{1,2}\frac{h}{2}=0+9.81\frac{2}{2} = 9.81$$Now that we have the $k_1$'s, we can proceed to calculate the $k_2$'s:
$$k_2 = f(t_i+\frac{1}{2}h,y_i+\frac{1}{2}k_1 h)\\ k_{2,1}=f_1(1,0,9.81)=9.8100\\ k_{2,2}=f_2(1,0,9.81)=9.4567\\ x(1) = x(0) + k_{2,1}\frac{h}{2}=0+9.8100\frac{2}{2}=9.8100\\ v(1) = v(0) + k_{2,2}\frac{h}{2}=0+9.4567\frac{2}{2}=9.4567 $$Then comes the $k_3$'s:
$$k_3 = f(t_i+\frac{1}{2}h,y_i+\frac{1}{2}k_2 h)\\ k_{3,1}=f_1(1,9.8100,9.4567)=9.4567\\ k_{3,2}=f_2(1,9.8100,9.4567)=9.4817\\ x(2) = x(0) + k_{3,1}h=0+9.4567\times2=18.9134\\ v(2) = v(0) + k_{3,2}h=0+9.4817\times2=18.9634 $$Finally, the $k_4$'s:
$$k_4=f(t_i+h,y_i+k_3 h)\\ k_{4,1}=f_1(2,18.9134,18.9634)=18.9634\\ k_{4,2}=f_2(2,18.9134,18.9634)=8.4808$$combining them all, we have:
$$\boxed{x(2)=0+\frac{1}{6}\left[0+2\left(9.8100+9.4567\right)+18.9634\right]\times 2=19.1656\\ v(2)=0+\frac{1}{6}\left[9.8100+2\left(9.4567+9.4817\right)+8.4898\right]\times 2=18.7256}$$
[Source: Chapra]
RK45 function¶In 1980, combining the fourth and fifth order RK formulas, Dormand and Prince came up with a very efficient and widely used RK approach, implemented to libraries as RK45.
In Python, this function is implemented as scipy.integrate.RK45
from scipy import integrate
import scipy as sp
g = 9.81 # m/s^2
m = 68.1 # kg
c_d = 0.25 # kg/m
def fun(t,v):
return g-(c_d/m)*v**2
v0 = 0 # m/s
s = sp.integrate.RK45(fun,t0=0,y0=[v0],t_bound=2)
while s.t<2:
s.step()
print(s.t)
print(s.y)
2 [18.72919]
solve_ivp function¶Yet another function that acts as a compilation of solvers (with RK45 being the default) is the scipy.integrate.solve_ivp function.
This function is much easier to implement and use as evaluation points can be specified via the _tspan parameter and unlike the above RK45 function, it isn't a solver object, therefore there isn't any requirement to advance via the step() command.
Setting the dense_output option to True, we get the .sol method that acts as a function and evaluates the solution at the given t.
from scipy.integrate import solve_ivp
g = 9.81 # m/s^2
m = 68.1 # kg
c_d = 0.25 # kg/m
def fun(t,v):
return g-(c_d/m)*v**2
v0 = 0 # m/s
s = solve_ivp(fun,t_span=(0,10),y0=[v0]\
,t_eval=np.arange(0,11,2)\
,dense_output=True)
#print(s.t)
print(s.sol(2))
st = np.array(s.t)
sy = np.array(s.y[0])
ss = np.vstack((st,sy)).T
print(ss)
[18.72081] [[ 0. 0. ] [ 2. 18.72081] [ 4. 33.11915] [ 6. 42.07956] [ 8. 46.96799] [10. 49.42437]]
now that we have the $v$ values, we can also calculate the $x$ values as well. The ODE we need to solve is straightforward:
$$\dot x = v$$and to calculate the $v$ for the control points, we are implement a function that just feeds these to the solver! 8)
def fun_x(t,x):
return s.sol(t)
x = sp.integrate.solve_ivp(fun_x,t_span=(0,10),y0=[0]\
,t_eval=np.arange(0,11,2))
xt = np.array(x.t)
xy = np.array(x.y[0])
xx = np.vstack((xt,xy,sy)).T
print(xx)
[[ 0. 0. 0. ] [ 2. 19.19842 18.72081] [ 4. 71.90152 33.11915] [ 6. 147.9443 42.07956] [ 8. 237.52964 46.96799] [ 10. 334.20814 49.42437]]
As a final exercise, let's also have the real values calculated and re-drive the results table that was quoted above:
$$x(t)=\frac{m}{c_d}\ln{\left[\cosh\left({\sqrt{\frac{g c_d}{m}}t}\right)\right]}$$$$v(t)=\sqrt{\frac{gm}{c_d}}\tanh\left({\sqrt{\frac{g c_d}{m}}t}\right)$$g = 9.81 # m/s^2
m = 68.1 # kg
c_d = 0.25 # kg/m
def xt(t):
return m/c_d*np.log(np.cosh(np.sqrt(g*c_d/m)*t))
def vt(t):
return np.sqrt(g*m/c_d)*np.tanh(np.sqrt(g*c_d/m)*t)
t = np.arange(0,11,2)
xtt = xt(t)
vtt = vt(t)
res = np.vstack((xx.T,xtt,vtt)).T
print(res)
import pandas as pd
resRK = pd.DataFrame(res)
resRK.columns = ['t','x_RK','v_RK','x_T','v_T']
E_x = np.abs((resRK['x_RK'] - resRK['x_T'])/resRK['x_T']*100)
resRK['E_x'] = E_x
E_v = np.abs((resRK['v_RK'] - resRK['v_T'])/resRK['v_T']*100)
resRK['E_v'] = E_v
resRK.iloc[0,5:7] = ""
resRK
[[ 0. 0. 0. 0. 0. ] [ 2. 19.19842 18.72081 19.16629 18.72919] [ 4. 71.90152 33.11915 71.93037 33.11183] [ 6. 147.9443 42.07956 147.94618 42.07623] [ 8. 237.52964 46.96799 237.51044 46.9575 ] [ 10. 334.20814 49.42437 334.17817 49.42137]]
| t | x_RK | v_RK | x_T | v_T | E_x | E_v | |
|---|---|---|---|---|---|---|---|
| 0 | 0.0 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | ||
| 1 | 2.0 | 19.198419 | 18.720810 | 19.166286 | 18.729189 | 0.167651 | 0.0447347 |
| 2 | 4.0 | 71.901520 | 33.119152 | 71.930366 | 33.111825 | 0.0401028 | 0.0221289 |
| 3 | 6.0 | 147.944296 | 42.079558 | 147.946180 | 42.076227 | 0.00127343 | 0.00791663 |
| 4 | 8.0 | 237.529639 | 46.967991 | 237.510440 | 46.957495 | 0.00808355 | 0.0223527 |
| 5 | 10.0 | 334.208142 | 49.424366 | 334.178167 | 49.421367 | 0.00896954 | 0.0060677 |
import seaborn as sns
sns.set_theme()
gg = sns.relplot(data=resRK[['x_RK','x_T']],kind="line"\
,markers=['o','*'],markersize=15)
#plt.plot(np.linspace(0,10))
plt.legend(['Runge-Kutta','Analytic'])
plt.show()
plt.plot(resRK['t'],resRK['x_RK'],"ob-",markersize=15)
plt.plot(resRK['t'],resRK['x_T'],"*r",markersize=15)
t = np.linspace(0,10,50)
plt.plot(t,xt(t),"r-")
plt.show()