The heat equation: adding time

Finite Elements

Poisson's equation has no time. Heat does. We need a new kind of matrix — the mass matrix — and we need to decide how to march the solution forward in time. Both decisions have visible consequences, and the visualizer at the end of the chapter lets you feel them.


The PDE and its weak form

The 1D heat equation:

With Dirichlet boundary conditions and an initial profile . The constant is the thermal diffusivity. Multiply by a test function and integrate:

Same integration-by-parts trick as Poisson on the second term; the time derivative just rides along. Now expand — coefficients that depend on time but basis functions that don't.


The mass matrix

Plug the expansion in. The first integral becomes:

where is the mass matrix. It plays the same role for the time derivative that played for the spatial Laplacian.

For 1D hat functions on a uniform mesh of width , the integrals are short to compute by hand. Each interior node's self-overlap integrates to ; each adjacent-node overlap integrates to ; everything else is zero (different basis functions don't share elements):

Tridiagonal, symmetric, positive-definite. Same shape as , different entries.


The semi-discrete system

Putting and together gives a system of ordinary differential equations in time:

"Semi-discrete" because we discretized in space (FEM) but not yet in time. The next step picks a numerical method for the time derivative; the choice has a name and a stability story.


Three time-stepping schemes

Approximate and decide where to evaluate the right-hand side: at the old time, the new time, or somewhere between.

Forward Euler (explicit)

The right-hand side uses only the known , so the update is direct — solve once per step. Conditionally stable: blows up unless . Halving the mesh size requires quartering the time step. Painful for fine meshes.

def step_forward_euler(c, M, K, alpha, dt):
    """Explicit. One step: c_new = c - α dt M^-1 K c.
    Stable only when dt < h² / (2α)."""
    rhs = M @ c - alpha * dt * (K @ c)
    return solve_tri(M, rhs)

Backward Euler (implicit)

The right-hand side uses the unknown , so we solve at every step. Unconditionally stable. First-order accurate in time, like forward Euler.

def step_backward_euler(c, M, K, alpha, dt):
    """Implicit. (M + α dt K) c_new = M c. Unconditionally stable."""
    A = M + alpha * dt * K
    rhs = M @ c
    return solve_tri(A, rhs)

Crank-Nicolson

Average of the two. Solve . Unconditionally stable AND second-order accurate in time. The default choice when accuracy matters and you can afford an implicit solve per step.

def step_crank_nicolson(c, M, K, alpha, dt):
    """Implicit, 2nd-order in time. (M + α dt K/2) c_new = (M - α dt K/2) c."""
    A = M + 0.5 * alpha * dt * K
    rhs = (M - 0.5 * alpha * dt * K) @ c
    return solve_tri(A, rhs)

All three solve a tridiagonal system per step in 1D — same Thomas-algorithm cost. The difference is what the matrix on the left looks like, and whether it depends on .


See it

The visualizer evolves an initial profile under each scheme. The dashed gray line shows the initial condition for reference; the solid accent curve is the current solution. Switch schemes, switch initial conditions, push past the CFL bound on forward Euler and watch the explicit scheme blow up while the implicit ones stay stable.

1.150x = 0x = 1u (x, t)t = 0.000
scheme
IC
Ndt
CFL bound (FE): dt < h² / 2α = 0.0098

Things to read off the animation:


Where this is going

We've added time. The next chapter adds vector fields: linear elasticity turns the scalar PDE into a vector PDE, with two displacement components per node and a stress tensor that needs visualizing. The mass matrix story carries over to dynamic elasticity (wave equation, basically); the Crank-Nicolson generalization is called the Newmark scheme, and it's the workhorse of time-domain structural dynamics. Static elasticity, which is what we'll build next, drops the time derivative — it's Poisson's equation again, just vectorial.