Residual#

We need to have a way to tell when to stop smoothing.

If we know the analytic solution, then we can just compare to that, and keep iterating until the error is small, but that kinda defeats the purpose. Instead, we can measure how well we satisfy the discrete equation—this is called the residual.

\[r_i \equiv f_i - \frac{\phi^{(k)}_{i+1} - 2 \phi^{(k)}_i + \phi_{i-1}^{(k)}}{\Delta x^2}\]

We still need something to compare to, so we define the source norm, \(\| f \|\), and we will pick a tolerance \(\epsilon\) and iterate until:

\[\| r \| < \epsilon \| f \|\]

For the special case of a homogeneous source (\(f = 0\)), then we will iterate until

\[\| r \| < \epsilon\]

We will use the L2 norm:

\[\| e \|_2 \equiv \left ( \Delta x \sum_i |e_i|^2 \right )^{1/2}\]

Let’s update our grid class to include a norm and residual function:

import numpy as np
import matplotlib.pyplot as plt

This is the same grid class as before

class Grid:
    def __init__(self, nx, ng=1, xmin=0, xmax=1,
                 bc_left_type="dirichlet", bc_left_val=0.0,
                 bc_right_type="dirichlet", bc_right_val=0.0):

        self.xmin = xmin
        self.xmax = xmax
        self.ng = ng
        self.nx = nx
        
        self.bc_left_type = bc_left_type
        self.bc_left_val = bc_left_val
        
        self.bc_right_type = bc_right_type
        self.bc_right_val = bc_right_val
        
        # python is zero-based.  Make easy integers to know where the
        # real data lives
        self.ilo = ng
        self.ihi = ng+nx-1

        # physical coords -- cell-centered
        self.dx = (xmax - xmin)/(nx)
        self.x = xmin + (np.arange(nx+2*ng)-ng+0.5)*self.dx

        # storage for the solution
        self.phi = self.scratch_array()
        self.f = self.scratch_array()
        
    def scratch_array(self):
        """return a scratch array dimensioned for our grid """
        return np.zeros((self.nx+2*self.ng), dtype=np.float64)

    def norm(self, e):
        """compute the L2 norm of e that lives on our grid"""
        return np.sqrt(self.dx * np.sum(e[self.ilo:self.ihi+1]**2))
    
    def fill_bcs(self):
        """fill the boundary conditions on phi"""
        
        # we only deal with a single ghost cell here
        
        # left
        if self.bc_left_type.lower() == "dirichlet":
            self.phi[self.ilo-1] = 2 * self.bc_left_val - self.phi[self.ilo]
        elif self.bc_left_type.lower() == "neumann":
            self.phi[self.ilo-1] = self.phi[self.ilo] - self.dx * self.bc_left_val
        else:
            raise ValueError("invalid bc_left_type")
            
        # right
        if self.bc_right_type.lower() == "dirichlet":
            self.phi[self.ihi+1] = 2 * self.bc_right_val - self.phi[self.ihi]
        elif self.bc_right_type.lower() == "neumann":
            self.phi[self.ihi+1] = self.phi[self.ihi] - self.dx * self.bc_right_val
        else:
            raise ValueError("invalid bc_right_type")

Now we’ll write our residual routine

def residual_norm(g):
    """compute the residual norm"""
    r = g.scratch_array()
    r[g.ilo:g.ihi+1] = g.f[g.ilo:g.ihi+1] - (g.phi[g.ilo+1:g.ihi+2] -
                                            2 * g.phi[g.ilo:g.ihi+1] +
                                            g.phi[g.ilo-1:g.ihi]) / g.dx**2
    return g.norm(r)

Now we’ll write a relaxation function that does smoothing until either a maximum number of iterations is taken or we reach a desired tolerance. If the tolerance is set to None, then the routine will take the full amount of iterations.

class TooManyIterations(Exception):
    pass

