15  Error Analysis and Convergence in FEM

This notebook explores FEM error behaviour through numerical experiments. We solve a 1D Poisson problem with a known exact solution, measure the error in different norms, and verify the theoretical convergence rates.

Learning objectives:

  1. Measure \(L^2\) and \(H^1\) (energy) errors against an exact solution.
  2. Verify the theoretical convergence rates: \(\|e\|_{L^2} = O(h^2)\) and \(\|e\|_{H^1} = O(h)\) for linear elements.
  3. Understand superconvergence at nodes.
  4. Observe the effect of mesh refinement on solution quality.
  5. Introduce a posteriori error estimation via the element residual.
import sys, os
sys.path.insert(0, os.path.join(os.pardir, 'src'))

import sympy as sp
import numpy as np
import matplotlib.pyplot as plt
from IPython.display import Math, display, Markdown

from symbolic_fem_workbench import (
    build_bar_1d_local_problem,
    assemble_dense_matrix, assemble_dense_vector,
    apply_dirichlet_by_reduction, expand_reduced_solution,
)

15.1 1. Model Problem with Known Exact Solution

We solve \(-u''(x) = f(x)\) on \([0, 1]\) with \(u(0) = 0\), \(u(1) = 0\).

Choose \(u_{\text{exact}}(x) = \sin(\pi x)\), which gives \(f(x) = \pi^2 \sin(\pi x)\).

This is a manufactured solution approach: we pick the answer first and derive the source term.

def u_exact(x):
    """Exact solution."""
    return np.sin(np.pi * x)

def du_exact(x):
    """Exact derivative."""
    return np.pi * np.cos(np.pi * x)

def f_source(x):
    """Source term: -u'' = pi^2 sin(pi x)."""
    return np.pi**2 * np.sin(np.pi * x)

15.2 2. FEM Solver for Varying Mesh Sizes

We solve the problem on uniform meshes with \(n = 2, 4, 8, 16, 32, 64\) elements and measure the error in each case.

def solve_poisson_1d(n_elem):
    """Solve -u'' = f on [0,1] with u(0)=u(1)=0 using n_elem linear elements.
    
    Returns: x_nodes, u_fem (nodal solution)
    """
    L_total = 1.0
    h = L_total / n_elem
    n_nodes = n_elem + 1
    x_nodes = np.linspace(0, L_total, n_nodes)
    
    # Element stiffness and load from the workbench (specialised numerically)
    # Ke = (1/h) * [[1, -1], [-1, 1]] for EA=1
    Ke = (1.0/h) * np.array([[1, -1], [-1, 1]], dtype=float)
    
    # Load vector via 2-point Gauss quadrature on each element
    K_global = np.zeros((n_nodes, n_nodes))
    F_global = np.zeros(n_nodes)
    
    for e in range(n_elem):
        conn = [e, e+1]
        x0, x1 = x_nodes[e], x_nodes[e+1]
        
        # Assemble stiffness
        assemble_dense_matrix(K_global, Ke, conn)
        
        # Load vector: 2-point Gauss on [x0, x1]
        # Gauss points in [-1,1]: +/- 1/sqrt(3)
        gp = 1.0 / np.sqrt(3)
        for xi_g, w_g in [(-gp, 1.0), (gp, 1.0)]:
            x_g = 0.5*(x0 + x1) + 0.5*h*xi_g  # map to physical
            N1 = 0.5*(1 - xi_g)
            N2 = 0.5*(1 + xi_g)
            f_val = f_source(x_g)
            F_global[conn[0]] += w_g * N1 * f_val * (h/2)
            F_global[conn[1]] += w_g * N2 * f_val * (h/2)
    
    # Apply Dirichlet BCs: u(0)=0, u(n_nodes-1)=0
    bc_dofs = [0, n_nodes-1]
    bc_vals = [0.0, 0.0]
    K_ff, F_f, free_dofs = apply_dirichlet_by_reduction(
        K_global, F_global, bc_dofs, bc_vals
    )
    
    u_free = np.linalg.solve(K_ff, F_f)
    u_fem = expand_reduced_solution(u_free, n_nodes, free_dofs, bc_dofs, bc_vals)
    
    return x_nodes, u_fem

