87. Lecture 3#

87.1. Vibration equation#

The vibration equation can be written as

(87.1)#\[ \begin{equation} u^{''} + \omega^2 u = 0, u(0)=I, u'(0) = 0, \end{equation} \]

where \(\omega\) and \(I\) are given constants. This equation is often also referred to as the Helmholtz equation. A simple, recursive, finite difference method for solving the vibration equation is

\[\begin{split} \begin{align*} u^0 &= I\\ u^{1} &= u^0 - \frac{1}{2}\Delta t^2 \omega^2 u^0 \\ u^{n+1} &= 2u^n - u^{n-1} - \Delta t^2 \omega^2 u^{n}, n \in (1, 2, \ldots, N_t-1) \end{align*} \end{split}\]

where \(u^n = u(t_n)\) and \(t_n = n \Delta t\).

A recursive solver can easily be created using Numpy:

import matplotlib.pyplot as plt
import numpy as np
from plotslopes import slope_marker


def solver(dt, T, w=0.35, I=1):
    """
    Solve Eq. (1)

    Parameters
    ----------
    dt : float
        Time step
    T : float
        End time
    I, w : float, optional
        Model parameters

    Returns
    -------
    t : array_like
        Discrete times (0, dt, 2*dt, ..., T)
    u : array_like
        The solution at discrete times t
    """
    dt = float(dt)
    Nt = int(round(T / dt))
    u = np.zeros(Nt + 1)
    t = np.linspace(0, Nt * dt, Nt + 1)
    u[0] = I
    u[1] = u[0] - 0.5 * dt**2 * w**2 * u[0]
    for n in range(1, Nt):
        u[n + 1] = 2 * u[n] - u[n - 1] - dt**2 * w**2 * u[n]
    return t, u


def u_exact(t, w=0.35, I=1):
    """Exact solution of Eq. (1)

    Parameters
    ----------
    t : array_like
        Array of times to compute the solution
    I, w : float, optional
        Model parameters

    Returns
    -------
    ue : array_like
        The solution at times t
    """
    return I * np.cos(w * t)

We now want to compute convergence rates. We will use the \(\ell^2\)-norm computed as

\[E_i = \sqrt{\Delta t_i\sum_{n=0}^{N^{i}_t}(u^n-u_e(t_n))^2}\]

for a given uniform mesh level \(i\). For example, we can use \(N^0_t=8, N_t^{1}=16, N^2_t = 32\), etc., such that \(\Delta t_{1} / \Delta t_2 = 2\) and \(\Delta t_{n-1} / \Delta t_{n} = 2\) for all \(n\). Note that there are \(N_t^{i}\) intervals on level \(i\) and \(N_t^{i}+1\) points.

We assume that the error on the mesh with level \(i\) can be written as

\[E_i = C (\Delta t_i)^r, \]

where \(C\) is a constant. This way, if we have the error on two levels, then we can compute

\[ \frac{E_{i-1}}{E_i} = \frac{ (\Delta t_{i-1})^r}{(\Delta t_{i})^r} = \left( \frac{\Delta t_{i-1}}{ \Delta t_i} \right)^r \]

and isolate \(r\) by computing

\[ r = \frac{\log {\frac{E_{i-1}}{E_i}}}{\log {\frac{\Delta t_{i-1}}{\Delta t_i}}} \]

So by computing the error on two different levels we can find the order of the convergence!

Let’s first write a function that computes the \(\ell^2\) error of the solver defined above

def l2_error(dt, T, w=0.35, I=0.3, sol=solver):
    """Compute the l2 error norm of result from `solver`

    Parameters
    ----------
    dt : float
        Time step
    T : float
        End time
    I, w : float, optional
        Model parameters
    sol : callable
        The function that solves Eq. (1)

    Returns
    -------
    float
        The l2 error norm
    """
    t, u = sol(dt, T, w, I)
    ue = u_exact(t, w, I)
    return np.sqrt(dt * np.sum((ue - u) ** 2))

and then compute the convergence rates

