Solving 1D Poisson

Finite Elements

Now we solve. We have the weak form, we have a mesh, and we have the hat-function basis. This chapter assembles those three things into a linear system and solves it. The pieces fit together exactly the way the math said they would.


The element stiffness matrix

Inside one element of width , only two basis functions are non-zero: the left-edge hat and the right-edge hat . Their derivatives on this element are constants: and . So the four integrals are products of constants times . Doing the arithmetic:

Symmetric, singular (its row sums are zero, which means it kills constant functions — good, those have zero derivative), and the same for every element of the same width. This little 2×2 is the entire FEM contribution from one element.

def element_stiffness(h):
    """Local 2x2 stiffness matrix for one hat-element of width h."""
    return (1.0 / h) * np.array([[ 1.0, -1.0],
                                 [-1.0,  1.0]])

The element load vector

The right-hand side breaks the same way: each element contributes a 2-vector built from the two basis functions that touch it. For a general we evaluate the integral numerically — two-point Gauss-Legendre quadrature is exact for polynomials of degree up to three, more than enough resolution for the smooth forcings we'll throw at it.

def element_load(f, x_left, x_right):
    """Local 2-vector load via 2-point Gauss-Legendre quadrature.

    Exact for any polynomial f of degree <= 3. For sin / cos forcings it's
    not exact but the error is well below the discretization error of the
    method itself, so it doesn't show up in the plot.
    """
    h = x_right - x_left
    xi = np.array([-1 / np.sqrt(3),  1 / np.sqrt(3)])  # quadrature nodes on [-1, 1]
    w  = np.array([ 1.0, 1.0])                          # weights
    x_q = x_left + (xi + 1) * 0.5 * h                   # mapped to element

    phi_left  = (1 - xi) * 0.5
    phi_right = (1 + xi) * 0.5

    f_q = np.array([f(xq) for xq in x_q])
    # Jacobian h/2 included.
    return np.array([
        np.sum(w * f_q * phi_left  * (h * 0.5)),
        np.sum(w * f_q * phi_right * (h * 0.5)),
    ])

A note on the Jacobian. The quadrature rule sits on the reference interval ; mapping it to the physical element introduces a factor of from the change of variables. That's the trailing h * 0.5 in the code.


Assembly

Assembly is the loop that turns local element matrices into a single global system. For each element, look up which two global nodes it touches, and add its 2×2 contribution into the matching 2×2 block of the global . Same for the load vector.

def assemble(N, f):
    """Assemble global K and b, ignoring boundary conditions for now."""
    nodes, elements = mesh_1d(N)
    n = N + 1
    K = np.zeros((n, n))
    b = np.zeros(n)

    for e in range(N):
        i, j = elements[e]
        h = nodes[j] - nodes[i]

        K_e = element_stiffness(h)
        K[np.ix_([i, j], [i, j])] += K_e

        b_e = element_load(f, nodes[i], nodes[j])
        b[[i, j]] += b_e

    return K, b, nodes

The np.ix_(...) trick is just numpy's way to write "the 2×2 block at rows [i, j] and columns [i, j]." Same pattern will show up in the 2D case, just with 3×3 blocks for triangles instead of 2×2 for line segments.


Boundary conditions

For Dirichlet BCs , the cleanest move is to strip the boundary nodes from the unknown set: we know already, so they don't need to be solved for. The implementation trick is to zero those rows and columns of , put a 1 on the diagonal, and zero the matching entries of . The resulting system has the boundary unknowns trivially equal to zero and leaves the interior block untouched:

def apply_dirichlet(K, b):
    """Pin the boundary nodes (0 and N) to zero by zeroing their rows/cols
    and putting a 1 on the diagonal — the standard "row replacement" trick."""
    n = K.shape[0]
    for i in (0, n - 1):
        K[i, :] = 0.0
        K[:, i] = 0.0
        K[i, i] = 1.0
        b[i] = 0.0

For non-zero Dirichlet conditions like , you'd set b[i] = u_0 and subtract from the original RHS before zeroing the column — a "lift the boundary data" operation. The rest of the machinery is unchanged. We'll stick to homogeneous BCs for the visualizations.


Solve and check

Tie it together. The full solver is just three steps — assemble, apply BCs, call np.linalg.solve:

def solve_1d_poisson(N, f):
    """End-to-end 1D Poisson solver: -u''(x) = f(x), u(0) = u(1) = 0."""
    K, b, nodes = assemble(N, f)
    apply_dirichlet(K, b)
    c = np.linalg.solve(K, b)
    return nodes, c


# Demo: f = pi^2 * sin(pi x), so u_exact(x) = sin(pi x).
import numpy as np
nodes, c = solve_1d_poisson(N=8, f=lambda x: np.pi**2 * np.sin(np.pi * x))

print("nodes:", nodes)
print("u_h:  ", c.round(4))
print("u_ex: ", np.sin(np.pi * nodes).round(4))
nodes: [0.    0.125 0.25  0.375 0.5   0.625 0.75  0.875 1.   ]
u_h:   [0.    0.3815 0.7053 0.9213 0.9991 0.9213 0.7053 0.3815 0.   ]
u_ex:  [0.    0.3827 0.7071 0.9239 1.     0.9239 0.7071 0.3827 0.   ]

Eight elements, and the FEM solution is already within of the analytic at every node. There is in fact a small piece of magic going on here: for 1D Poisson with hat functions, the FEM solution is exact at the nodes (assuming exact integration of the load vector). The error you see is purely from quadrature, not from the basis. Between the nodes, however, is only piecewise linear, and that's what the visualization below makes obvious.


See it

The plot below shows the forcing on top, the analytic solution as a smooth line, and the FEM solution as a polyline through the nodes. Switch problems and refine the mesh and watch the polyline snap onto the smooth curve.

010.710.7f (x)1.120-0.12u (x)x = 0x = 1analytic u(x)FEM u_h (N = 8)
problem
elementsmax nodal error ≈ 1.67e-5

What to read off the plot:

A clean theoretical statement, made visible: piecewise-linear FEM converges as in the norm. Empirically: each doubling of quarters the max between-node error of the polyline (the error you see). Try it — eyeball the worst-case gap at , then look at it again at .


Where this is going

The architecture established here — element stiffness, element load, assembly loop, boundary handling, solve — is the entire FEM playbook. Chapter 4 builds a 2D triangular mesh; chapter 5 swaps the 2-node line element for a 3-node triangle and solves Poisson on the unit square. The assembly routine in 2D will look almost identical to assemble above — just with 3×3 element matrices and a different geometry. Same idea, one dimension up.