Solving 2D Poisson

Finite Elements

Now we solve on the unit square with zero boundary values, using the triangulation from chapter 4 and a piecewise-linear "tent" basis at every node. Same recipe as the 1D case from chapter 3 — compute element stiffness, compute element load, assemble, apply BCs, solve — just with 3×3 element matrices instead of 2×2.


The basis: linear tents on triangles

For each node , the basis function is the unique linear function on each surrounding triangle that equals 1 at node and 0 at every other node. Picture a tent: it peaks at one node and slopes down to zero along every edge of every triangle that doesn't touch that node. Outside the triangles that share node , it's identically zero.

Three properties carry over from 1D unchanged:


The gradient formula

For a triangle with vertices (counterclockwise), the gradient of points perpendicular to the opposite edge, scaled so that the basis function rises by 1 over the height of the triangle:

The pattern: the gradient of the basis at vertex is the 90° rotation of the edge opposite vertex , scaled by . The signs work out so that — the basis is a partition of unity, so its gradients must sum to zero.

def gradients_p1(verts):
    """Gradients of the P1 basis functions on a triangle.

    For a triangle with vertices p1, p2, p3 (counterclockwise), the basis
    function phi_i is the unique linear function that equals 1 at vertex i
    and 0 at the other two. Its gradient is constant on the element.
    Returns a (3, 2) array: row i is grad phi_i.
    """
    (x1, y1), (x2, y2), (x3, y3) = verts
    two_A = (x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1)
    A = abs(two_A) * 0.5
    inv = 1.0 / abs(two_A)
    return A, np.array([
        [(y2 - y3) * inv, (x3 - x2) * inv],
        [(y3 - y1) * inv, (x1 - x3) * inv],
        [(y1 - y2) * inv, (x2 - x1) * inv],
    ])

Element stiffness

With the gradients in hand, the element stiffness is one matrix multiply:

where is the 3×2 matrix of gradient rows. The result is a symmetric 3×3 with zero row sums (just like the 1D case had zero row sums on the 2×2). One line of code:

def element_stiffness_p1(verts):
    """3x3 stiffness for one P1 triangle: K^e_ij = A * (grad phi_i . grad phi_j)."""
    A, G = gradients_p1(verts)
    return A * (G @ G.T)

Element load

The right-hand side needs quadrature again. The 3-point edge-midpoint rule is exact for polynomials of degree 2, plenty for our smooth forcings. At each edge midpoint, two of the three basis functions equal 1/2 and one is 0; weighting by gives the standard formula:

def element_load_p1(f, verts):
    """3-vector load via 3-point edge-midpoint quadrature.

    The midpoint rule on a triangle is exact for polynomials of degree 2,
    which is plenty for the smooth forcings we throw at it.
    """
    A, _ = gradients_p1(verts)
    p1, p2, p3 = verts
    m12 = 0.5 * (p1 + p2)
    m23 = 0.5 * (p2 + p3)
    m31 = 0.5 * (p3 + p1)

    f12 = f(*m12)
    f23 = f(*m23)
    f31 = f(*m31)

    # At each midpoint, two of the three basis functions equal 1/2 and one
    # equals 0. Each midpoint contributes weight A/3.
    return (A / 3.0) * np.array([
        0.5 * f12 + 0.0 * f23 + 0.5 * f31,
        0.5 * f12 + 0.5 * f23 + 0.0 * f31,
        0.0 * f12 + 0.5 * f23 + 0.5 * f31,
    ])

Assembly, BCs, solve

Identical pattern to chapter 3, just with 3-element index vectors instead of 2:

def assemble_2d(N, f):
    """Assemble global K and b on the structured mesh of [0,1]^2."""
    nodes, elements, boundary = mesh_2d(N)
    n = len(nodes)
    K = np.zeros((n, n))
    b = np.zeros(n)

    for tri in elements:
        verts = nodes[tri]
        K_e = element_stiffness_p1(verts)
        b_e = element_load_p1(f, verts)
        K[np.ix_(tri, tri)] += K_e
        b[tri] += b_e

    return K, b, nodes, boundary


def apply_dirichlet_2d(K, b, boundary):
    """Pin every boundary node to zero — same row-replacement trick as in 1D."""
    for i in boundary:
        K[i, :] = 0.0
        K[:, i] = 0.0
        K[i, i] = 1.0
        b[i] = 0.0


def solve_2d_poisson(N, f):
    K, b, nodes, boundary = assemble_2d(N, f)
    apply_dirichlet_2d(K, b, boundary)
    u = np.linalg.solve(K, b)
    return nodes, u

The assembly loop is doing real work this time: for each of triangles, compute a 3×3 stiffness and a 3-vector load, scatter them into the global system using the triangle's three node indices. The boundary handler runs over the precomputed boundary array from chapter 4's mesher and zeroes each row/column. Then np.linalg.solve finishes it off — for our largest visualizer mesh that's a few-hundred-by- few-hundred dense system, well within numpy's reach.


Convergence

A manufactured solution gives us something to compare against. Pick ; then , so we know the analytic answer at every point and can measure the error directly:

import numpy as np

# Manufactured solution: u = sin(pi x) sin(pi y), so f = 2 pi^2 sin sin.
def f(x, y):
    return 2 * np.pi**2 * np.sin(np.pi * x) * np.sin(np.pi * y)
def u_exact(x, y):
    return np.sin(np.pi * x) * np.sin(np.pi * y)

for N in [4, 8, 16]:
    nodes, u_h = solve_2d_poisson(N, f)
    u_ex = np.array([u_exact(x, y) for x, y in nodes])
    err = np.max(np.abs(u_h - u_ex))
    print(f"N = {N:2d}:  max nodal error = {err:.4e}")
N =  4:  max nodal error = 4.0712e-02
N =  8:  max nodal error = 1.0292e-02
N = 16:  max nodal error = 2.5837e-03

Each doubling of roughly quarters the max nodal error. That's the convergence rate you expect from piecewise-linear FEM on a quasi-uniform mesh — the same rate we saw in 1D, now in 2D.


See it

The visualizer below runs the whole pipeline — assemble, solve, render — every time you change a parameter. Each triangle is filled with the average of its three nodal solution values; the color bar on the right shows the mapping from value to color. Toggle the mesh overlay to see the underlying triangulation.

0110xy0.9920.000u
problem
divisions
121 nodes · 200 triangles · 81 unknownsmax nodal error ≈ 8.23e-3

Things to read off the picture:

The max nodal error readout is reported only for the manufactured problem (the only one with a known analytic answer). For the other two we can't compute the true error — we'd have to refine repeatedly and measure differences between successive meshes, a Richardson-extrapolation kind of argument.


Where to go from here

The code we just wrote — mesher, element stiffness, element load, assembly, BCs, solve — is the entire FEM playbook, structured the way every textbook FEM library structures it (the local-to-global scatter, the per-element loop, the boundary handling). What separates a research-grade code from this is mostly:

But the core idea is what you just built. A working FEM solver in well under 200 lines of Python, end-to-end, with results you can hold up against analytic answers and watch converge. Everything that comes next is incremental.