def convergence_rates(m, dt0=30, num_periods=8, w=0.35, I=0.3, sol=solver):
    """
    Return m-1 empirical estimates of the convergence rate
    based on m simulations, where the time step is halved
    for each simulation.

    Parameters
    ----------
    m : int
        The number of mesh levels
    dt0 : int, optional
        Time steps per period on coarsest level
    num_periods : int, optional
        Size of domain is num_periods * 2pi / w
    w, I : float, optional
        Model parameters
    sol : callable
        The function that solves Eq. (1)

    Returns
    -------
    array_like
        The m-1 convergence rates
    array_like
        The m errors of the m meshes
    array_like
        The m time steps of the m meshes

    """
    P = 2 * np.pi / w
    dt = P / dt0
    T = P * num_periods
    dt_values, E_values = [], []
    for i in range(m):
        E = l2_error(dt, T, w, I, sol=sol)
        dt_values.append(dt)
        E_values.append(E)
        dt = dt / 2
    # Compute m-1 orders that should all be the same
    r = [
        np.log(E_values[i - 1] / E_values[i]) / np.log(dt_values[i - 1] / dt_values[i])
        for i in range(1, m, 1)
    ]
    return r, E_values, dt_values

Test it

convergence_rates(4)
([np.float64(2.0036366687367093),
  np.float64(2.0009497328195964),
  np.float64(2.000240106059535)],
 [np.float64(0.13526035155205304),
  np.float64(0.03372995596052594),
  np.float64(0.008426939670390834),
  np.float64(0.0021063843253281145)],
 [0.5983986006837702,
  0.2991993003418851,
  0.14959965017094254,
  0.07479982508547127])

These last two lists are the errors and timesteps af all the \(m\) levels. We can plot the error as a function of timestep and show that the discretization scheme is second order.

r, E, dt = convergence_rates(5)
plt.loglog(dt, E)
plt.loglog(dt, dt)
plt.loglog(dt, np.array(dt) ** 2)
plt.title("Convergence of finite difference method")
plt.legend(["Error", r"$\mathcal{O}(\Delta t)$", r"$\mathcal{O}(\Delta t^2)$"])
slope_marker((dt[1], E[1]), (2, 1))
slope_marker((dt[1], dt[1]), (1, 1))
slope_marker((dt[1], dt[1] ** 2), (2, 1))
../_images/e9656b42b930d58dc0d674c2177d430bf009fcd6b890f5f9dc9e6bfb253b42c5.png

We see that the error has the same slope as \(\Delta t^2\).

We can write a test for the order of accuracy:

def test_order(m):
    r, E, dt = convergence_rates(m)
    assert np.allclose(np.array(r), 2, atol=1e-2)
test_order(5)

87.2. Method of manufactured solution#

We will now use the method of manufactured solutions (MMS) to verify that the solver works. This is a method that will be used a lot throughout the course.

With the MMS we first need to assume that we know the solution. For example, we assume that the solution is

\[ u(t) = t^2. \]