# Quick test
x_test, u_test = solve_poisson_1d(8)
print(f"Max nodal error with 8 elements: {np.max(np.abs(u_test - u_exact(x_test))):.6e}")
Max nodal error with 8 elements: 1.665047e-05

15.3 3. Computing Error Norms

We measure errors in three norms:

  • Nodal max error (pointwise): \(\max_i |u^h(x_i) - u(x_i)|\)
  • \(L^2\) error: \(\|e\|_{L^2} = \left( \int_0^1 (u^h - u)^2 \, dx \right)^{1/2}\)
  • \(H^1\) semi-norm error (energy norm): \(|e|_{H^1} = \left( \int_0^1 (u^{h\prime} - u')^2 \, dx \right)^{1/2}\)

Theory predicts for P1 elements and smooth \(u\):

  • \(\|e\|_{L^2} \leq C_1 h^2 |u|_{H^2}\)
  • \(|e|_{H^1} \leq C_2 h |u|_{H^2}\)
def compute_errors(x_nodes, u_fem):
    """Compute L2 and H1 errors using 3-point Gauss quadrature per element."""
    n_elem = len(x_nodes) - 1
    L2_sq = 0.0
    H1_sq = 0.0
    
    # 3-point Gauss on [-1,1]
    gp3 = np.array([-np.sqrt(3/5), 0.0, np.sqrt(3/5)])
    gw3 = np.array([5/9, 8/9, 5/9])
    
    for e in range(n_elem):
        x0, x1 = x_nodes[e], x_nodes[e+1]
        h = x1 - x0
        u0, u1 = u_fem[e], u_fem[e+1]
        
        for xi_g, w_g in zip(gp3, gw3):
            # Physical coordinate
            x_g = 0.5*(x0 + x1) + 0.5*h*xi_g
            
            # FEM interpolation at Gauss point
            N1 = 0.5*(1 - xi_g)
            N2 = 0.5*(1 + xi_g)
            uh = N1*u0 + N2*u1
            
            # FEM derivative (constant per element)
            duh = (u1 - u0) / h
            
            # Errors
            L2_sq += w_g * (uh - u_exact(x_g))**2 * (h/2)
            H1_sq += w_g * (duh - du_exact(x_g))**2 * (h/2)
    
    return np.sqrt(L2_sq), np.sqrt(H1_sq)

# ---- Convergence study ----
n_elems = [2, 4, 8, 16, 32, 64, 128]
h_vals = [1.0/n for n in n_elems]
L2_errors = []
H1_errors = []
nodal_errors = []

for n in n_elems:
    x_n, u_n = solve_poisson_1d(n)
    e_L2, e_H1 = compute_errors(x_n, u_n)
    e_nodal = np.max(np.abs(u_n - u_exact(x_n)))
    L2_errors.append(e_L2)
    H1_errors.append(e_H1)
    nodal_errors.append(e_nodal)

L2_errors = np.array(L2_errors)
H1_errors = np.array(H1_errors)
nodal_errors = np.array(nodal_errors)
h_vals = np.array(h_vals)

# Print table
print(f"{'n_elem':>8} {'h':>10} {'L2 error':>12} {'H1 error':>12} {'nodal max':>12}")
print('-' * 56)
for i, n in enumerate(n_elems):
    print(f"{n:>8d} {h_vals[i]:>10.4f} {L2_errors[i]:>12.4e} {H1_errors[i]:>12.4e} {nodal_errors[i]:>12.4e}")
  n_elem          h     L2 error     H1 error    nodal max
--------------------------------------------------------
       2     0.5000   1.4869e-01   9.6687e-01   4.8349e-03
       4     0.2500   3.9127e-02   4.9851e-01   2.7307e-04
       8     0.1250   9.9108e-03   2.5118e-01   1.6650e-05
      16     0.0625   2.4859e-03   1.2583e-01   1.0343e-06
      32     0.0312   6.2198e-04   6.2947e-02   6.4544e-08
      64     0.0156   1.5553e-04   3.1477e-02   4.0325e-09
     128     0.0078   3.8884e-05   1.5739e-02   2.5200e-10

15.4 4. Convergence Rates

# Compute convergence rates
L2_rates = np.log(L2_errors[:-1] / L2_errors[1:]) / np.log(h_vals[:-1] / h_vals[1:])
H1_rates = np.log(H1_errors[:-1] / H1_errors[1:]) / np.log(h_vals[:-1] / h_vals[1:])
nodal_rates = np.log(nodal_errors[:-1] / nodal_errors[1:]) / np.log(h_vals[:-1] / h_vals[1:])

print(f"{'refinement':>12} {'L2 rate':>10} {'H1 rate':>10} {'nodal rate':>12}")
print('-' * 46)
for i in range(len(L2_rates)):
    print(f"{n_elems[i]:>4d}->{n_elems[i+1]:>4d}  {L2_rates[i]:>10.2f} {H1_rates[i]:>10.2f} {nodal_rates[i]:>12.2f}")

print(f"\nExpected: L2 rate -> 2.0, H1 rate -> 1.0")
print(f"Nodal superconvergence for uniform meshes: rate -> 2.0")
  refinement    L2 rate    H1 rate   nodal rate
----------------------------------------------
   2->   4        1.93       0.96         4.15
   4->   8        1.98       0.99         4.04
   8->  16        2.00       1.00         4.01
  16->  32        2.00       1.00         4.00
  32->  64        2.00       1.00         4.00
  64-> 128        2.00       1.00         4.00

Expected: L2 rate -> 2.0, H1 rate -> 1.0
Nodal superconvergence for uniform meshes: rate -> 2.0
fig, ax = plt.subplots(figsize=(8, 6))

ax.loglog(h_vals, L2_errors, 'bo-', lw=2, ms=6, label=r'$\|e\|_{L^2}$')
ax.loglog(h_vals, H1_errors, 'rs-', lw=2, ms=6, label=r'$|e|_{H^1}$')
ax.loglog(h_vals, nodal_errors, 'g^-', lw=2, ms=6, label='Nodal max')

# Reference slopes
h_ref = np.array([h_vals[0], h_vals[-1]])
ax.loglog(h_ref, L2_errors[0] * (h_ref/h_ref[0])**2, 'b--', alpha=0.4, label=r'$O(h^2)$')
ax.loglog(h_ref, H1_errors[0] * (h_ref/h_ref[0])**1, 'r--', alpha=0.4, label=r'$O(h^1)$')

ax.set_xlabel('Element size h')
ax.set_ylabel('Error')
ax.set_title('FEM Convergence: P1 Elements on 1D Poisson')
ax.legend()
ax.grid(True, which='both', alpha=0.3)
plt.tight_layout()
plt.show()

15.5 5. Visualising the Error Distribution

Where is the error largest? For linear elements, the derivative error is constant per element, so the \(H^1\) error is element-wise.

n_show = 8
x_n, u_n = solve_poisson_1d(n_show)
x_fine = np.linspace(0, 1, 500)

fig, axes = plt.subplots(1, 3, figsize=(14, 4))

# Solution comparison
axes[0].plot(x_fine, u_exact(x_fine), 'k-', lw=2, label='Exact')
axes[0].plot(x_n, u_n, 'bo-', ms=6, label=f'FEM ({n_show} elem)')
axes[0].set_title('Solution')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# Pointwise error
# Interpolate FEM solution on fine grid
u_fem_fine = np.interp(x_fine, x_n, u_n)
axes[1].plot(x_fine, np.abs(u_fem_fine - u_exact(x_fine)), 'b-', lw=1.5)
axes[1].set_title('Pointwise error |u_h - u|')
axes[1].set_ylabel('|error|')
axes[1].grid(True, alpha=0.3)

# Element-wise H1 error
elem_H1_err = []
elem_centers = []
for e in range(n_show):
    x0, x1 = x_n[e], x_n[e+1]
    h = x1 - x0
    xc = 0.5*(x0 + x1)
    duh = (u_n[e+1] - u_n[e]) / h
    # Integrate (du_h' - u')^2 with 3-point Gauss
    gp3 = [-np.sqrt(3/5), 0.0, np.sqrt(3/5)]
    gw3 = [5/9, 8/9, 5/9]
    err_sq = 0.0
    for xi_g, w_g in zip(gp3, gw3):
        x_g = 0.5*(x0+x1) + 0.5*h*xi_g
        err_sq += w_g * (duh - du_exact(x_g))**2 * (h/2)
    elem_H1_err.append(np.sqrt(err_sq))
    elem_centers.append(xc)

axes[2].bar(elem_centers, elem_H1_err, width=1.0/n_show*0.8, color='salmon', edgecolor='darkred')
axes[2].set_title(r'Element $H^1$ semi-norm error')
axes[2].set_xlabel('x')
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

15.6 6. A Posteriori Error Estimation (Element Residual)

In practice, we don’t know the exact solution. The element residual indicator provides an error estimate from the computed solution alone:

\[\eta_e^2 = h_e^2 \int_{\Omega_e} r_h^2 \, dx + h_e \sum_{\text{edges}} \int_{\partial \Omega_e} [\![\nabla u_h \cdot n]\!]^2 \, ds\]

In 1D, the interior residual is \(r_h = f + u_h'' = f\) (since \(u_h''=0\) for linear elements), and the jump term at each interior node is \([\![u_h'\!]] = u_h'|_{\text{right}} - u_h'|_{\text{left}}\).

def element_residual_indicators(x_nodes, u_fem):
    """Compute element residual error indicators in 1D."""
    n_elem = len(x_nodes) - 1
    eta = np.zeros(n_elem)
    
    # Element derivatives
    du_elem = np.diff(u_fem) / np.diff(x_nodes)
    
    for e in range(n_elem):
        x0, x1 = x_nodes[e], x_nodes[e+1]
        h = x1 - x0
        xc = 0.5*(x0 + x1)
        
        # Interior residual: h^2 * integral of f^2 over element
        # (since u_h'' = 0 for linear elements, residual r = f + u_h'' = f)
        # Use midpoint rule for simplicity
        r_int = h**2 * h * f_source(xc)**2
        
        # Jump terms at element boundaries
        jump_sq = 0.0
        if e > 0:  # left interior node
            jump = du_elem[e] - du_elem[e-1]
            jump_sq += 0.5 * h * jump**2  # half goes to each element
        if e < n_elem - 1:  # right interior node
            jump = du_elem[e+1] - du_elem[e]
            jump_sq += 0.5 * h * jump**2
        
        eta[e] = np.sqrt(r_int + jump_sq)
    
    return eta

# Compare true error vs indicator
eta = element_residual_indicators(x_n, u_n)

fig, ax = plt.subplots(figsize=(8, 4))
width = 0.35 / n_show
centers = np.array(elem_centers)
ax.bar(centers - width, elem_H1_err, width=2*width, color='steelblue', 
       alpha=0.7, label=r'True $|e|_{H^1,e}$')
ax.bar(centers + width, eta, width=2*width, color='salmon', 
       alpha=0.7, label=r'Indicator $\eta_e$')
ax.set_xlabel('x')
ax.set_ylabel('Error / Indicator')
ax.set_title('True Error vs. A Posteriori Indicator')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Effectivity index
total_true = np.sqrt(sum(e**2 for e in elem_H1_err))
total_eta = np.sqrt(sum(e**2 for e in eta))
print(f"Global true H1 error: {total_true:.6e}")
print(f"Global indicator:     {total_eta:.6e}")
print(f"Effectivity index:    {total_eta/total_true:.3f} (should be O(1))")

Global true H1 error: 2.511817e-01
Global indicator:     1.225849e+00
Effectivity index:    4.880 (should be O(1))

15.7 Summary

Quantity Convergence Rate (P1) Measured
\(\|e\|_{L^2}\) \(O(h^2)\) ~ 2.0
\(|e|_{H^1}\) \(O(h)\) ~ 1.0
Nodal error (uniform mesh) \(O(h^2)\) superconvergence ~ 2.0

Key takeaways:

  • The \(L^2\) error converges one order faster than the \(H^1\) error (Aubin-Nitsche trick).
  • Nodal values enjoy superconvergence on uniform meshes.
  • A posteriori indicators track the true error distribution and can guide adaptive mesh refinement.
  • The effectivity index (ratio of indicator to true error) should remain bounded — this is the hallmark of a reliable estimator.