def relax(g, tol=1.e-8, max_iters=200000, analytic=None):
    
    iter = 0
    fnorm = g.norm(g.f)
    
    # if the RHS is f = 0, then set the norm to 1
    # this ensures that we then check for residual < eps
    if fnorm == 0.0:
        fnorm = 1
        
    g.fill_bcs()        
    r = residual_norm(g)
    
    res_norm = []
    true_norm = []
    
    while iter < max_iters and (tol is None or r > tol * fnorm):

        g.phi[g.ilo:g.ihi+1:2] = 0.5 * (-g.dx * g.dx * g.f[g.ilo:g.ihi+1:2] +
                                        g.phi[g.ilo+1:g.ihi+2:2] + g.phi[g.ilo-1:g.ihi:2])
    
        g.fill_bcs()

        g.phi[g.ilo+1:g.ihi+1:2] = 0.5 * (-g.dx * g.dx * g.f[g.ilo+1:g.ihi+1:2] +
                                          g.phi[g.ilo+2:g.ihi+2:2] + g.phi[g.ilo:g.ihi:2])
        
        g.fill_bcs()
        
        r = residual_norm(g)
        res_norm.append(r / fnorm)
        
        if analytic is not None:
            true_norm.append(g.norm(g.phi - analytic(g.x)))
        
        iter += 1
        
    if tol is not None and iter >= max_iters:
        raise TooManyIterations(f"too many iterations, niter = {iter}")
        
    return res_norm, true_norm, iter

Residual vs. Truncation Error#

Now we’ll look at how the residual error compares to the truncation error of our discretization. We’ll take a fixed number of iterations for our same model problem,

\[\phi^{\prime\prime} = \sin(x)\]

on \([0, 1]\) with homogeneous Dirichlet BCs.

def analytic(x):
    return -np.sin(x) + x * np.sin(1.0)

def f(x):
    return np.sin(x)
from matplotlib.lines import Line2D
fig, ax = plt.subplots()

for i, nx in enumerate([16, 32, 64]):
    g = Grid(nx)
    g.f[:] = f(g.x)

    res_norm, true_norm, _ = relax(g, tol=None, max_iters=20000, analytic=analytic)
    n = np.arange(len(res_norm)) + 1
    
    ax.loglog(n, true_norm, label=f"{nx}", color=f"C{i}")
    ax.loglog(n, res_norm, ls=":", color=f"C{i}")
    
    print(f"nx = {nx}, true error = {true_norm[-1]}")

# manually add line types to the legend
handles, labels = ax.get_legend_handles_labels()
line1 = Line2D([0], [0], ls="-", color="k", label="truncation error")
line2 = Line2D([0], [0], ls=":", color="k", label="residual")
handles.extend([line1, line2])
ax.legend(handles=handles, ncol=2)

ax.set_xlabel("number of smoothing iterations")
ax.grid(ls=":", color="0.5")
nx = 16, true error = 0.0002489743563076079
nx = 32, true error = 6.22480653738797e-05
nx = 64, true error = 1.5562295551083177e-05
../../_images/4fd8654f8ece793e62fc49fd24191c0c84791b51622870c6f1228795a3f6ffd3.png

Look at what this shows us:

  • The truncation error (solid line) stalls at a much higher value than the residual (dotted).

  • The truncation error converges second order with the number of zones (look at the numbers printed during the run)

  • As we increase the number of zones, we need more iterations until the residual drops to machine roundoff

  • The residual error eventually reaches roundoff—this indicates that we satisfy the discrete equation “exactly”

Note that the residual is telling us how well we satisfy the discrete form of Poisson’s equation, but it is not telling us anything about whether the discrete approximation we are using for Poisson’s equation is a good approximation. That is our job as a computational scientist to assess.

We are using a second-order accurate discretization. If needed, we could use a higher-order accurate method, although that would be more computationally expensive.

In-class exercise:

Solve the problem

\[\phi^{\prime\prime} = -4\pi^2 \cos(2\pi x)\]

on \([0, 1]\) with homogeneous Neumann boundary conditions.

C++ implementation#

Here’s a C++ implementation that supports Dirichlet boundary conditions.

This can be compiled as:

g++ -I. -o poisson poisson.cpp

and it will output a text file, poisson.txt with 3 columns of information: \(x\), \(\phi\), and \(f\).