It is important that the solution satisfies the boundary conditions: \(u(0)=0\) and \(u'(0)=0\). However, if we plug the solution into the Helmholtz equation, we do not get a zero right hand side

\[ u'' + \omega^2 u = 2 + \omega^2 t^2 \ne 0. \]

So we need to use a nonzero right hand side of Eq. (87.1). Since in this case \(u=t^2\) we get

\[\begin{split} \begin{align*} u'' + {\omega^2 u} &= 2 + \omega^2 u \\ u'' &= 2 \\ \end{align*} \end{split}\]

We also know that a second order accurate finite difference solution should be exact for a second order polynomial. Check:

dt = 0.1
T = 10
Nt = int(round(T / dt))
t = np.linspace(0, Nt * dt, Nt + 1)
u = t**2
uc = (u[2:] - 2 * u[1:-1] + u[:-2]) / dt**2
assert np.allclose(uc, 2)

The numerical solution is exact!

Assume now that the solution is

\[ u(t) = t^4 \]

Initial conditions still ok. Now the equation becomes

\[\begin{split} \begin{align*} u'' + \omega^2 u &= 12 t^2 + \omega^2 t^4 \\ u'' &= 12 t^2 \end{align*} \end{split}\]

Since the solution is a fourth order polynomial, the second order numerical solution will not be exact, but it will converge

dt = 0.1
Nt = int(round(T / dt))
t = np.linspace(0, Nt * dt, Nt + 1)
u = t**4
uc = (u[2:] - 2 * u[1:-1] + u[:-2]) / dt**2
print(uc - 12 * t[1:-1] ** 2)
[0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02
 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02
 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02
 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02
 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02
 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02
 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02 0.02
 0.02]

Let’s take a closer look at the numerical accuracy. The finite differece expression for \(u''\) comes from two Taylor expansions around \(t_n\)

(87.2)#\[ u^{n+1} = u^n + \Delta t u' + \frac{\Delta t^2}{2}u'' + \frac{\Delta t^3}{6}u''' + \frac{\Delta t^4}{24}u'''' + \cdots \]
(87.3)#\[ u^{n-1} = u^n - \Delta t u' + \frac{\Delta t^2}{2}u'' - \frac{\Delta t^3}{6}u''' + \frac{\Delta t^4}{24}u'''' + \cdots \]

Adding now (87.2) and (87.3) and we get

\[ u^{n+1} - 2u^n + u^{n-1} = \Delta t^2 u'' + \frac{\Delta t^4}{12}u'''' + \cdots \]

such that

\[ u'' = \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} - \frac{\Delta t^2}{12}u'''' + \cdots \]

There is a second order error proportional to \(u''''\). For \(u=t^2\) we have that \(u''''=0\), and as such no numerical error for our scheme. For \(u=t^4\) the numerical error is \(2 \Delta t^2\)

We can put the numerical expression into our equation and get

\[\begin{split} \begin{align} u'' + \omega^2 u &= 0 \\ \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} - \frac{\Delta t^2}{12}u'''' + \omega^2 u &= 0 \\ \frac{u^{n+1} - 2u^n + u^{n-1}}{\Delta t^2} + \omega^2 u &= \frac{\Delta t^2}{12}u'''' \end{align} \end{split}\]

The residual is \(\Delta t^2 u'''' / 12\).

Go back and check that the solution for \(u=t^4\) correspond exactly to this residual:

assert np.allclose(uc - 12 * t[1:-1] ** 2, 2 * dt**2)

It is exact because the next error term will be proportional to the sixth derivative of \(u\), which is zero for \(u=t^4\).

87.3. Adjusted solver#

def adj_solver(dt, T, w=0.35, I=1):
    """
    Solve Eq. (1)

    Parameters
    ----------
    dt : float
        Time step
    T : float
        End time
    I, w : float, optional
        Model parameters

    Returns
    -------
    t : array_like
        Discrete times (0, dt, 2*dt, ..., T)
    u : array_like
        The solution at discrete times t
    """
    dt = float(dt)
    Nt = int(round(T / dt))
    u = np.zeros(Nt + 1)
    t = np.linspace(0, Nt * dt, Nt + 1)
    u[0] = I
    u[1] = u[0] - 0.5 * dt**2 * (w * (1 - w**2 * dt**2 / 24)) ** 2 * u[0]
    for n in range(1, Nt):
        u[n + 1] = (
            2 * u[n] - u[n - 1] - dt**2 * (w * (1 - w**2 * dt**2 / 24)) ** 2 * u[n]
        )
    return t, u
r, E, dt = convergence_rates(5, sol=solver)
r2, E2, dt2 = convergence_rates(5, sol=adj_solver)
plt.loglog(dt, E)
plt.loglog(dt2, E2)
plt.loglog(dt, dt)
plt.loglog(dt, np.array(dt) ** 2)
plt.title("Convergence of finite difference method")
plt.legend(
    [
        "Error",
        "Error modified",
        r"$\mathcal{O}(\Delta t)$",
        r"$\mathcal{O}(\Delta t^2)$",
    ]
)
slope_marker((dt[1], E[1]), (2, 1))
slope_marker((dt2[1], E2[1]), (4, 1))
slope_marker((dt[1], dt[1]), (1, 1))
slope_marker((dt[1], dt[1] ** 2), (2, 1))
../_images/da58a884b377d1754357dd7b34c8423d5b024fae6012af4d8a755d58169cd78d.png